Compare commits
309 commits
dependabot
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
ebd4e997bd | ||
|
64f7f37288 | ||
|
c5185e419c | ||
|
eb37dd2833 | ||
|
b860b16e90 | ||
|
7763f34d5d | ||
|
efa44f86db | ||
|
e05ce9631c | ||
|
d0fea9e42d | ||
|
1259b2f178 | ||
|
4f9c135fb9 | ||
|
00f0bd08b0 | ||
|
6222e3f8af | ||
|
c6301a826e | ||
|
a42d90fd86 | ||
|
a71eca2913 | ||
|
adc93b8040 | ||
|
d325be4bec | ||
|
43c9fb86b4 | ||
|
47d863b8a0 | ||
|
f6864e06be | ||
|
de8940398e | ||
|
5eba7700c8 | ||
|
9438a3169d | ||
|
7b1663fb9c | ||
|
7681682445 | ||
|
893342a317 | ||
|
0f08860288 | ||
|
0418e75ece | ||
|
539508d1ab | ||
|
0c670219ff | ||
|
fc32cc283a | ||
|
d5ced0718f | ||
|
daa55d7f58 | ||
|
dfc0aaa8c2 | ||
|
7e49ec210e | ||
|
ff8e2c980d | ||
|
75b52d4003 | ||
|
44f395663f | ||
|
99aed36c00 | ||
|
7c4f65573c | ||
|
331d5dd11d | ||
|
96b6cd564c | ||
|
abdb818cf2 | ||
|
94c927c37f | ||
|
ebd22bc6f2 | ||
|
78e1cd7361 | ||
|
362d7db738 | ||
|
46134e0b37 | ||
|
2a8b2fa05b | ||
|
2987c2d85d | ||
|
14cf6a3996 | ||
|
2bd5dde44e | ||
|
7b91944fbf | ||
|
2b78d506de | ||
|
7fd2a3596b | ||
|
36750b7f17 | ||
|
41591e8d05 | ||
|
bd07bdc2c3 | ||
|
e4d0a93016 | ||
|
859a8f4a01 | ||
|
d44bceb7a8 | ||
|
a01a93f260 | ||
|
4cb74c9358 | ||
|
ca97c5e976 | ||
|
27d66e0c1f | ||
|
44eac1c84f | ||
|
13833ff8b0 | ||
|
b869efe6aa | ||
|
66ba14e330 | ||
|
165176b9a4 | ||
|
025166ead5 | ||
|
4f6eb560fe | ||
|
2d051fedd7 | ||
|
bcdd435e45 | ||
|
a2eb8c2915 | ||
|
eee1735654 | ||
|
bef576a77b | ||
|
2456f87c18 | ||
|
22c3261d27 | ||
|
010aa2f45e | ||
|
4eba638a36 | ||
|
f7cdf212bc | ||
|
71856fed87 | ||
|
544f58ecde | ||
|
3cfc16c9ee | ||
|
bdf3ec8fce | ||
|
e12f5e3d97 | ||
|
be56c1bf85 | ||
|
a3ac1092f9 | ||
|
561052a3f4 | ||
|
645e728178 | ||
|
1d3c92763b | ||
|
653229c8fa | ||
|
aa3c0703dc | ||
|
9898718b1a | ||
|
f66b2326eb | ||
|
898d266ea7 | ||
|
a6104635d5 | ||
|
a0a8462f5f | ||
|
d5ad87dc36 | ||
|
509f18e737 | ||
|
d4e42c68ca | ||
|
e8e243ee09 | ||
|
08b23aade9 | ||
|
62b96dcf10 | ||
|
ee829af279 | ||
|
619ebc70c4 | ||
|
d6652cb2a4 | ||
|
d75a191ded | ||
|
de97d05528 | ||
|
05ee49e7c4 | ||
|
3dde23eb15 | ||
|
5235a6ee87 | ||
|
43df901280 | ||
|
de81de92a9 | ||
|
a4431c43c8 | ||
|
614295b7b2 | ||
|
70a7be0791 | ||
|
182ccc31dd | ||
|
f3a9500f63 | ||
|
d286188a3f | ||
|
4c7137d045 | ||
|
d171f509ce | ||
|
56afb1f663 | ||
|
8ee9ed6259 | ||
|
3cfef16e17 | ||
|
c5bf19ba00 | ||
|
c05f5d987e | ||
|
8b843ac45c | ||
|
90600794eb | ||
|
74aa452df7 | ||
|
8be6bd67bf | ||
|
65bf9b2419 | ||
|
9b55e52439 | ||
|
0ac691a5c8 | ||
|
4551ad2964 | ||
|
3a0a208c99 | ||
|
7a9fcad960 | ||
|
1f44af873c | ||
|
078f5eae83 | ||
|
8ada523124 | ||
|
65a320ab35 | ||
|
3001b1f49c | ||
|
7ba434c71d | ||
|
fce64b744c | ||
|
323c9efbf5 | ||
|
13fc98c66d | ||
|
ab5e34b16a | ||
|
3f67d5ac90 | ||
|
8409e0a0c3 | ||
|
2e2a4a7f90 | ||
|
999a55c65f | ||
|
6413f2fa8e | ||
|
52cd203a53 | ||
|
8476595011 | ||
|
a473a40807 | ||
|
089d69a095 | ||
|
e764f334d1 | ||
|
a215d6c06a | ||
|
4fd7e10f50 | ||
|
7da33e608e | ||
|
fc3004a954 | ||
|
25f0abfe5d | ||
|
eca6802188 | ||
|
b0a223896d | ||
|
b2ccd5f4c1 | ||
|
364c2c8990 | ||
|
4e9c91ef0d | ||
|
8fd5f04ecd | ||
|
684304db9b | ||
|
3bb2b55f21 | ||
|
0d14020a8e | ||
|
e0f4a3f18a | ||
|
f2330cb0e7 | ||
|
aa0088bbc9 | ||
|
72871e4dea | ||
|
087af79229 | ||
|
b10e9dbb82 | ||
|
66231d6076 | ||
|
663a5cecf9 | ||
|
b4fe712825 | ||
|
d085e2e370 | ||
|
3d984fc42b | ||
|
7a78ee026e | ||
|
0a09e50ab9 | ||
|
72137e811d | ||
|
3c5fc01582 | ||
|
444d24c3b9 | ||
|
e02435d736 | ||
|
3291700be5 | ||
|
16c29e6805 | ||
|
e6633d52c3 | ||
|
b570b6c865 | ||
|
7b543bc4f3 | ||
|
716a57f13e | ||
|
14a05801e9 | ||
|
f4fb1f7c3a | ||
|
7654a2fd35 | ||
|
ffa8d4233b | ||
|
efce76a81d | ||
|
5c043a22d2 | ||
|
8cd91af180 | ||
|
2a77a6ff94 | ||
|
e8519393b6 | ||
|
efa44fea4c | ||
|
04a80b7a7a | ||
|
4a98db443b | ||
|
aeb1f22317 | ||
|
782fce4e53 | ||
|
5035403867 | ||
|
4c63d9b022 | ||
|
e1c26a60c6 | ||
|
60dc4df79c | ||
|
b53da763d9 | ||
|
3620ba2916 | ||
|
7787e115fe | ||
|
db92f5081c | ||
|
52d453a482 | ||
|
3cca7572f0 | ||
|
4227d79d86 | ||
|
113d660790 | ||
|
273f1a33de | ||
|
0a980d7f96 | ||
|
128b6616ab | ||
|
8c01312ecb | ||
|
97b8946aef | ||
|
bf05a9fd63 | ||
|
528b4fd452 | ||
|
a962723cb6 | ||
|
974b440752 | ||
|
9bd761e420 | ||
|
e16e52b8b2 | ||
|
ae58c02de2 | ||
|
c40b1e8473 | ||
|
8cffbe9ef6 | ||
|
4111c40471 | ||
|
ebcffd1a42 | ||
|
e2ae62a55b | ||
|
92961f5f88 | ||
|
896e33549b | ||
|
79359dc468 | ||
|
4cb8f86824 | ||
|
968d6d639d | ||
|
a0ccb75762 | ||
|
1761522b7f | ||
|
57c26ff4d9 | ||
|
0aed1f4668 | ||
|
faad87ceac | ||
|
a447f7bf01 | ||
|
4f9e73899a | ||
|
4d70d67c41 | ||
|
2423540675 | ||
|
c9b60fc6d4 | ||
|
1e904f12d7 | ||
|
c7a3f3f150 | ||
|
ac165af833 | ||
|
7fb24a6899 | ||
|
ca1ea11dcb | ||
|
f36ae70146 | ||
|
415b9f507e | ||
|
437c234f8a | ||
|
90253effa6 | ||
|
420586839a | ||
|
e9e73bd128 | ||
|
e373993899 | ||
|
ec62b1155e | ||
|
8a18871b90 | ||
|
f6277bcc83 | ||
|
cc3424d4d8 | ||
|
578c79cec3 | ||
|
d3fa441846 | ||
|
c9388c744e | ||
|
89e20a65db | ||
|
ad2278f5ba | ||
|
3d45e4609c | ||
|
15821eaa47 | ||
|
59af3db87e | ||
|
bb9eccd31c | ||
|
6fd42ef6d5 | ||
|
940efb91e6 | ||
|
231fc25486 | ||
|
1b5057d203 | ||
|
85eb787175 | ||
|
6fb4035ac3 | ||
|
477a6533e8 | ||
|
0571682e73 | ||
|
9fca355c99 | ||
|
fef6a46cb7 | ||
|
4126d2e70d | ||
|
968480605c | ||
|
511338850e | ||
|
58527eae71 | ||
|
92ba396ec0 | ||
|
f55a7f479d | ||
|
6645eda390 | ||
|
2253e8293f | ||
|
d5c3847f94 | ||
|
bac534f5d1 | ||
|
8df1b08fe5 | ||
|
3073f9165c | ||
|
3067485417 | ||
|
9d3a1d81cd | ||
|
387576c16c | ||
|
fd0ecf579d | ||
|
a363becbb9 | ||
|
34e949cd4e | ||
|
ac2323ea0b | ||
|
064bbd3dec |
|
@ -1 +0,0 @@
|
|||
dist
|
47
.eslintrc
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"es2021": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"standard-with-typescript",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:tailwindcss/recommended",
|
||||
"prettier"
|
||||
],
|
||||
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.eslint.json",
|
||||
"warnOnUnsupportedTypeScriptVersion": false
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/prop-types": "off",
|
||||
"import/order": "error",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-confusing-void-expression": "off",
|
||||
"@typescript-eslint/strict-boolean-expressions": "off",
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off",
|
||||
"@typescript-eslint/consistent-type-assertions": "off"
|
||||
}
|
||||
}
|
44
.github/workflows/cd.yml
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: lts/*
|
||||
- uses: pnpm/action-setup@v2.1.0
|
||||
with:
|
||||
version: latest
|
||||
- run: pnpm install --ignore-scripts
|
||||
- run: pnpm wxt prepare
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run check
|
||||
|
||||
release:
|
||||
needs: linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# https://github.com/semantic-release/semantic-release/issues/2636
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: lts/*
|
||||
- uses: pnpm/action-setup@v2.1.0
|
||||
with:
|
||||
version: latest
|
||||
- run: pnpm install --ignore-scripts
|
||||
- run: pnpm wxt prepare
|
||||
- run: pnpm semantic-release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_CHAT_GITHUB_TOKEN }}
|
6
.gitignore
vendored
|
@ -1,5 +1,10 @@
|
|||
dist
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
.wxt
|
||||
.million
|
||||
web-ext.config.ts
|
||||
|
||||
*.DS_Store
|
||||
*.eslintcache
|
||||
|
@ -9,4 +14,5 @@ node_modules
|
|||
*.pem
|
||||
*.xpi
|
||||
*.zip
|
||||
.idea
|
||||
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx commitlint --edit "$1"
|
||||
pnpm commitlint --edit "$1"
|
||||
|
|
|
@ -1,4 +1,2 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged && pnpm tsc:check
|
||||
pnpm lint-staged && pnpm check
|
||||
|
|
1
.npmrc
|
@ -1,2 +1,3 @@
|
|||
engine-strict=true
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=false
|
||||
|
|
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
>= 20
|
|
@ -1,6 +1,13 @@
|
|||
{
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
"autoprefixer": {},
|
||||
"postcss-rem-to-responsive-pixel": {
|
||||
"rootValue": 16,
|
||||
"propList": [
|
||||
"*"
|
||||
],
|
||||
"transformUnit": "px"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
59
.releaserc.mjs
Normal file
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* @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'
|
||||
]
|
||||
}
|
14
.vscode/launch.json
vendored
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "debug vite",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["run-script", "dev"],
|
||||
"runtimeExecutable": "npm",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"type": "node",
|
||||
"sourceMaps": true
|
||||
}
|
||||
]
|
||||
}
|
6
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"eslint.options": {
|
||||
"flags": ["unstable_ts_config"]
|
||||
},
|
||||
"eslint.useFlatConfig": true
|
||||
}
|
415
CHANGELOG.md
Normal file
|
@ -0,0 +1,415 @@
|
|||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add .zip to assets ([273f1a3](https://github.com/molvqingtai/WebChat/commit/273f1a33deb5c8e84aa4c2540a41127f4e41b166))
|
||||
|
||||
## [1.0.3](https://github.com/molvqingtai/WebChat/compare/v1.0.2...v1.0.3) (2024-09-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add packge.json & .zip to assets ([8c01312](https://github.com/molvqingtai/WebChat/commit/8c01312ecb5fa2c27340f123316df112b67e8582))
|
||||
|
||||
## [1.0.2](https://github.com/molvqingtai/WebChat/compare/v1.0.1...v1.0.2) (2024-09-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add packge.json to assets ([528b4fd](https://github.com/molvqingtai/WebChat/commit/528b4fd452fb14974e218b65ac4588c351dd72e4))
|
||||
|
||||
## [1.0.1](https://github.com/molvqingtai/WebChat/compare/v1.0.0...v1.0.1) (2024-09-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add packge.json to assets ([974b440](https://github.com/molvqingtai/WebChat/commit/974b4407520c10b745abcab031898476477dee27))
|
||||
|
||||
# 1.0.0 (2024-09-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* "use px units to fix small font-size in some websites root elements ([1e904f1](https://github.com/molvqingtai/WebChat/commit/1e904f12d791cc030d175cbc35bdee61b8237764))
|
||||
* **css:** prevent some styles from being inherited from the app ([1a8d2ec](https://github.com/molvqingtai/WebChat/commit/1a8d2ec675d53eb2dc3641e52c8e0b1054b1b93f))
|
||||
* hasItemQuery not use get ([15821ea](https://github.com/molvqingtai/WebChat/commit/15821eaa47203178accf7634ebf8af1ca0d33de0))
|
||||
* mesage time update ([90253ef](https://github.com/molvqingtai/WebChat/commit/90253effa616ea0b991f69cd01e7c9eba942645e))
|
||||
* message may not exist ([59af3db](https://github.com/molvqingtai/WebChat/commit/59af3db87e5ae4d9bae621f0020f90238ae7c7ff))
|
||||
* messageId not found ([bb9eccd](https://github.com/molvqingtai/WebChat/commit/bb9eccd31c67f0c921d1bd27aec1b9809a2970c6))
|
||||
* **options:** fix meteors overflow ([c7a3f3f](https://github.com/molvqingtai/WebChat/commit/c7a3f3f150dd7af8c5394ba11323fd6addf2481d))
|
||||
* **setup:** setup page display timing is incorrect ([f6277bc](https://github.com/molvqingtai/WebChat/commit/f6277bcc83d8306c5ca9c8fc269cea6b7760c004))
|
||||
* **userInfo:** fixed infinite loop sync in firefox ([9fca355](https://github.com/molvqingtai/WebChat/commit/9fca355c99cacca116904a6b31f3e953d8a567ba))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add setup page ([578c79c](https://github.com/molvqingtai/WebChat/commit/578c79cec3da369ba9949d1f76d1d6f9540f1e79))
|
||||
* auto-growing Textarea ([98268ce](https://github.com/molvqingtai/WebChat/commit/98268ce09f82b9cbb096e94f28c9c13f30b66301))
|
||||
* implement join and leave prompts ([ec62b11](https://github.com/molvqingtai/WebChat/commit/ec62b1155e4d1d66c9487db41eff1ebac79c199a))
|
||||
* message list implements virtual scrolling ([c9388c7](https://github.com/molvqingtai/WebChat/commit/c9388c744e554a89b1d784c8475fd775207cd806))
|
||||
* peer message working! ([6fb4035](https://github.com/molvqingtai/WebChat/commit/6fb4035ac34a1b64762237a60455612ac1e2a5bf))
|
||||
* **setup:** user and message sync ([cc3424d](https://github.com/molvqingtai/WebChat/commit/cc3424d4d8203fd09d5412f8498d143bf4283ede))
|
||||
* store message records ([c029423](https://github.com/molvqingtai/WebChat/commit/c029423bf9e553cd9000f547f6c7cd28da05896e))
|
||||
* use ualy avatar ([89e20a6](https://github.com/molvqingtai/WebChat/commit/89e20a65db3cdb8e24bad34c5002a25ffd128c47))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* adapt to small screen ([c9b60fc](https://github.com/molvqingtai/WebChat/commit/c9b60fc6d4af83903cbe6bcc4621e5c081417d3f))
|
||||
* add animation effects and add self join message ([437c234](https://github.com/molvqingtai/WebChat/commit/437c234f8a7ba1e02c04cc60bddafd59436a33fd))
|
||||
* add custom scroll bars to scrollable content ([d3fa441](https://github.com/molvqingtai/WebChat/commit/d3fa4418463d47cfe9164086bc00a86ce624b7d7))
|
||||
* add github link ([7fb24a6](https://github.com/molvqingtai/WebChat/commit/7fb24a68990bb37a15ad01f0f97c7b18a148c20c))
|
||||
* app show hide toggle ([ca1ea11](https://github.com/molvqingtai/WebChat/commit/ca1ea11dcbcd1f090f23282127b934afce25fa1c))
|
||||
* **AppContainer:** dynamic width ([3d45e46](https://github.com/molvqingtai/WebChat/commit/3d45e4609c136a98e9994d0c04f64a8d89cb6442))
|
||||
* custom toast style ([f36ae70](https://github.com/molvqingtai/WebChat/commit/f36ae70146736533ef1178af2ac11402cf957b37))
|
||||
* **message:** user name ellipsis ([8a18871](https://github.com/molvqingtai/WebChat/commit/8a18871b90a59ce6e958d600de5993d83c85d322))
|
||||
* multiple peerRoom implementation ([e373993](https://github.com/molvqingtai/WebChat/commit/e37399389974384634089dfe301973e9deea99a0))
|
||||
* multiple Tab for the same user lead to duplicate joining issues ([4205868](https://github.com/molvqingtai/WebChat/commit/420586839ac6e6192caa258b271da948b5f80992))
|
||||
* optimize avatar display ([9d3a1d8](https://github.com/molvqingtai/WebChat/commit/9d3a1d81cdb9df048b0b3c81ff7b091a79891ac7))
|
||||
* optimize style and update deps ([e9e73bd](https://github.com/molvqingtai/WebChat/commit/e9e73bd128d85da08a628e2044e0bdf7b40ebc0a))
|
||||
* **options:** add meteors effect ([ac165af](https://github.com/molvqingtai/WebChat/commit/ac165af833c6797d629afd70117f930a25673778))
|
||||
* remove callbackToObserve ([415b9f5](https://github.com/molvqingtai/WebChat/commit/415b9f507ee9268e11d1e98d8dcb5e22b6f594d3))
|
54
README.md
|
@ -1,6 +1,56 @@
|
|||
# [WIP] WebChat
|
||||
<p align="center">
|
||||
<img src="https://github.com/molvqingtai/WebChat/blob/master/src/public/logo.png" width="200px"/>
|
||||
</p>
|
||||
|
||||
> Chatting Anonymously with People on the Same Website.
|
||||
# WebChat
|
||||
|
||||
[![GitHub License](https://img.shields.io/github/license/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/cpaedhbidlpnbdfegakhiamfpndhjpgf)](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [![GitHub Release](https://img.shields.io/github/v/release/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/releases)
|
||||
|
||||
> Chat with anyone on any website
|
||||
|
||||
This is an anonymous chat browser extension that is decentralized and serverless, utilizing WebRTC for end-to-end encrypted communication. It prioritizes privacy, with all data stored locally.
|
||||
|
||||
The aim is to add chat room functionality to any website, you'll never feel alone again.
|
||||
|
||||
### Install
|
||||
|
||||
**Install from Store**
|
||||
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf)
|
||||
- [Edge Web Store](https://microsoftedge.microsoft.com/addons/detail/mmfdplbomjjlgdffecapcpgjmhfhmiob)
|
||||
- [Firefox Addons](https://addons.mozilla.org/firefox/addon/webchat/)
|
||||
|
||||
**Manual Installation**
|
||||
|
||||
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.
|
||||
|
||||
- **[remesh](https://github.com/remesh-js/remesh)**: A framework in JavaScript that implements DDD principles, achieving true separation of UI and logic, allowing for easy implementation of the UI part, such as rewriting it in Vue, due to its independence from the UI.
|
||||
|
||||
- **[shadcn/ui](https://ui.shadcn.com/)**: A beautiful UI library and a pioneer of the no-install concept, offering unmatched convenience in customizing styles.
|
||||
|
||||
- **[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.~~
|
||||
- **[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
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "@/index.css",
|
||||
"css": "@/assets/styles.tailwind.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
|
|
42
eslint.config.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
// import type { Linter } from 'eslint'
|
||||
import globals from 'globals'
|
||||
import pluginJs from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactPlugin from '@eslint-react/eslint-plugin'
|
||||
import tailwindPlugin from 'eslint-plugin-tailwindcss'
|
||||
import prettierPlugin from 'eslint-plugin-prettier/recommended'
|
||||
import * as tsParser from '@typescript-eslint/parser'
|
||||
|
||||
export default [
|
||||
{ files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
|
||||
{
|
||||
languageOptions: { globals: globals.browser }
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tailwindPlugin.configs['flat/recommended'],
|
||||
prettierPlugin,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
...reactPlugin.configs.recommended,
|
||||
languageOptions: {
|
||||
parser: tsParser
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['**/.output/*', '**/.wxt/*', '**/ui/**', '**/magicui/**', '**/lib/**', '**.million**']
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@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'
|
||||
}
|
||||
}
|
||||
]
|
||||
// satisfies Linter.Config[]
|
15
index.html
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Chatting Anonymously with People on the Same Website." />
|
||||
<link rel="shortcut icon" href="https://github.com/shadcn.png" type="image/x-icon" />
|
||||
<title>WebChat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<h1>WebChat Dev</h1>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
16
manifest.ts
|
@ -1,16 +0,0 @@
|
|||
import { defineManifest } from '@crxjs/vite-plugin'
|
||||
import packageJson from './package.json'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
export default defineManifest({
|
||||
manifest_version: 3,
|
||||
name: packageJson.displayName,
|
||||
version: packageJson.version,
|
||||
content_scripts: [
|
||||
{
|
||||
js: ['src/main.tsx'],
|
||||
matches: isDev ? ['*://localhost/*', 'https://www.example.com/*'] : ['https://*/*']
|
||||
}
|
||||
]
|
||||
})
|
178
package.json
|
@ -1,21 +1,23 @@
|
|||
{
|
||||
"name": "web-chat",
|
||||
"displayName": "WebChat",
|
||||
"version": "0.0.1",
|
||||
"description": "Chatting Anonymously with People on the Same Website.",
|
||||
"version": "1.7.1",
|
||||
"description": "Chat with anyone on any website.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --force",
|
||||
"dev:web": "vite -c vite.config.web.ts --force",
|
||||
"build": "vite build",
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "cross-env NODE_ENV=production run-p build:*",
|
||||
"build:chrome": "wxt build -b chrome",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"pack": "cross-env NODE_ENV=production run-p pack:*",
|
||||
"pack:zip": "rimraf dist.zip && jszip-cli add dist/* -o ./dist.zip",
|
||||
"pack:crx": "crx pack dist -o ./dist.crx",
|
||||
"pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./dist --filename dist.xpi --overwrite-dest",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --cache --fix",
|
||||
"clear": "rimraf dist dist.*",
|
||||
"tsc:check": "tsc --noEmit",
|
||||
"prepare": "husky install"
|
||||
"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",
|
||||
"prepare": "husky",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -40,79 +42,101 @@
|
|||
"bugs": {
|
||||
"url": "https://github.com/molvqingtai/WebChat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/molvqingtai/WebChat#readme",
|
||||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@number-flow/react": "^0.3.2",
|
||||
"@perfsee/jsonr": "^1.13.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-portal": "^1.1.2",
|
||||
"@radix-ui/react-presence": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@resreq/event-hub": "^1.6.0",
|
||||
"@resreq/timer": "^1.1.6",
|
||||
"@rtco/client": "^0.2.17",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@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",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.456.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remesh": "^4.2.2",
|
||||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"type-fest": "^4.26.1",
|
||||
"unstorage": "^1.13.1",
|
||||
"valibot": "1.0.0-beta.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.6.6",
|
||||
"@commitlint/config-conventional": "^17.6.6",
|
||||
"@crxjs/vite-plugin": "2.0.0-beta.18",
|
||||
"@ffflorian/jszip-cli": "^3.4.1",
|
||||
"@types/node": "^20.4.2",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/webextension-polyfill": "^0.10.1",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@eslint-react/eslint-plugin": "^1.16.1",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.14.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"crx": "^5.0.1",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-standard-with-typescript": "^36.0.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-n": "^16.0.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-tailwindcss": "^3.13.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.3",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.4.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.25",
|
||||
"prettier": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"semantic-release": "^24.2.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.1.6",
|
||||
"unplugin-icons": "^0.16.5",
|
||||
"vite": "^4.4.3",
|
||||
"web-ext": "^7.6.2",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.14.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
"wxt": "^0.19.15"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@perfsee/jsonr": "^1.8.2",
|
||||
"@radix-ui/react-avatar": "^1.0.3",
|
||||
"@radix-ui/react-hover-card": "^1.0.6",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.263.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"peerjs": "^1.4.7",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-nice-avatar": "^1.4.1",
|
||||
"react-use": "^17.4.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remesh": "^4.2.0",
|
||||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"type-fest": "^3.13.0"
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
|
16121
pnpm-lock.yaml
18
src/App.tsx
|
@ -1,18 +0,0 @@
|
|||
import Header from '@/views/Header'
|
||||
import Footer from '@/views/Footer'
|
||||
import Main from '@/views/Main'
|
||||
import AppButton from '@/views/AppButton'
|
||||
import AppContainer from '@/views/AppContainer'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
</AppContainer>
|
||||
<AppButton></AppButton>
|
||||
</>
|
||||
)
|
||||
}
|
64
src/app/background/index.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { EVENT } from '@/constants/event'
|
||||
import { messenger } from '@/messenger'
|
||||
import { browser, Tabs } from 'wxt/browser'
|
||||
import { defineBackground } from 'wxt/sandbox'
|
||||
|
||||
export default defineBackground({
|
||||
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.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)
|
||||
})
|
||||
}
|
||||
})
|
122
src/app/content/App.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import '@webcomponents/custom-elements'
|
||||
import Header from '@/app/content/views/Header'
|
||||
import Footer from '@/app/content/views/Footer'
|
||||
import Main from '@/app/content/views/Main'
|
||||
import AppButton from '@/app/content/views/AppButton'
|
||||
import AppMain from '@/app/content/views/AppMain'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import Setup from '@/app/content/views/Setup'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import DanmakuContainer from './components/DanmakuContainer'
|
||||
import DanmakuDomain from '@/domain/Danmaku'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import VirtualRoomDomain from '@/domain/VirtualRoom'
|
||||
|
||||
/**
|
||||
* Fix requestAnimationFrame error in jest
|
||||
* @see https://github.com/facebook/react/issues/16606
|
||||
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1469304
|
||||
*/
|
||||
if (import.meta.env.FIREFOX) {
|
||||
window.requestAnimationFrame = window.requestAnimationFrame.bind(window)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const send = useRemeshSend()
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
||||
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
||||
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
|
||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||
const chatRoomJoinIsFinished = useRemeshQuery(chatRoomDomain.query.JoinIsFinishedQuery())
|
||||
const virtualRoomJoinIsFinished = useRemeshQuery(virtualRoomDomain.query.JoinIsFinishedQuery())
|
||||
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||
|
||||
const joinRoom = () => {
|
||||
send(chatRoomDomain.command.JoinRoomCommand())
|
||||
send(virtualRoomDomain.command.JoinRoomCommand())
|
||||
}
|
||||
|
||||
const leaveRoom = () => {
|
||||
chatRoomJoinIsFinished && send(chatRoomDomain.command.LeaveRoomCommand())
|
||||
virtualRoomJoinIsFinished && send(virtualRoomDomain.command.LeaveRoomCommand())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messageListLoadFinished) {
|
||||
if (userInfoSetFinished) {
|
||||
joinRoom()
|
||||
} 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])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', leaveRoom)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', leaveRoom)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const themeMode =
|
||||
userInfo?.themeMode === 'system'
|
||||
? checkDarkMode()
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: (userInfo?.themeMode ?? (checkDarkMode() ? 'dark' : 'light'))
|
||||
|
||||
const danmakuContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
19
src/app/content/components/DanmakuContainer.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { cn } from '@/utils'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export interface DanmakuContainerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed left-0 top-0 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
|
||||
ref={ref}
|
||||
></div>
|
||||
)
|
||||
})
|
||||
|
||||
DanmakuContainer.displayName = 'DanmakuContainer'
|
||||
|
||||
export default DanmakuContainer
|
38
src/app/content/components/DanmakuMessage.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: TextMessage
|
||||
className?: string
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseEnter?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseLeave?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouseEnter, onMouseLeave }) => {
|
||||
return (
|
||||
<Button
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex justify-center pointer-events-auto visible gap-x-2 border border-slate-50 px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="max-w-44 truncate">{data.body}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
DanmakuMessage.displayName = 'DanmakuMessage'
|
||||
|
||||
export default DanmakuMessage
|
|
@ -3,14 +3,14 @@ import { useState, type FC } from 'react'
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EMOJI_LIST } from '@/constants'
|
||||
import { EMOJI_LIST } from '@/constants/config'
|
||||
import { chunk } from '@/utils'
|
||||
|
||||
export interface EmojiButtonProps {
|
||||
onSelect?: (value: string) => void
|
||||
}
|
||||
|
||||
const emojiGroups = chunk(EMOJI_LIST, 8)
|
||||
const emojiGroups = chunk([...EMOJI_LIST], 6)
|
||||
|
||||
// BUG: https://github.com/radix-ui/primitives/pull/2433
|
||||
// BUG https://github.com/radix-ui/primitives/issues/1666
|
||||
|
@ -30,20 +30,23 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
|
|||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" className="dark:text-white">
|
||||
<SmileIcon size={20} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-top w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}>
|
||||
<ScrollArea className="h-72 w-72 px-3">
|
||||
<PopoverContent
|
||||
className="z-infinity w-64 overflow-hidden rounded-xl p-0 dark:bg-slate-900"
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<ScrollArea className="size-64 p-1">
|
||||
{emojiGroups.map((group, index) => {
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-8">
|
||||
<div key={index} className="grid grid-cols-6">
|
||||
{group.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="sm"
|
||||
className="text-base"
|
||||
size="icon"
|
||||
className="text-xl"
|
||||
variant="ghost"
|
||||
onClick={() => handleSelect(emoji)}
|
||||
>
|
18
src/app/content/components/FormatDate.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { format as formatDate } from 'date-fns'
|
||||
import { type FC } from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
|
||||
export interface FormatDateProps {
|
||||
date: Date | number | string
|
||||
format?: string
|
||||
asChild?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FormatDate: FC<FormatDateProps> = ({ date, format = 'yyyy/MM/dd HH:mm:ss', asChild = false, ...props }) => {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
return <Comp {...props}>{formatDate(date, format)}</Comp>
|
||||
}
|
||||
|
||||
FormatDate.displayName = 'FormatDate'
|
||||
export default FormatDate
|
34
src/app/content/components/ImageButton.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { createElement } from '@/utils'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
|
||||
export interface ImageButtonProps {
|
||||
onSelect?: (file: File) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ImageButton = ({ onSelect, disabled }: ImageButtonProps) => {
|
||||
const handleClick = () => {
|
||||
const input = createElement<HTMLInputElement>(`<input type="file" accept="image/png,image/jpeg,image/webp" />`)
|
||||
|
||||
input.addEventListener(
|
||||
'change',
|
||||
async (e: Event) => {
|
||||
onSelect?.((e.target as HTMLInputElement).files![0])
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
input.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button disabled={disabled} onClick={handleClick} variant="ghost" size="icon" className="dark:text-white">
|
||||
<ImageIcon size={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ImageButton.displayName = 'ImageButton'
|
||||
|
||||
export default ImageButton
|
|
@ -1,6 +1,7 @@
|
|||
import { type MouseEvent, type FC, type ReactElement } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/utils'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
|
||||
export interface LikeButtonIconProps {
|
||||
children: JSX.Element
|
||||
|
@ -33,14 +34,18 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
|
|||
onClick={handleClick}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all',
|
||||
checked ? 'text-orange-500' : 'text-slate-500',
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none dark:bg-slate-600',
|
||||
checked ? 'text-orange-500' : 'text-slate-500 dark:text-slate-100',
|
||||
count ? 'grid-cols-[auto_1fr] gap-x-1' : 'grid-cols-[auto_0fr] gap-x-0'
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{children}
|
||||
{!!count && <span className="min-w-0 text-xs">{count}</span>}
|
||||
{!!count && (
|
||||
<span className="min-w-0 text-xs">
|
||||
{import.meta.env.FIREFOX ? <span className="tabular-nums">{count}</span> : <NumberFlow value={count} />}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
89
src/app/content/components/MessageInput.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent, ClipboardEvent } from 'react'
|
||||
|
||||
import { cn } from '@/utils'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import LoadingIcon from '@/assets/images/loading.svg'
|
||||
|
||||
export interface MessageInputProps {
|
||||
value?: string
|
||||
className?: string
|
||||
maxLength?: number
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
|
||||
*
|
||||
* @see https://github.com/w3c/csswg-drafts/issues/4603
|
||||
*/
|
||||
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
(
|
||||
{
|
||||
value = '',
|
||||
className,
|
||||
maxLength = 500,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
autoFocus,
|
||||
disabled,
|
||||
loading
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className={cn(
|
||||
'box-border resize-none whitespace-pre-wrap break-words border-none bg-slate-100 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800',
|
||||
{
|
||||
'disabled:opacity-100': loading
|
||||
}
|
||||
)}
|
||||
rows={2}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder="Type your message here."
|
||||
onInput={onInput}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<div
|
||||
className={cn('absolute bottom-1 right-3 rounded-lg text-xs text-slate-400', {
|
||||
'opacity-50': disabled || loading
|
||||
})}
|
||||
>
|
||||
{value?.length ?? 0}/{maxLength}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-800 after:absolute after:inset-0 after:backdrop-blur-xs dark:text-slate-100">
|
||||
<LoadingIcon className="relative z-10 size-10"></LoadingIcon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MessageInput.displayName = 'MessageInput'
|
||||
|
||||
export default MessageInput
|
95
src/app/content/components/MessageItem.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { type FC } from 'react'
|
||||
import { FrownIcon, HeartIcon } from 'lucide-react'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import { Markdown } from '@/components/Markdown'
|
||||
import { type NormalMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: NormalMessage
|
||||
index?: number
|
||||
like: boolean
|
||||
hate: boolean
|
||||
onLikeChange?: (checked: boolean) => void
|
||||
onHateChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MessageItem: FC<MessageItemProps> = (props) => {
|
||||
const handleLikeChange = (checked: boolean) => {
|
||||
props.onLikeChange?.(checked)
|
||||
}
|
||||
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
|
||||
)}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
||||
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none dark:text-slate-600">
|
||||
<LikeButton
|
||||
checked={props.like}
|
||||
onChange={(checked) => handleLikeChange(checked)}
|
||||
count={props.data.likeUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<HeartIcon size={14}></HeartIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
<LikeButton
|
||||
checked={props.hate}
|
||||
onChange={(checked) => handleHateChange(checked)}
|
||||
count={props.data.hateUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<FrownIcon size={14}></FrownIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItem.displayName = 'MessageItem'
|
||||
|
||||
export default MessageItem
|
30
src/app/content/components/MessageList.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { FC, useState, type ReactElement } from 'react'
|
||||
|
||||
import { type MessageItemProps } from './MessageItem'
|
||||
import { type PromptItemProps } from './PromptItem'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
export interface MessageListProps {
|
||||
children?: Array<ReactElement<MessageItemProps | PromptItemProps>>
|
||||
}
|
||||
const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<ScrollArea ref={setScrollParentRef} className="dark:bg-slate-900">
|
||||
<Virtuoso
|
||||
defaultItemHeight={108}
|
||||
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
|
||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
data={children}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(_: any, item: ReactElement<MessageItemProps | PromptItemProps>) => item}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
MessageList.displayName = 'MessageList'
|
||||
|
||||
export default MessageList
|
29
src/app/content/components/PromptItem.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { PromptMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: PromptMessage
|
||||
className?: string
|
||||
}
|
||||
|
||||
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">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{data.body}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PromptItem.displayName = 'PromptItem'
|
||||
|
||||
export default PromptItem
|
78
src/app/content/index.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
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 { 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 '@/assets/styles/tailwind.css'
|
||||
import NotificationDomain from '@/domain/Notification'
|
||||
import { createElement } from '@/utils'
|
||||
|
||||
export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
runAt: 'document_end',
|
||||
matches: ['https://*/*'],
|
||||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
|
||||
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()] : []
|
||||
})
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: __NAME__,
|
||||
position: 'inline',
|
||||
anchor: 'body',
|
||||
append: 'last',
|
||||
mode: 'open',
|
||||
isolateEvents: ['keyup', 'keydown', 'keypress'],
|
||||
onMount: (container) => {
|
||||
const app = createElement('<div id="root"></div>')
|
||||
container.append(app)
|
||||
const root = createRoot(app)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<RemeshRoot store={store}>
|
||||
<RemeshScope domains={[NotificationDomain()]}>
|
||||
<App />
|
||||
</RemeshScope>
|
||||
</RemeshRoot>
|
||||
</React.StrictMode>
|
||||
)
|
||||
return root
|
||||
},
|
||||
onRemove: (root) => {
|
||||
root?.unmount()
|
||||
}
|
||||
})
|
||||
ui.mount()
|
||||
}
|
||||
})
|
170
src/app/content/views/AppButton/index.tsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
import { type FC, useState, type MouseEvent, useEffect } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
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'
|
||||
|
||||
export interface AppButtonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
const send = useRemeshSend()
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const appPosition = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||
|
||||
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||
|
||||
const isDarkMode = userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkDarkMode()
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
setRef: appButtonRef
|
||||
} = useDraggable({
|
||||
initX: appPosition.x,
|
||||
initY: appPosition.y,
|
||||
minX: 50,
|
||||
maxX: window.innerWidth - 50,
|
||||
maxY: window.innerHeight - 22,
|
||||
minY: 750
|
||||
})
|
||||
|
||||
useWindowResize(({ width, height }) => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||
}, [x, y])
|
||||
|
||||
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||
|
||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setMenuOpen(!menuOpen)
|
||||
}
|
||||
|
||||
const handleSwitchTheme = () => {
|
||||
if (userInfo) {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
||||
} else {
|
||||
handleOpenOptionsPage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
messenger.sendMessage(EVENT.OPTIONS_PAGE_OPEN, undefined)
|
||||
}
|
||||
|
||||
const handleToggleApp = () => {
|
||||
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
|
||||
}
|
||||
|
||||
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%)'
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
className="z-10 grid gap-y-3"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300',
|
||||
isDarkMode ? 'top-0' : '-top-10',
|
||||
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
|
||||
)}
|
||||
>
|
||||
<MoonIcon size={20} />
|
||||
<SunIcon size={20} />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenOptionsPage}
|
||||
variant="outline"
|
||||
className="size-10 rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
ref={appButtonRef}
|
||||
variant="outline"
|
||||
className="size-10 cursor-grab rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<HandIcon size={20} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={handleToggleApp}
|
||||
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%)]"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{hasUnreadQuery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
|
||||
></span>
|
||||
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden"></DayLogo>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
AppButton.displayName = 'AppButton'
|
||||
|
||||
export default AppButton
|
69
src/app/content/views/AppMain/index.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { type ReactNode, type FC, useState } from 'react'
|
||||
import useResizable from '@/hooks/useResizable'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import { cn } from '@/utils'
|
||||
import useWindowResize from '@/hooks/useWindowResize'
|
||||
|
||||
export interface AppMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppMain: FC<AppMainProps> = ({ children, className }) => {
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||
|
||||
const { width } = useWindowResize()
|
||||
|
||||
const isOnRightSide = x >= width / 2 + 50
|
||||
|
||||
const { size, setRef } = useResizable({
|
||||
initSize: Math.max(375, width / 6),
|
||||
maxSize: Math.max(Math.min(750, width / 3), 375),
|
||||
minSize: Math.max(375, width / 6),
|
||||
direction: isOnRightSide ? 'left' : 'right'
|
||||
})
|
||||
|
||||
const [isAnimationComplete, setAnimationComplete] = useState(false)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{appOpenStatus && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, x: isOnRightSide ? '-100%' : '0' }}
|
||||
animate={{ opacity: 1, y: 0, x: isOnRightSide ? '-100%' : '0' }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3, ease: 'linear' }}
|
||||
onAnimationEnd={() => setAnimationComplete(true)}
|
||||
onAnimationStart={() => setAnimationComplete(false)}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
left: `${x}px`,
|
||||
bottom: `calc(100vh - ${y}px + 22px)`
|
||||
}}
|
||||
className={cn(
|
||||
`fixed inset-y-10 right-10 z-infinity mb-0 mt-auto box-border grid max-h-[min(calc(100vh_-60px),_1000px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl`,
|
||||
className,
|
||||
{ 'transition-transform': isAnimationComplete }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
'absolute inset-y-3 z-infinity w-1 dark:bg-slate-600 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
|
||||
isOnRightSide ? '-left-0.5' : '-right-0.5'
|
||||
)}
|
||||
></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
AppMain.displayName = 'AppMain'
|
||||
|
||||
export default AppMain
|
376
src/app/content/views/Footer/index.tsx
Normal file
|
@ -0,0 +1,376 @@
|
|||
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC, ClipboardEvent } from 'react'
|
||||
import { CornerDownLeftIcon } from 'lucide-react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import MessageInput from '../../components/MessageInput'
|
||||
import EmojiButton from '../../components/EmojiButton'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||
import useShareRef from '@/hooks/useShareRef'
|
||||
import { Presence } from '@radix-ui/react-presence'
|
||||
import { Portal } from '@radix-ui/react-portal'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
import ImageButton from '../../components/ImageButton'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
||||
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const autoCompleteListRef = useRef<HTMLDivElement>(null)
|
||||
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
|
||||
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
|
||||
const isComposing = useRef(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [inputLoading, setInputLoading] = useState(false)
|
||||
|
||||
const shareRef = useShareRef(inputRef, setRef)
|
||||
|
||||
/**
|
||||
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
|
||||
*/
|
||||
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
|
||||
const imageRecord = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const updateAtUserAtRecord = useMemo(
|
||||
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
|
||||
const positions: [number, number] = [start, end]
|
||||
|
||||
// If the editing position is before the end position of @user, update the editing position.
|
||||
// "@user" => "E@user"
|
||||
// "@user" => "@useEr"
|
||||
// "@user" => "@user @user"
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
const positionList = [...item].map<[number, number]>((item) => {
|
||||
const inBefore = Math.min(start, end) <= item[1]
|
||||
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
|
||||
})
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
})
|
||||
|
||||
// Insert a new @user record
|
||||
if (atUserId) {
|
||||
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
|
||||
}
|
||||
|
||||
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
|
||||
// Filter out records where the stored position does not match the actual position.
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const positionList = [...item].filter((item) => {
|
||||
const username = message.slice(item[0], item[1] + 1)
|
||||
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||
})
|
||||
if (positionList.length) {
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
} else {
|
||||
atUserRecord.current.delete(userId)
|
||||
}
|
||||
})
|
||||
},
|
||||
[userList]
|
||||
)
|
||||
|
||||
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
|
||||
const [searchNameKeyword, setSearchNameKeyword] = useState('')
|
||||
|
||||
const autoCompleteList = useMemo(() => {
|
||||
return userList
|
||||
.filter((user) => user.userId !== userInfo?.id)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||
}))
|
||||
.toSorted((a, b) => b.similarity - a.similarity)
|
||||
}, [searchNameKeyword, userList, userInfo])
|
||||
|
||||
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
|
||||
|
||||
// Replace the hash URL in ![Image](hash:${hash}) with base64 and update the atUserRecord.
|
||||
const transformMessage = async (message: string) => {
|
||||
let newMessage = message
|
||||
const matchList = [...message.matchAll(/!\[Image\]\(hash:([^\s)]+)\)/g)]
|
||||
matchList?.forEach((match) => {
|
||||
const base64 = imageRecord.current.get(match[1])
|
||||
if (base64) {
|
||||
const base64Syntax = `![Image](${base64})`
|
||||
const hashSyntax = match[0]
|
||||
const startIndex = match.index
|
||||
const endIndex = startIndex + base64Syntax.length - hashSyntax.length
|
||||
newMessage = newMessage.replace(hashSyntax, base64Syntax)
|
||||
updateAtUserAtRecord(newMessage, startIndex, endIndex, 0)
|
||||
}
|
||||
})
|
||||
return newMessage
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!`${message}`.trim()) {
|
||||
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||
}
|
||||
const transformedMessage = await transformMessage(message)
|
||||
const atUsers = [...atUserRecord.current]
|
||||
.map(([userId, positions]) => {
|
||||
const user = userList.find((user) => user.userId === userId)
|
||||
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const newMessage = { body: transformedMessage, atUsers }
|
||||
const byteSize = getTextByteSize(JSON.stringify(newMessage))
|
||||
|
||||
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
|
||||
}
|
||||
|
||||
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const length = autoCompleteList.length
|
||||
const prevIndex = selectedUserIndex
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
const index = (prevIndex + 1) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const index = (prevIndex - 1 + length) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const isDeleteAt = message.at(selectionStart - 1) === '@'
|
||||
setAutoCompleteListShow(!isDeleteAt)
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
setSelectedUserIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (isComposing.current) return
|
||||
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
handleInjectAtSyntax(selectedUser.username)
|
||||
} else {
|
||||
handleSend()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const currentMessage = e.target.value
|
||||
|
||||
if (autoCompleteListShow) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
if (target.value) {
|
||||
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
|
||||
if (atIndex !== -1) {
|
||||
const keyword = target.value.slice(atIndex + 1, selectionEnd)
|
||||
setSearchNameKeyword(keyword)
|
||||
setSelectedUserIndex(0)
|
||||
virtuosoRef.current?.scrollIntoView({ index: 0 })
|
||||
}
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const event = e.nativeEvent as InputEvent
|
||||
|
||||
if (event.data === '@' && autoCompleteList.length) {
|
||||
setAutoCompleteListShow(true)
|
||||
}
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionStart + currentMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(currentMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||
}
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.nativeEvent.clipboardData?.files[0]
|
||||
if (['image/png', 'image/jpeg', 'image/webp'].includes(file?.type ?? '')) {
|
||||
handleInjectImage(file!)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInjectEmoji = (emoji: string) => {
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionEnd + newMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current?.setSelectionRange(end, end)
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleInjectImage = async (file: File) => {
|
||||
try {
|
||||
setInputLoading(true)
|
||||
|
||||
const blob = await compressImage({
|
||||
input: file,
|
||||
targetSize: 30 * 1024,
|
||||
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
|
||||
})
|
||||
|
||||
const base64 = await blobToBase64(blob)
|
||||
const hash = nanoid()
|
||||
const newMessage = `${message.slice(0, selectionEnd)}![Image](hash:${hash})${message.slice(selectionEnd)}`
|
||||
|
||||
const start = selectionStart
|
||||
const end = selectionEnd + newMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
|
||||
imageRecord.current.set(hash, base64)
|
||||
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current?.setSelectionRange(end, end)
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
} catch (error) {
|
||||
send(toastDomain.command.ErrorCommand((error as Error).message))
|
||||
} finally {
|
||||
setInputLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInjectAtSyntax = (username: string) => {
|
||||
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
|
||||
// Determine if there is a space before @
|
||||
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
|
||||
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
|
||||
|
||||
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
|
||||
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
|
||||
|
||||
setAutoCompleteListShow(false)
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = atIndex
|
||||
const end = selectionStart + newMessage.length - message.length
|
||||
|
||||
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
|
||||
|
||||
// Calculate the difference after replacing @text with @user
|
||||
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||
|
||||
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current!.setSelectionRange(end, end)
|
||||
inputRef.current!.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const root = getRootNode()
|
||||
|
||||
return (
|
||||
<div className="relative z-10 grid gap-y-2 rounded-b-xl px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent dark:bg-slate-900 before:dark:from-slate-900">
|
||||
<Presence present={autoCompleteListShow}>
|
||||
<Portal
|
||||
container={root}
|
||||
ref={shareAutoCompleteListRef}
|
||||
className="fixed z-infinity w-36 -translate-y-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
style={{ left: `min(${x}px, 100vw - 160px)`, top: `${y}px` }}
|
||||
>
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={autoCompleteList}
|
||||
defaultItemHeight={28}
|
||||
context={{ currentItemIndex: selectedUserIndex }}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
onClick={() => handleInjectAtSyntax(user.username)}
|
||||
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||
{
|
||||
'bg-accent text-accent-foreground': index === selectedUserIndex
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</Portal>
|
||||
</Presence>
|
||||
<MessageInput
|
||||
ref={shareRef}
|
||||
value={message}
|
||||
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>
|
||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||
<span className="mr-2">Send</span>
|
||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.displayName = 'Footer'
|
||||
|
||||
export default Footer
|
176
src/app/content/views/Header/index.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
import { useState, type FC } from 'react'
|
||||
import { Globe2Icon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn, getSiteInfo } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import VirtualRoomDomain, { FromInfo, RoomUser } from '@/domain/VirtualRoom'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import AvatarCircles from '@/components/magicui/AvatarCircles'
|
||||
import Link from '@/components/Link'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
|
||||
const chatOnlineCount = chatUserList.length
|
||||
|
||||
const virtualOnlineGroup = virtualUserList
|
||||
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
|
||||
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
|
||||
const existSite = acc.find((group) => group.origin === item.from.origin)
|
||||
if (existSite) {
|
||||
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
|
||||
!existUser && existSite.users.push(item.user)
|
||||
} else {
|
||||
acc.push({ ...item.from, users: [item.user] })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
|
||||
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg dark:bg-slate-950">
|
||||
<Avatar className="size-8 rounded-sm">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
</AvatarFallback>
|
||||
</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">
|
||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||
<ScrollArea type="scroll" className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={chatUserList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={chatUserListScrollParentRef!}
|
||||
itemContent={(_index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Header.displayName = 'Header'
|
||||
|
||||
export default Header
|
66
src/app/content/views/Main/index.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { type FC } from 'react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
|
||||
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 MessageListDomain from '@/domain/MessageList'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
|
||||
const messageList = _messageList
|
||||
.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(chatRoomDomain.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>
|
||||
) : (
|
||||
<PromptItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||
></PromptItem>
|
||||
)
|
||||
)}
|
||||
</MessageList>
|
||||
)
|
||||
}
|
||||
|
||||
Main.displayName = 'Main'
|
||||
|
||||
export default Main
|
132
src/app/content/views/Setup/index.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
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 { 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 { motion } from 'framer-motion'
|
||||
|
||||
const mockTextList = [
|
||||
`你問我支持不支持,我說我支持`,
|
||||
`我就明確告訴你,你們啊,我感覺你們新聞界還要學習一個,你們非常熟悉西方的那一套`,
|
||||
`你們畢竟還 “too young”`,
|
||||
`明白我的意思吧?`,
|
||||
`我告訴你們我是身經百戰了,見得多了`,
|
||||
`西方的那個國家我沒去過?`,
|
||||
`媒體他們...你們要知道美國的華萊士,比你們不知道高到哪裏去了,我跟他談笑風生`,
|
||||
`其實媒體呀,還是要提高自己的知識水平,識得唔識得呀?`,
|
||||
`你們有一個好,全世界跑到什么地方,你們比其他的西方記者跑得還快`,
|
||||
`但是呢問來問去的問題呀`,
|
||||
`都 “too simple sometimes naive”`,
|
||||
`懂了沒啊,識得唔識得呀?`,
|
||||
`我很抱歉,我今天是作爲一個長者給你們講`,
|
||||
`我不是新聞工作者,但是我見得太多了`,
|
||||
`我有這個必要好告訴你們一點人生的經驗`,
|
||||
`![ExampleImage](${ExampleImage})`
|
||||
]
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
type: MessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||
hateUsers: [],
|
||||
atUsers: []
|
||||
}
|
||||
}
|
||||
|
||||
const Setup: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>()
|
||||
|
||||
const handleSetup = () => {
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||
}
|
||||
|
||||
const refreshUserInfo = async () => {
|
||||
const userInfo = await generateUserInfo()
|
||||
setUserInfo(userInfo)
|
||||
return userInfo
|
||||
}
|
||||
const createMessage = async (userInfo: UserInfo) => {
|
||||
const message = await generateMessage(userInfo!)
|
||||
send(messageListDomain.command.CreateItemCommand(message))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = new Timer(
|
||||
async () => {
|
||||
await createMessage(await refreshUserInfo())
|
||||
},
|
||||
{ delay: 2000, immediate: true, limit: mockTextList.length }
|
||||
)
|
||||
timer.start()
|
||||
return () => {
|
||||
timer.stop()
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex rounded-xl bg-black/10 shadow-2xl backdrop-blur-sm">
|
||||
<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" />
|
||||
<AvatarFallback>
|
||||
<UserIcon size={30} className="text-slate-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</BlurFade>
|
||||
<div className="flex" key={userInfo?.name}>
|
||||
<motion.div
|
||||
className="text-2xl font-bold text-primary"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
@
|
||||
</motion.div>
|
||||
<WordPullUp className="text-2xl font-bold text-primary" words={`${userInfo?.name || ''.padEnd(10, ' ')}`} />
|
||||
</div>
|
||||
<PulsatingButton onClick={handleSetup}>Start chatting</PulsatingButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Setup.displayName = 'Setup'
|
||||
|
||||
export default Setup
|
35
src/app/options/App.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Toaster } from 'sonner'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
75
src/app/options/components/AvatarSelect.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React from 'react'
|
||||
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'
|
||||
|
||||
export interface AvatarSelectProps {
|
||||
value?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
compressSize?: number
|
||||
onSuccess?: (blob: string) => void
|
||||
onWarning?: (error: Error) => void
|
||||
onError?: (error: Error) => void
|
||||
onChange?: (src: string) => void
|
||||
}
|
||||
|
||||
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
||||
({ onChange, value, onError, onWarning, onSuccess, className, compressSize = 8 * 1024, disabled }, ref) => {
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
if (!/image\/(png|jpeg|webp)/.test(file.type)) {
|
||||
onWarning?.(new Error('Only PNG, JPEG and WebP image are supported.'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* 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)
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Label className="contents">
|
||||
<Avatar
|
||||
tabIndex={disabled ? -1 : 1}
|
||||
className={cn(
|
||||
'group h-24 w-24 cursor-pointer border-4 border-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
{
|
||||
'cursor-not-allowed': disabled,
|
||||
'opacity-50': disabled
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AvatarImage src={value} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>
|
||||
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<input
|
||||
ref={ref}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
)
|
||||
AvatarSelect.displayName = 'AvatarSelect'
|
||||
|
||||
export default AvatarSelect
|
21
src/app/options/components/BadgeList.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
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">
|
||||
<Button asChild size="lg" variant="ghost" className="rounded-full px-3 text-xl font-semibold text-primary">
|
||||
<Link href="https://github.com/molvqingtai/WebChat">
|
||||
<GitHubLogoIcon className="mr-1 size-6"></GitHubLogoIcon>
|
||||
Github
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BadgeList.displayName = 'BadgeList'
|
||||
|
||||
export default BadgeList
|
20
src/app/options/components/Layout.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Meteors from '@/components/magicui/Meteors'
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface LayoutProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
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="fixed left-0 top-0 h-full w-screen overflow-hidden">
|
||||
<Meteors number={30} />
|
||||
</div>
|
||||
<div className="relative z-10 min-h-screen min-w-screen">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Layout.displayName = 'Layout'
|
||||
export default Layout
|
17
src/app/options/components/Main.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { type ReactNode, type FC } from 'react'
|
||||
|
||||
export interface MainProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Main.displayName = 'Main'
|
||||
|
||||
export default Main
|
284
src/app/options/components/ProfileForm.tsx
Normal file
|
@ -0,0 +1,284 @@
|
|||
import * as v from 'valibot'
|
||||
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 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 { 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'
|
||||
|
||||
const defaultUserInfo: UserInfo = {
|
||||
id: nanoid(),
|
||||
name: '',
|
||||
avatar: '',
|
||||
createTime: Date.now(),
|
||||
themeMode: 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
}
|
||||
|
||||
const formSchema = v.object({
|
||||
id: v.string(),
|
||||
// Pure numeric strings will be converted to number
|
||||
// Issues: https://github.com/unjs/unstorage/issues/277
|
||||
createTime: v.number(),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minBytes(1, 'Please enter your username.'),
|
||||
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
),
|
||||
avatar: v.pipe(
|
||||
v.string(),
|
||||
v.notLength(0, 'Please select your avatar.'),
|
||||
v.maxBytes(8 * 1024, `Your avatar cannot exceed 8kb.`)
|
||||
),
|
||||
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 send = useRemeshSend()
|
||||
const toast = ToastImpl.value
|
||||
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
|
||||
const form = useForm({
|
||||
resolver: valibotResolver(formSchema),
|
||||
defaultValues: userInfo ?? defaultUserInfo
|
||||
})
|
||||
|
||||
// Update defaultValues
|
||||
useEffect(() => {
|
||||
userInfo && form.reset(userInfo)
|
||||
}, [userInfo, form])
|
||||
|
||||
const handleSubmit = (userInfo: UserInfo) => {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
|
||||
toast.success('Saved successfully!')
|
||||
}
|
||||
|
||||
const handleWarning = (error: Error) => {
|
||||
toast.warning(error.message)
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
toast.error(error.message)
|
||||
}
|
||||
|
||||
const handleRefreshAvatar = async () => {
|
||||
const avatarBase64 = await generateRandomAvatar(MAX_AVATAR_SIZE)
|
||||
form.setValue('avatar', avatarBase64)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
autoComplete="off"
|
||||
className="relative w-[450px] space-y-8 p-14 pt-20"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 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>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Please enter your username" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>This is your public display name.</FormDescription>
|
||||
<FormMessage />
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="light" />
|
||||
<Label className="cursor-pointer" htmlFor="light">
|
||||
Light
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="dark" />
|
||||
<Label className="cursor-pointer" htmlFor="dark">
|
||||
Dark
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The theme mode of the extension. If you choose the system, will follow the system theme.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className="w-full" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
ProfileForm.displayName = 'ProfileForm'
|
||||
|
||||
export default ProfileForm
|
20
src/app/options/components/VersionLink.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { FC } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import Link from '@/components/Link'
|
||||
import { version } from '@/../package.json'
|
||||
|
||||
const VersionLink: FC = () => {
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="fixed right-4 top-2 rounded-full px-3 text-base font-medium text-primary"
|
||||
>
|
||||
<Link href="https://github.com/molvqingtai/WebChat/releases">Version: v{version}</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
VersionLink.displayName = 'VersionLink'
|
||||
|
||||
export default VersionLink
|
13
src/app/options/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WEB CHAT</title>
|
||||
<meta name="manifest.open_in_tab" content="true" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
21
src/app/options/main.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Remesh } from 'remesh'
|
||||
import { RemeshRoot } from 'remesh-react'
|
||||
import App from './App'
|
||||
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [BrowserSyncStorageImpl, ToastImpl]
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<RemeshRoot store={store}>
|
||||
<App />
|
||||
</RemeshRoot>
|
||||
</React.StrictMode>
|
||||
)
|
BIN
src/assets/images/example.jpg
Normal file
After Width: | Height: | Size: 28 KiB |
12
src/assets/images/loading.svg
Normal file
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<radialGradient id="a4" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)">
|
||||
<stop offset="0" stop-color="currentColor"></stop><stop offset=".3" stop-color="currentColor" stop-opacity=".9"></stop>
|
||||
<stop offset=".6" stop-color="currentColor" stop-opacity=".6"></stop>
|
||||
<stop offset=".8" stop-color="currentColor" stop-opacity=".3"></stop>
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
<circle transform-origin="center" fill="none" stroke="url(#a4)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70">
|
||||
<animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform>
|
||||
</circle>
|
||||
<circle transform-origin="center" fill="none" opacity=".2" stroke="currentColor" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle>
|
||||
</svg>
|
After Width: | Height: | Size: 1 KiB |
7
src/assets/images/logo-0.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m531.66 507.52c-7.6719 0-14.281 8.457-16.262 19.547 4.4336-4.4258 10.02-7.1562 16.262-7.1562 6.2461 0 11.836 2.7344 16.273 7.168-1.9844-11.098-8.6016-19.559-16.273-19.559z" fill="#fff"/>
|
||||
<path d="m531.66 536.7c-4.3359 0-9.1914 6.7109-9.1914 15.691 0 1.25 0.23438 2.457 0.42188 3.6758 2.582 2.5039 5.5742 4.0352 8.7695 4.0352s6.1914-1.5312 8.7734-4.0352c0.1875-1.207 0.41797-2.3984 0.41797-3.6719 0-8.9844-4.8555-15.695-9.1914-15.695z" fill="#fff"/>
|
||||
<path d="m600 286.45c-135.02 0-245.49 110.47-245.49 245.48v299.79c0 45.008 36.824 81.828 81.828 81.828s81.828-36.82 81.828-81.828c0 45.008 36.824 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828 0 45.008 36.82 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828v-24.426l0.007812-275.36c0-135.02-110.47-245.48-245.49-245.48zm-120.7 215.01c8.6328-24.148 29.184-39.75 52.348-39.75 23.172 0 43.723 15.602 52.355 39.75 1.5664 4.3672-0.71484 9.1719-5.0742 10.73-0.93359 0.33594-1.8945 0.49609-2.8281 0.49609-3.4453 0-6.6836-2.1406-7.9062-5.5742-6.2148-17.375-20.559-28.605-36.547-28.605-15.98 0-30.324 11.23-36.539 28.605-1.5508 4.3711-6.3477 6.668-10.734 5.0781-4.3594-1.5586-6.6406-6.3633-5.0742-10.73zm86.078 32.344c0 24.16-14.816 43.086-33.727 43.086s-33.719-18.926-33.719-43.086c0-24.156 14.809-43.082 33.719-43.082 18.906-0.003906 33.727 18.922 33.727 43.082zm34.617 204.94c-66.738 0-121.03-54.297-121.03-121.04 0-4.6367 3.7539-8.3984 8.3984-8.3984 4.6406 0 8.3984 3.7578 8.3984 8.3984 0 57.477 46.766 104.24 104.24 104.24s104.24-46.766 104.24-104.24c0-4.6367 3.7539-8.3984 8.3984-8.3984 4.6406 0 8.3984 3.7578 8.3984 8.3984-0.003906 66.738-54.297 121.04-121.04 121.04zm95.328-188.98c3.7812 2.6914 4.6562 7.9336 1.9688 11.711-1.6406 2.3008-4.2227 3.5273-6.8477 3.5273-1.6797 0-3.3867-0.50391-4.8633-1.5586l-39.723-28.301c-2.9766-2.1211-4.2383-5.918-3.125-9.3984 1.1055-3.4766 4.3477-5.8398 7.9961-5.8398h48.117c4.6406 0 8.3984 3.7578 8.3984 8.3984 0 4.6367-3.7539 8.3984-8.3984 8.3984h-21.859zm26.746-36.18c-0.93359 0.33594-1.8945 0.49609-2.8281 0.49609-3.4453 0-6.6836-2.1406-7.9062-5.5742-6.2148-17.375-20.559-28.605-36.547-28.605-15.98 0-30.324 11.23-36.547 28.609-1.5664 4.3711-6.3867 6.6367-10.734 5.0742-4.3711-1.5625-6.6406-6.3672-5.0742-10.734 8.6445-24.145 29.191-39.746 52.355-39.746 23.172 0 43.723 15.602 52.355 39.75 1.5703 4.3672-0.71094 9.1719-5.0742 10.73z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
8
src/assets/images/logo-1.svg
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m542.62 572.49c17.328 0 31.426-14.098 31.426-31.422v-38.383c0-17.328-14.102-31.422-31.426-31.422-17.328 0-31.422 14.098-31.422 31.422v38.383c0 17.324 14.098 31.422 31.422 31.422zm-25.668-50.613c0-14.152 11.516-25.668 25.672-25.668 14.152 0 25.672 11.512 25.672 25.668 0 4.6406-3.7578 8.3984-8.3984 8.3984-4.6367 0-8.3984-3.7539-8.3984-8.3984 0-4.8945-3.9805-8.8711-8.875-8.8711s-8.875 3.9766-8.875 8.8711c0 4.6406-3.7578 8.3984-8.3984 8.3984-4.6406 0-8.3984-3.7578-8.3984-8.3984z" fill="#fff"/>
|
||||
<path d="m657.39 572.49c17.328 0 31.422-14.098 31.422-31.422v-38.383c0-17.328-14.098-31.422-31.422-31.422-17.328 0-31.426 14.098-31.426 31.422v38.383c-0.003907 17.324 14.098 31.422 31.426 31.422zm0-76.277c14.152 0 25.672 11.512 25.672 25.668 0 14.152-11.516 25.668-25.672 25.668-14.152 0-25.672-11.512-25.672-25.668s11.516-25.668 25.672-25.668z" fill="#fff"/>
|
||||
<path d="m666.26 521.88c0 4.9023-3.9766 8.875-8.875 8.875-4.9023 0-8.875-3.9727-8.875-8.875 0-4.8984 3.9727-8.875 8.875-8.875 4.8984 0 8.875 3.9766 8.875 8.875" fill="#fff"/>
|
||||
<path d="m600 286.45c-135.02 0-245.49 110.47-245.49 245.48v299.79c0 45.008 36.824 81.828 81.828 81.828s81.828-36.82 81.828-81.828c0 45.008 36.824 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828 0 45.008 36.82 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828v-24.426l0.007812-275.36c0-135.02-110.47-245.48-245.49-245.48zm9.1641 216.23c0-26.586 21.633-48.215 48.219-48.215 26.586 0 48.215 21.633 48.215 48.215v38.383c0 26.586-21.633 48.215-48.215 48.215-26.59 0-48.219-21.633-48.219-48.215zm-114.76 0c0-26.586 21.633-48.215 48.215-48.215 26.59 0 48.219 21.633 48.219 48.215v38.383c0 26.586-21.633 48.215-48.219 48.215-26.586 0-48.215-21.633-48.215-48.215zm218.22 191.95c-1.5898 1.3945-3.5586 2.0742-5.5195 2.0742-2.3398 0-4.6641-0.97656-6.3281-2.8711l-28.93-33.113-25.082 28.707c-5.5469 6.3633-16.117 6.3633-21.68 0l-25.086-28.703-25.082 28.707c-5.5508 6.3633-16.121 6.3633-21.68 0l-25.078-28.707-28.93 33.113c-3.043 3.4766-8.3477 3.8555-11.844 0.79688-3.4922-3.0508-3.8516-8.3555-0.80078-11.848l30.734-35.18c5.5547-6.3633 16.125-6.3633 21.68 0l25.078 28.707 25.09-28.707c5.5547-6.3633 16.125-6.3633 21.68 0l25.078 28.707 25.09-28.707c5.5508-6.3633 16.121-6.3633 21.68 0l30.73 35.18c3.0508 3.4883 2.6914 8.793-0.80078 11.844z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
10
src/assets/images/logo-2.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m531.01 626.36h-34.223c-0.23828 0-0.44141-0.11719-0.67187-0.13672 2.543 31.316 19.047 58.684 43.203 76.066-5.293-9.7578-8.3047-20.93-8.3047-32.789z" fill="#fff"/>
|
||||
<path d="m669 626.36v43.141c0 11.859-3.0117 23.031-8.3047 32.789 24.156-17.383 40.66-44.75 43.203-76.066-0.23438 0.019532-0.43359 0.13672-0.67188 0.13672z" fill="#fff"/>
|
||||
<path d="m524.8 540.95c3.1484-3.1484 8.7266-3.1484 11.875 0l19.527 19.531 11.641-11.645-19.523-19.523c-3.2812-3.2812-3.2812-8.5938 0-11.875l19.523-19.531-11.641-11.637-19.527 19.523c-3.2812 3.2812-8.5938 3.2812-11.875 0l-19.523-19.523-11.645 11.637 19.527 19.531c1.5742 1.5742 2.4609 3.707 2.4609 5.9375 0 2.2305-0.88672 4.3633-2.4609 5.9375l-19.527 19.523 11.645 11.645z" fill="#fff"/>
|
||||
<path d="m608.4 674.03c0 4.6406-3.7578 8.3984-8.3984 8.3984-4.6367 0-8.3984-3.7539-8.3984-8.3984v-47.668h-43.805v43.141c0 28.781 23.414 52.203 52.195 52.203s52.203-23.418 52.203-52.203v-43.141h-43.801z" fill="#fff"/>
|
||||
<path d="m675.2 505.79c-3.2812 3.2812-8.5938 3.2812-11.875 0l-19.523-19.523-11.645 11.637 19.527 19.531c1.5742 1.5742 2.4609 3.707 2.4609 5.9375s-0.88672 4.3633-2.4609 5.9375l-19.527 19.523 11.645 11.645 19.523-19.531c3.1484-3.1484 8.7266-3.1484 11.875 0l19.527 19.531 11.641-11.645-19.523-19.523c-3.2812-3.2812-3.2812-8.5938 0-11.875l19.523-19.531-11.641-11.637z" fill="#fff"/>
|
||||
<path d="m600 286.45c-135.02 0-245.49 110.47-245.49 245.48v299.79c0 45.008 36.824 81.828 81.828 81.828s81.828-36.82 81.828-81.828c0 45.008 36.824 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828 0 45.008 36.82 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828v-24.426l0.007812-275.36c0-135.02-110.47-245.48-245.49-245.48zm-126.64 262.38c0-2.2305 0.88672-4.3633 2.4609-5.9375l19.527-19.523-19.527-19.531c-1.5742-1.5742-2.4609-3.7148-2.4609-5.9375 0-2.2305 0.88672-4.3633 2.4609-5.9375l23.52-23.508c3.2812-3.2812 8.5938-3.2812 11.875 0l19.523 19.523 19.527-19.523c3.2812-3.2812 8.5938-3.2812 11.875 0l23.512 23.508c3.2812 3.2812 3.2812 8.5938 0 11.875l-19.523 19.531 19.523 19.523c3.2812 3.2812 3.2812 8.5938 0 11.875l-23.512 23.52c-1.5742 1.5742-3.7109 2.4609-5.9375 2.4609s-4.3633-0.88672-5.9375-2.4609l-19.527-19.531-19.523 19.531c-3.1484 3.1484-8.7266 3.1484-11.875 0l-23.52-23.52c-1.5742-1.5742-2.4609-3.707-2.4609-5.9375zm126.64 189.91c-66.734 0-121.03-54.293-121.03-121.03 0-4.6406 3.7578-8.3984 8.3984-8.3984h225.27c4.6367 0 8.3984 3.7539 8.3984 8.3984 0 66.738-54.297 121.03-121.04 121.03zm124.18-195.85c3.2812 3.2812 3.2812 8.5938 0 11.875l-23.512 23.52c-1.5742 1.5742-3.7109 2.4609-5.9375 2.4609s-4.3633-0.88672-5.9375-2.4609l-19.527-19.531-19.523 19.531c-3.1484 3.1484-8.7266 3.1484-11.875 0l-23.52-23.52c-1.5742-1.5742-2.4609-3.707-2.4609-5.9375 0-2.2305 0.88672-4.3633 2.4609-5.9375l19.527-19.523-19.527-19.531c-1.5742-1.5742-2.4609-3.7148-2.4609-5.9375 0-2.2305 0.88672-4.3633 2.4609-5.9375l23.52-23.508c3.2812-3.2812 8.5938-3.2812 11.875 0l19.523 19.523 19.527-19.523c3.2812-3.2812 8.5938-3.2812 11.875 0l23.512 23.508c3.2812 3.2812 3.2812 8.5938 0 11.875l-19.523 19.531z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
6
src/assets/images/logo-3.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m599.71 739.02c-9.7656 0-19.133-0.74219-28.172-1.9805v18.906c0 15.766 12.773 28.559 28.469 28.559 15.695 0 28.477-12.793 28.477-28.559v-19.059c-9.6055 1.4219-19.277 2.1328-28.746 2.1328h-0.027344z" fill="#fff"/>
|
||||
<path d="m600 241.04c-150.02 0-272.06 117.76-272.06 262.53v387.89c0 37.234 30.152 67.516 67.227 67.516 37.047 0 67.207-30.281 67.207-67.516v-29.996c0-19.512 15.793-35.383 35.195-35.383 19.414 0 35.207 15.871 35.207 35.383v29.996c0 37.234 30.152 67.516 67.227 67.516 37.074 0 67.227-30.281 67.227-67.516v-29.996c0-19.512 15.793-35.383 35.199-35.383s35.199 15.871 35.199 35.383v29.996c0 37.234 30.152 67.516 67.207 67.516 37.07-0.003906 67.223-30.285 67.223-67.52v-387.88c0-144.77-122.03-262.53-272.06-262.53zm-149.82 330.29-15.07-28.379 53.535-28.656-53.551-28.695c-7.793-4.1875-10.738-13.914-6.5898-21.742 4.1953-7.8516 13.859-10.801 21.652-6.6367l80.016 42.84c5.2305 2.8047 8.4883 8.2461 8.4883 14.191 0 5.918-3.2578 11.383-8.4648 14.184zm283.18 122.78c0 8.8711-7.1641 16.074-16 16.074-4.957 0-9.3711-2.2734-12.324-5.8281-12.469 10.926-27.922 19.035-44.566 24.719v26.895c0.007812 33.477-27.121 60.699-60.465 60.699s-60.473-27.227-60.473-60.699v-26.23c-17.516-5.8867-32.699-14.41-44.738-25.164-2.9414 3.4297-7.293 5.6055-12.145 5.6055-8.8359 0-16.008-7.2031-16.008-16.074l0.003906-37.5c0-8.0547 5.9414-14.863 13.906-15.922 7.875-1.0977 15.438 3.9375 17.539 11.723 6.8984 25.535 31.332 41.465 59.824 49.09 0.52734 0.082031 1.0586 0.089844 1.5781 0.21875 25.656 6.6133 55.266 6.5742 80.969-0.13672 0.6875-0.17188 1.3828-0.20312 2.0703-0.29297 28.355-7.707 52.543-23.57 59.383-48.887 2.0938-7.7773 9.7695-12.824 17.531-11.723 7.9727 1.0664 13.906 7.8672 13.906 15.922v37.512zm31.543-206.62-53.551 28.695 53.535 28.656-15.062 28.379-80.02-42.891c-5.1992-2.8047-8.457-8.2656-8.457-14.184 0-5.9492 3.2578-11.387 8.4883-14.191l80.016-42.84c2.3867-1.2773 4.957-1.8828 7.4961-1.8828 5.7344 0 11.262 3.082 14.156 8.5156 4.1445 7.8281 1.1992 17.555-6.6016 21.742z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
9
src/assets/images/logo-4.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m666.66 547.89c-4.3359 0-9.1914 6.7109-9.1914 15.695 0 1.2617 0.23438 2.4648 0.41797 3.6758 2.582 2.5039 5.5781 4.0312 8.7734 4.0312 3.1953 0 6.1914-1.5273 8.7734-4.0312 0.1875-1.2148 0.41797-2.418 0.41797-3.6758 0-8.9844-4.8555-15.695-9.1914-15.695z" fill="#fff"/>
|
||||
<path d="m533.35 547.89c-4.3359 0-9.1914 6.7109-9.1914 15.695 0 1.2695 0.23047 2.4648 0.41797 3.6758 2.582 2.5039 5.5781 4.0352 8.7734 4.0352 3.1953 0 6.1914-1.5312 8.7734-4.0352 0.1875-1.2109 0.41797-2.4102 0.41797-3.6719 0-8.9883-4.8516-15.699-9.1914-15.699z" fill="#fff"/>
|
||||
<path d="m600 286.45c-135.02 0-245.49 110.47-245.49 245.48v299.79c0 45.008 36.824 81.828 81.828 81.828s81.828-36.82 81.828-81.828c0 45.008 36.824 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828 0 45.008 36.82 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828v-24.426l0.007812-275.36c0-135.02-110.47-245.48-245.49-245.48zm-151.21 236.49c0-4.6367 3.7578-8.3984 8.3984-8.3984 69.93 0 85.145-43.41 85.762-45.258 1.4609-4.3789 6.2109-6.7773 10.57-5.3672 4.3906 1.4219 6.8164 6.1016 5.4219 10.5-0.35937 1.1328-4.9648 14.699-19.105 28.297 15.699 3.7656 27.238 20.961 27.238 42.273 0 24.16-14.812 43.086-33.723 43.086-18.914 0-33.727-18.926-33.727-43.086 0-7.6797 1.6289-14.723 4.2812-20.906-12.676 4.3633-27.965 7.25-46.711 7.25-4.6445 0.007812-8.4062-3.7539-8.4062-8.3906zm151.21 215.8c-66.734 0-121.03-54.297-121.03-121.04 0-4.6367 3.7578-8.3984 8.3984-8.3984 4.6367 0 8.3984 3.7578 8.3984 8.3984 0 57.477 46.762 104.24 104.24 104.24 57.477 0 104.24-46.766 104.24-104.24 0-4.6367 3.7578-8.3984 8.3984-8.3984 4.6367 0 8.3984 3.7578 8.3984 8.3984-0.003907 66.738-54.301 121.04-121.04 121.04zm142.82-207.41c-18.75 0-34.039-2.8906-46.711-7.2539 2.6523 6.1836 4.2812 13.223 4.2812 20.906 0 24.16-14.812 43.086-33.727 43.086-18.914 0-33.727-18.926-33.727-43.086 0-21.312 11.539-38.504 27.242-42.273-14.141-13.602-18.746-27.168-19.105-28.297-1.4023-4.418 1.043-9.1445 5.4609-10.547 4.3945-1.3906 9.1055 1.0195 10.527 5.418 0.64453 1.9297 15.898 45.25 85.762 45.25 4.6367 0 8.3984 3.7578 8.3984 8.3984-0.003906 4.6367-3.7656 8.3984-8.4023 8.3984z" fill="#fff"/>
|
||||
<path d="m549.61 538.25c-1.9805-11.09-8.5938-19.551-16.266-19.551-7.6797 0-14.293 8.4609-16.273 19.555 4.4375-4.4336 10.023-7.168 16.273-7.168 6.2461 0.003906 11.832 2.7383 16.266 7.1641z" fill="#fff"/>
|
||||
<path d="m666.66 518.71c-7.6797 0-14.293 8.4609-16.273 19.555 4.4375-4.4336 10.023-7.168 16.273-7.168 6.2461 0 11.836 2.7344 16.273 7.168-1.9805-11.094-8.5977-19.555-16.273-19.555z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
5
src/assets/images/logo-5.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m600 241.04c-150.02 0-272.06 117.76-272.06 262.53v387.89c0 37.242 30.152 67.523 67.223 67.523 37.059 0 67.207-30.281 67.207-67.523v-29.996c0-19.496 15.781-35.375 35.195-35.375 19.414 0 35.207 15.879 35.207 35.375v29.996c0 37.242 30.152 67.523 67.227 67.523s67.227-30.281 67.227-67.523v-29.996c0-19.496 15.793-35.375 35.188-35.375 19.414 0 35.207 15.879 35.207 35.375v29.996c0 37.242 30.152 67.523 67.215 67.523 37.07-0.011718 67.223-30.293 67.223-67.527v-387.88c0-144.77-122.03-262.53-272.06-262.53zm-173.37 273.24c-8.8359 0-16-7.2031-16-16.074 0-8.8711 7.1641-16.074 16-16.074h112.03c8.8359 0 16 7.1953 16 16.074 0 1.3438-0.21094 2.6367-0.51953 3.8984-1.9805 24.062-22.016 42.984-46.484 42.984-20.148 0-37.266-12.855-43.801-30.809zm306.72 197.18c0 8.8711-7.1641 16.074-16 16.074-4.957 0-9.3711-2.2656-12.324-5.8281-27.039 23.676-67.781 34.656-105.32 34.656-43.551-0.015625-80.621-12.727-104.93-34.445-2.9414 3.4297-7.293 5.6055-12.145 5.6055-8.8359 0-16.008-7.2031-16.008-16.074v-37.508c0-8.0469 5.9414-14.871 13.891-15.914 8.125-1.1094 15.461 3.9453 17.555 11.723 10.172 37.629 58.379 54.449 101.64 54.465 43.543 0 92.031-16.844 102.2-54.465 2.0938-7.7852 9.6797-12.809 17.531-11.723 7.9727 1.0664 13.906 7.8672 13.906 15.914v37.52zm52.832-209.36c-1.9727 24.062-22.008 42.984-46.477 42.984-20.148 0-37.273-12.855-43.809-30.809h-37.211c-8.8438 0-16-7.2031-16-16.074 0-8.8711 7.1562-16.074 16-16.074h112.01c8.8438 0 16.008 7.2031 16.008 16.074-0.003907 1.3438-0.21484 2.6367-0.52344 3.8984z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
10
src/assets/images/logo-6.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1200 600c0 214.36-114.36 412.44-300 519.62-185.64 107.18-414.36 107.18-600 0-185.64-107.18-300-305.26-300-519.62s114.36-412.44 300-519.62c185.64-107.18 414.36-107.18 600 0 185.64 107.18 300 305.26 300 519.62" fill="#0f1729"/>
|
||||
<path d="m645.61 677.49c0 36.332-20.422 65.789-45.609 65.789-25.191 0-45.613-29.457-45.613-65.789 0-36.336 20.422-65.789 45.613-65.789 25.188 0 45.609 29.453 45.609 65.789" fill="#fff"/>
|
||||
<path d="m667.65 516.34c-7.6719 0-14.285 8.4609-16.266 19.551 4.4336-4.4297 10.023-7.1602 16.266-7.1602 6.2461 0 11.836 2.7344 16.27 7.1602-1.9805-11.09-8.5938-19.551-16.27-19.551z" fill="#fff"/>
|
||||
<path d="m667.65 545.52c-4.3359 0-9.1914 6.7109-9.1914 15.691 0 1.2617 0.23438 2.4609 0.42188 3.6758 2.582 2.5039 5.5781 4.0312 8.7695 4.0312 3.1953 0 6.1914-1.5273 8.7734-4.0312 0.1875-1.2148 0.42188-2.4219 0.42188-3.6758 0-8.9766-4.8555-15.691-9.1953-15.691z" fill="#fff"/>
|
||||
<path d="m532.36 516.34c-7.6719 0-14.285 8.4609-16.266 19.551 4.4336-4.4297 10.023-7.1602 16.266-7.1602 6.2461 0 11.836 2.7344 16.27 7.1641-1.9805-11.09-8.5938-19.555-16.27-19.555z" fill="#fff"/>
|
||||
<path d="m600 286.45c-135.02 0-245.49 110.47-245.49 245.48v299.79c0 45.008 36.824 81.828 81.828 81.828s81.828-36.82 81.828-81.828c0 45.008 36.824 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828 0 45.008 36.82 81.828 81.828 81.828 45.008 0 81.828-36.82 81.828-81.828v-24.426l0.007812-275.36c0-135.02-110.47-245.48-245.49-245.48zm-107.3 217.28c-3.4805 0-6.7344-2.1797-7.9375-5.6562-1.5117-4.3828 0.8125-9.1641 5.1953-10.68l79.316-27.426c4.3594-1.5039 9.1602 0.80859 10.68 5.1953 1.5117 4.3828-0.8125 9.1641-5.1953 10.68l-79.316 27.426c-0.90625 0.30859-1.832 0.46094-2.7422 0.46094zm5.9336 38.902c0-24.16 14.812-43.086 33.723-43.086 18.914 0 33.727 18.926 33.727 43.086 0 24.156-14.812 43.082-33.727 43.082-18.91 0.003906-33.723-18.922-33.723-43.082zm101.37 217.44c-34.406 0-62.402-37.047-62.402-82.582 0-45.535 27.996-82.582 62.402-82.582 34.41 0 62.406 37.047 62.406 82.582 0 45.535-27.992 82.582-62.406 82.582zm67.648-174.35c-18.91 0-33.723-18.926-33.723-43.082 0-24.16 14.812-43.086 33.723-43.086 18.914 0 33.727 18.926 33.727 43.086 0 24.156-14.812 43.082-33.727 43.082zm47.598-87.648c-1.2031 3.4766-4.457 5.6562-7.9375 5.6562-0.91016 0-1.8359-0.15234-2.7422-0.46484l-79.316-27.426c-4.3828-1.5156-6.707-6.2969-5.1953-10.68 1.5117-4.3867 6.3086-6.6992 10.68-5.1953l79.316 27.426c4.3828 1.5195 6.707 6.3008 5.1953 10.684z" fill="#fff"/>
|
||||
<path d="m532.36 545.52c-4.3359 0-9.1914 6.7109-9.1914 15.691 0 1.2578 0.23438 2.4609 0.41797 3.6758 2.582 2.5039 5.5781 4.0352 8.7734 4.0352 3.1953 0 6.1914-1.5273 8.7734-4.0312 0.1875-1.2109 0.41797-2.4141 0.41797-3.6758 0-8.9805-4.8516-15.695-9.1914-15.695z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src/assets/images/texture.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
17
src/assets/styles/overlay.css
Normal file
|
@ -0,0 +1,17 @@
|
|||
section[aria-live='polite'] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster]) {
|
||||
max-width: 300px;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
max-width: 300px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
width: fit-content;
|
||||
}
|
686
src/assets/styles/sonner.css
Normal file
|
@ -0,0 +1,686 @@
|
|||
:where(html[dir='ltr']),
|
||||
:where([data-sonner-toaster][dir='ltr']) {
|
||||
--toast-icon-margin-start: -3px;
|
||||
--toast-icon-margin-end: 4px;
|
||||
--toast-svg-margin-start: -1px;
|
||||
--toast-svg-margin-end: 0px;
|
||||
--toast-button-margin-start: auto;
|
||||
--toast-button-margin-end: 0;
|
||||
--toast-close-button-start: 0;
|
||||
--toast-close-button-end: unset;
|
||||
--toast-close-button-transform: translate(-35%, -35%);
|
||||
}
|
||||
|
||||
:where(html[dir='rtl']),
|
||||
:where([data-sonner-toaster][dir='rtl']) {
|
||||
--toast-icon-margin-start: 4px;
|
||||
--toast-icon-margin-end: -3px;
|
||||
--toast-svg-margin-start: 0px;
|
||||
--toast-svg-margin-end: -1px;
|
||||
--toast-button-margin-start: 0;
|
||||
--toast-button-margin-end: auto;
|
||||
--toast-close-button-start: unset;
|
||||
--toast-close-button-end: 0;
|
||||
--toast-close-button-transform: translate(35%, -35%);
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster]) {
|
||||
position: fixed;
|
||||
width: var(--width);
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
Apple Color Emoji,
|
||||
Segoe UI Emoji,
|
||||
Segoe UI Symbol,
|
||||
Noto Color Emoji;
|
||||
--gray1: hsl(0, 0%, 99%);
|
||||
--gray2: hsl(0, 0%, 97.3%);
|
||||
--gray3: hsl(0, 0%, 95.1%);
|
||||
--gray4: hsl(0, 0%, 93%);
|
||||
--gray5: hsl(0, 0%, 90.9%);
|
||||
--gray6: hsl(0, 0%, 88.7%);
|
||||
--gray7: hsl(0, 0%, 85.8%);
|
||||
--gray8: hsl(0, 0%, 78%);
|
||||
--gray9: hsl(0, 0%, 56.1%);
|
||||
--gray10: hsl(0, 0%, 52.3%);
|
||||
--gray11: hsl(0, 0%, 43.5%);
|
||||
--gray12: hsl(0, 0%, 9%);
|
||||
--border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
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']) {
|
||||
right: max(var(--offset), env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='left']) {
|
||||
left: max(var(--offset), env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='center']) {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-y-position='top']) {
|
||||
top: max(var(--offset), env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-y-position='bottom']) {
|
||||
bottom: max(var(--offset), env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) {
|
||||
--y: translateY(100%);
|
||||
--lift-amount: calc(var(--lift) * var(--gap));
|
||||
z-index: var(--z-index);
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: var(--y);
|
||||
filter: blur(0);
|
||||
/* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */
|
||||
touch-action: none;
|
||||
transition:
|
||||
transform 400ms,
|
||||
opacity 400ms,
|
||||
height 400ms,
|
||||
box-shadow 200ms;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
padding: 16px;
|
||||
background: var(--normal-bg);
|
||||
border: 1px solid var(--normal-border);
|
||||
color: var(--normal-text);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
width: var(--width);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]:focus-visible) {
|
||||
box-shadow:
|
||||
0px 4px 12px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='top']) {
|
||||
top: 0;
|
||||
--y: translateY(-100%);
|
||||
--lift: 1;
|
||||
--lift-amount: calc(1 * var(--gap));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='bottom']) {
|
||||
bottom: 0;
|
||||
--y: translateY(100%);
|
||||
--lift: -1;
|
||||
--lift-amount: calc(var(--lift) * var(--gap));
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-description]) {
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-title]) {
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
position: relative;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--toast-icon-margin-start);
|
||||
margin-right: var(--toast-icon-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-promise='true']) :where([data-icon]) > svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transform-origin: center;
|
||||
animation: sonner-fade-in 300ms ease forwards;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-icon]) svg {
|
||||
margin-left: var(--toast-svg-margin-start);
|
||||
margin-right: var(--toast-svg-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-content]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-styled='true'] [data-button] {
|
||||
border-radius: 4px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--normal-bg);
|
||||
background: var(--normal-text);
|
||||
margin-left: var(--toast-button-margin-start);
|
||||
margin-right: var(--toast-button-margin-end);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
opacity 400ms,
|
||||
box-shadow 200ms;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-button]):focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-button]):first-of-type {
|
||||
margin-left: var(--toast-button-margin-start);
|
||||
margin-right: var(--toast-button-margin-end);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-cancel]) {
|
||||
color: var(--normal-text);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-theme='dark']) :where([data-cancel]) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-close-button]) {
|
||||
position: absolute;
|
||||
left: var(--toast-close-button-start);
|
||||
right: var(--toast-close-button-end);
|
||||
top: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
color: var(--gray12);
|
||||
border: 1px solid var(--gray4);
|
||||
transform: var(--toast-close-button-transform);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
transition:
|
||||
opacity 100ms,
|
||||
background 200ms,
|
||||
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),
|
||||
0 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-disabled='true']) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]):hover :where([data-close-button]):hover {
|
||||
background: var(--gray2);
|
||||
border-color: var(--gray5);
|
||||
}
|
||||
|
||||
/* Leave a ghost div to avoid setting hover to false when swiping out */
|
||||
:where([data-sonner-toast][data-swiping='true'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='top'][data-swiping='true'])::before {
|
||||
/* y 50% needed to distribute height additional height evenly */
|
||||
bottom: 50%;
|
||||
transform: scaleY(3) translateY(50%);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-y-position='bottom'][data-swiping='true'])::before {
|
||||
/* y -50% needed to distribute height additional height evenly */
|
||||
top: 50%;
|
||||
transform: scaleY(3) translateY(-50%);
|
||||
}
|
||||
|
||||
/* Leave a ghost div to avoid setting hover to false when transitioning out */
|
||||
:where([data-sonner-toast][data-swiping='false'][data-removed='true'])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: scaleY(2);
|
||||
}
|
||||
|
||||
/* Needed to avoid setting hover to false when inbetween toasts */
|
||||
:where([data-sonner-toast])::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: calc(var(--gap) + 1px);
|
||||
bottom: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-mounted='true']) {
|
||||
--y: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-expanded='false'][data-front='false']) {
|
||||
--scale: var(--toasts-before) * 0.05 + 1;
|
||||
--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));
|
||||
height: var(--front-toast-height);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) > * {
|
||||
transition: opacity 400ms;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true']) > * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-visible='false']) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-mounted='true'][data-expanded='true']) {
|
||||
--y: translateY(calc(var(--lift) * var(--offset)));
|
||||
height: var(--initial-height);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false']) {
|
||||
--y: translateY(calc(var(--lift) * -100%));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']) {
|
||||
--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']) {
|
||||
--y: translateY(40%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 500ms,
|
||||
opacity 200ms;
|
||||
}
|
||||
|
||||
/* Bump up the height to make sure hover state doesn't get set to false */
|
||||
:where([data-sonner-toast][data-removed='true'][data-front='false'])::before {
|
||||
height: calc(var(--initial-height) + 20%);
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-swiping='true'] {
|
||||
transform: var(--y) translateY(var(--swipe-amount, 0px));
|
||||
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;
|
||||
}
|
||||
|
||||
@keyframes swipe-out {
|
||||
from {
|
||||
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount)));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
[data-sonner-toaster] {
|
||||
position: fixed;
|
||||
--mobile-offset: 16px;
|
||||
right: var(--mobile-offset);
|
||||
left: var(--mobile-offset);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-sonner-toaster][dir='rtl'] {
|
||||
left: calc(var(--mobile-offset) * -1);
|
||||
}
|
||||
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: calc(100% - var(--mobile-offset) * 2);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-x-position='left'] {
|
||||
left: var(--mobile-offset);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-y-position='bottom'] {
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-y-position='top'] {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-x-position='center'] {
|
||||
left: var(--mobile-offset);
|
||||
right: var(--mobile-offset);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray4);
|
||||
--normal-text: var(--gray12);
|
||||
|
||||
--success-bg: hsl(143, 85%, 96%);
|
||||
--success-border: hsl(145, 92%, 91%);
|
||||
--success-text: hsl(140, 100%, 27%);
|
||||
|
||||
--info-bg: hsl(208, 100%, 97%);
|
||||
--info-border: hsl(221, 91%, 91%);
|
||||
--info-text: hsl(210, 92%, 45%);
|
||||
|
||||
--warning-bg: hsl(49, 100%, 97%);
|
||||
--warning-border: hsl(49, 91%, 91%);
|
||||
--warning-text: hsl(31, 92%, 45%);
|
||||
|
||||
--error-bg: hsl(359, 100%, 97%);
|
||||
--error-border: hsl(359, 100%, 94%);
|
||||
--error-text: hsl(360, 100%, 45%);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark'] [data-sonner-toast][data-invert='true'] {
|
||||
--normal-bg: #fff;
|
||||
--normal-border: var(--gray3);
|
||||
--normal-text: var(--gray12);
|
||||
}
|
||||
|
||||
[data-sonner-toaster][data-theme='dark'] {
|
||||
--normal-bg: #000;
|
||||
--normal-border: hsl(0, 0%, 20%);
|
||||
--normal-text: var(--gray1);
|
||||
|
||||
--success-bg: hsl(150, 100%, 6%);
|
||||
--success-border: hsl(147, 100%, 12%);
|
||||
--success-text: hsl(150, 86%, 65%);
|
||||
|
||||
--info-bg: hsl(215, 100%, 6%);
|
||||
--info-border: hsl(223, 100%, 12%);
|
||||
--info-text: hsl(216, 87%, 65%);
|
||||
|
||||
--warning-bg: hsl(64, 100%, 6%);
|
||||
--warning-border: hsl(60, 100%, 12%);
|
||||
--warning-text: hsl(46, 87%, 65%);
|
||||
|
||||
--error-bg: hsl(358, 76%, 10%);
|
||||
--error-border: hsl(357, 89%, 16%);
|
||||
--error-text: hsl(358, 100%, 81%);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='success'] [data-close-button] {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='info'] {
|
||||
background: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='info'] [data-close-button] {
|
||||
background: var(--info-bg);
|
||||
border-color: var(--info-border);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='warning'] {
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='warning'] [data-close-button] {
|
||||
background: var(--warning-bg);
|
||||
border-color: var(--warning-border);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='error'] {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
[data-rich-colors='true'][data-sonner-toast][data-type='error'] [data-close-button] {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.sonner-loading-wrapper {
|
||||
--size: 16px;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sonner-loading-wrapper[data-visible='false'] {
|
||||
transform-origin: center;
|
||||
animation: sonner-fade-out 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.sonner-spinner {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
.sonner-loading-bar {
|
||||
animation: sonner-spin 1.2s linear infinite;
|
||||
background: var(--gray11);
|
||||
border-radius: 6px;
|
||||
height: 8%;
|
||||
left: -10%;
|
||||
position: absolute;
|
||||
top: -3.9%;
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(1) {
|
||||
animation-delay: -1.2s;
|
||||
transform: rotate(0.0001deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
transform: rotate(30deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(3) {
|
||||
animation-delay: -1s;
|
||||
transform: rotate(60deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(4) {
|
||||
animation-delay: -0.9s;
|
||||
transform: rotate(90deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(5) {
|
||||
animation-delay: -0.8s;
|
||||
transform: rotate(120deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(6) {
|
||||
animation-delay: -0.7s;
|
||||
transform: rotate(150deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(7) {
|
||||
animation-delay: -0.6s;
|
||||
transform: rotate(180deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(8) {
|
||||
animation-delay: -0.5s;
|
||||
transform: rotate(210deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(9) {
|
||||
animation-delay: -0.4s;
|
||||
transform: rotate(240deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(10) {
|
||||
animation-delay: -0.3s;
|
||||
transform: rotate(270deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(11) {
|
||||
animation-delay: -0.2s;
|
||||
transform: rotate(300deg) translate(146%);
|
||||
}
|
||||
|
||||
.sonner-loading-bar:nth-child(12) {
|
||||
animation-delay: -0.1s;
|
||||
transform: rotate(330deg) translate(146%);
|
||||
}
|
||||
|
||||
@keyframes sonner-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonner-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonner-spin {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
[data-sonner-toast],
|
||||
[data-sonner-toast] > *,
|
||||
.sonner-loading-bar {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sonner-loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
opacity 200ms,
|
||||
transform 200ms;
|
||||
}
|
||||
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
|
@ -3,7 +3,8 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:host {
|
||||
:host,
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
|
@ -72,11 +73,19 @@
|
|||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
:host {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
:host,
|
||||
:root {
|
||||
@apply !bg-background !text-foreground !text-base !visible;
|
||||
|
||||
/* Disabled inherit */
|
||||
all: initial;
|
||||
direction: ltr;
|
||||
all: initial !important;
|
||||
direction: ltr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* @property --shimmer-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
} */
|
Before Width: | Height: | Size: 30 KiB |
26
src/components/Link.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { cn } from '@/utils'
|
||||
import { forwardRef, ReactNode } from 'react'
|
||||
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
underline?: boolean
|
||||
}
|
||||
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href}
|
||||
rel="noopener noreferrer"
|
||||
className={cn(underline && 'hover:underline', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
|
||||
Link.displayName = 'Link'
|
||||
export default Link
|
134
src/components/Markdown.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { type FC } from 'react'
|
||||
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'
|
||||
|
||||
export interface MarkdownProps {
|
||||
children?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const safeProtocol = /^(https?|ircs?|mailto|xmpp|data)$/i
|
||||
|
||||
/**
|
||||
* https://github.com/remarkjs/react-markdown/blob/baad6c53764e34c4ead41e2eaba176acfc87538a/lib/index.js#L293
|
||||
*/
|
||||
const urlTransform = (value: string) => {
|
||||
// Same as:
|
||||
// <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
|
||||
// But without the `encode` part.
|
||||
const colon = value.indexOf(':')
|
||||
const questionMark = value.indexOf('?')
|
||||
const numberSign = value.indexOf('#')
|
||||
const slash = value.indexOf('/')
|
||||
|
||||
if (
|
||||
// If there is no protocol, it’s relative.
|
||||
colon < 0 ||
|
||||
// If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
|
||||
(slash > -1 && colon > slash) ||
|
||||
(questionMark > -1 && colon > questionMark) ||
|
||||
(numberSign > -1 && colon > numberSign) ||
|
||||
// It is a protocol, it should be allowed.
|
||||
safeProtocol.test(value.slice(0, colon))
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
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} />
|
||||
),
|
||||
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)}
|
||||
target={props.href || '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }) => {
|
||||
Reflect.deleteProperty(props, 'ordered')
|
||||
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
|
||||
},
|
||||
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
||||
table: ({ className, ...props }) => (
|
||||
<div className="my-2 w-full">
|
||||
<ScrollArea scrollLock={false}>
|
||||
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
),
|
||||
tr: ({ className, ...props }) => {
|
||||
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
|
||||
},
|
||||
th: ({ className, ...props }) => {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
td: ({ className, ...props }) => {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
pre: ({ className, ...props }) => <pre className={cn('my-2', className)} {...props} />,
|
||||
/**
|
||||
* TODO: Code highlight
|
||||
* @see https://github.com/remarkjs/react-markdown/issues/680
|
||||
* @see https://shiki.style/guide/install#usage
|
||||
*
|
||||
*/
|
||||
code: ({ className, ...props }) => (
|
||||
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
|
||||
<code className={cn('text-sm', className)} {...props}></code>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
className={cn(className, 'prose prose-sm prose-slate break-words dark:text-slate-50')}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
Markdown.displayName = 'Markdown'
|
||||
|
||||
export { Markdown }
|
|
@ -1,63 +0,0 @@
|
|||
import { type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
|
||||
import React from 'react'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
export interface MessageInputProps {
|
||||
value?: string
|
||||
className?: string
|
||||
maxLength?: number
|
||||
preview?: boolean
|
||||
autoFocus?: boolean
|
||||
onInput?: (value: string) => void
|
||||
onEnter?: (value: string) => void
|
||||
}
|
||||
|
||||
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus }, 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)}>
|
||||
{preview ? (
|
||||
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||
) : (
|
||||
// Hack: Auto-Growing Textarea
|
||||
<div
|
||||
data-value={value}
|
||||
className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40"
|
||||
>
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden whitespace-pre-wrap break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
|
||||
rows={2}
|
||||
value={value}
|
||||
placeholder="Type your message here."
|
||||
onInput={handleInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
||||
{value?.length ?? 0}/{maxLength}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
MessageInput.displayName = 'MessageInput'
|
||||
|
||||
export default MessageInput
|
|
@ -1,76 +0,0 @@
|
|||
import { type FC, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import LikeButton from '@/components/LikeButton'
|
||||
import { type Message } from '@/types'
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: Message
|
||||
index?: number
|
||||
}
|
||||
|
||||
const MessageItem: FC<MessageItemProps> = ({ data, index }) => {
|
||||
const [formatData, setFormatData] = useState({
|
||||
...data,
|
||||
date: format(data.date, 'yyyy/MM/dd HH:mm:ss')
|
||||
})
|
||||
|
||||
const handleLikeChange = (type: 'like' | 'hate', checked: boolean, count: number) => {
|
||||
setFormatData((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[`${type}Checked`]: checked,
|
||||
[`${type}Count`]: count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={index}
|
||||
className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 [content-visibility:auto] first:pt-4 last:pb-4"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={formatData.avatar} />
|
||||
<AvatarFallback>{formatData.username}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] items-baseline gap-x-2 leading-none">
|
||||
<div className="text-sm font-medium text-slate-600">{formatData.username}</div>
|
||||
<div className="text-xs text-slate-400">{formatData.date}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{formatData.body}</Markdown>
|
||||
</div>
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
||||
<LikeButton
|
||||
checked={formatData.likeChecked}
|
||||
onChange={(...args) => handleLikeChange('like', ...args)}
|
||||
count={formatData.likeCount}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<ThumbsUpIcon size={14}></ThumbsUpIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
<LikeButton
|
||||
checked={formatData.hateChecked}
|
||||
onChange={(...args) => handleLikeChange('hate', ...args)}
|
||||
count={formatData.hateCount}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<FrownIcon size={14}></FrownIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
MessageItem.displayName = 'MessageItem'
|
||||
export default MessageItem
|
|
@ -1,21 +0,0 @@
|
|||
import { type ReactElement } from 'react'
|
||||
|
||||
import React from 'react'
|
||||
import { type MessageItemProps } from './MessageItem'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
|
||||
export interface MessageListProps {
|
||||
children?: Array<ReactElement<MessageItemProps>>
|
||||
}
|
||||
// [&>div>div]:!block fix word-break: break-word;
|
||||
const MessageList = React.forwardRef<HTMLDivElement, MessageListProps>(({ children }, ref) => {
|
||||
return (
|
||||
<ScrollArea ref={ref} className="[&>div>div]:!block">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
|
||||
MessageList.displayName = 'MessageList'
|
||||
|
||||
export default MessageList
|
69
src/components/magicui/AvatarCircles.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
interface AvatarCirclesProps {
|
||||
className?: string
|
||||
avatarUrls: string[]
|
||||
size?: VariantProps<typeof SizeVariants>['size']
|
||||
max?: number
|
||||
}
|
||||
|
||||
const SizeVariants = cva('z-10 flex -space-x-4 rtl:space-x-reverse', {
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 min-w-10',
|
||||
sm: 'h-8 min-w-8',
|
||||
xs: 'h-6 min-w-6',
|
||||
lg: 'h-12 min-w-12'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const spaceVariants = cva('flex -space-x-4 rtl:space-x-reverse', {
|
||||
variants: {
|
||||
size: {
|
||||
default: '-space-x-4',
|
||||
sm: '-space-x-3',
|
||||
xs: '-space-x-2',
|
||||
lg: '-space-x-5'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const AvatarCircles = ({ className, avatarUrls, size, max = 10 }: AvatarCirclesProps) => {
|
||||
return (
|
||||
<div className={cn(spaceVariants({ size }), className)}>
|
||||
{avatarUrls.slice(0, max).map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded-full border-2 border-white dark:border-slate-800 aspect-square',
|
||||
SizeVariants({ size })
|
||||
)}
|
||||
src={url}
|
||||
alt={`Avatar ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full border-2 border-white bg-slate-600 text-center text-xs font-medium text-white dark:border-slate-800 p-1',
|
||||
SizeVariants({ size }),
|
||||
size === 'xs' && 'text-2xs'
|
||||
)}
|
||||
>
|
||||
+{avatarUrls.length}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvatarCircles
|
61
src/components/magicui/BlurFade.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { AnimatePresence, motion, useInView, UseInViewOptions, Variants } from 'framer-motion'
|
||||
|
||||
type MarginType = UseInViewOptions['margin']
|
||||
|
||||
interface BlurFadeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
yOffset?: number
|
||||
inView?: boolean
|
||||
inViewMargin?: MarginType
|
||||
blur?: string
|
||||
}
|
||||
|
||||
export default function BlurFade({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
yOffset = 6,
|
||||
inView = false,
|
||||
inViewMargin = '-50px',
|
||||
blur = '6px'
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const defaultVariants: Variants = {
|
||||
hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` },
|
||||
visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }
|
||||
}
|
||||
const combinedVariants = variant || defaultVariants
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: 'easeOut'
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
42
src/components/magicui/Meteors.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
interface MeteorsProps {
|
||||
number?: number
|
||||
}
|
||||
export const Meteors = ({ number = 20 }: MeteorsProps) => {
|
||||
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])
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...meteorStyles].map((style, idx) => (
|
||||
// Meteor Head
|
||||
<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]'
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{/* Meteor Tail */}
|
||||
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-slate-500 to-transparent" />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Meteors
|
37
src/components/magicui/PulsatingButton.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
interface PulsatingButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
pulseColor?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export default function PulsatingButton({
|
||||
className,
|
||||
children,
|
||||
pulseColor = '#0f172a50',
|
||||
duration = '1.5s',
|
||||
...props
|
||||
}: PulsatingButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative rounded-full text-center cursor-pointer text-sm font-medium flex justify-center items-center text-primary-foreground bg-primary py-2 h-10 px-8 hover:bg-primary/90',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--pulse-color': pulseColor,
|
||||
'--duration': duration
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10">{children}</div>
|
||||
<div className="absolute left-1/2 top-1/2 size-full -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-full bg-inherit" />
|
||||
</button>
|
||||
)
|
||||
}
|
53
src/components/magicui/WordPullUp.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { motion, Variants } from "framer-motion";
|
||||
|
||||
import { cn } from "@/utils/index";
|
||||
|
||||
interface WordPullUpProps {
|
||||
words: string;
|
||||
delayMultiple?: number;
|
||||
wrapperFramerProps?: Variants;
|
||||
framerProps?: Variants;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function WordPullUp({
|
||||
words,
|
||||
wrapperFramerProps = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
framerProps = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
show: { y: 0, opacity: 1 },
|
||||
},
|
||||
className,
|
||||
}: WordPullUpProps) {
|
||||
return (
|
||||
<motion.h1
|
||||
variants={wrapperFramerProps}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className={cn(
|
||||
"font-display text-center text-4xl font-bold leading-[5rem] tracking-[-0.02em] drop-shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{words.split(" ").map((word, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
variants={framerProps}
|
||||
style={{ display: "inline-block", paddingRight: "8px" }}
|
||||
>
|
||||
{word === "" ? <span> </span> : word}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.h1>
|
||||
);
|
||||
}
|
|
@ -29,7 +29,7 @@ const AvatarFallback = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted dark:text-slate-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
@ -11,7 +11,8 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
outline:
|
||||
'border border-input text-primary bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
|
@ -21,7 +22,7 @@ const buttonVariants = cva(
|
|||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
xs: 'h-6 rounded-md px-2 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
26
src/components/ui/Checkbox.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
143
src/components/ui/Form.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
import * as React from 'react'
|
||||
import type * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
interface FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState
|
||||
}
|
||||
}
|
||||
|
||||
interface FormItemContextValue {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormItem.displayName = 'FormItem'
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
|
||||
})
|
||||
FormLabel.displayName = 'FormLabel'
|
||||
|
||||
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
|
||||
({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormControl.displayName = 'FormControl'
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p ref={ref} id={formDescriptionId} className={cn('text-[0.8rem] text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
FormDescription.displayName = 'FormDescription'
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-[0.8rem] font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }
|
22
src/components/ui/Input.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium 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}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
17
src/components/ui/Label.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70')
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
|
@ -1,73 +0,0 @@
|
|||
import { type FC } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
export interface MarkdownProps {
|
||||
children?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1 className={cn('mb-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-[50%]', className)} alt={alt} {...props} />
|
||||
),
|
||||
ul: ({ className, ...props }) => {
|
||||
Reflect.deleteProperty(props, 'ordered')
|
||||
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
|
||||
},
|
||||
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
||||
table: ({ className, ...props }) => (
|
||||
<div className="my-4 w-full overflow-y-auto">
|
||||
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
tr: ({ className, ...props }) => {
|
||||
// fix: spell it as lowercase `isheader` warning
|
||||
Reflect.deleteProperty(props, 'isHeader')
|
||||
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
|
||||
},
|
||||
th: ({ className, ...props }) => {
|
||||
// fix: spell it as lowercase `isheader` warning
|
||||
Reflect.deleteProperty(props, 'isHeader')
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
td: ({ className, ...props }) => {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
className={cn(className, 'prose prose-sm prose-slate break-words')}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
Markdown.displayName = 'Markdown'
|
||||
|
||||
export { Markdown }
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { cn } from '@/utils/index'
|
||||
import { cn, getRootNode } from '@/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
|
@ -10,9 +10,9 @@ const PopoverContent = React.forwardRef<
|
|||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as any as HTMLElement
|
||||
const root = getRootNode()
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
||||
<PopoverPrimitive.Portal container={root}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
|
36
src/components/ui/RadioGroup.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import * as React from 'react'
|
||||
import { CheckIcon } from '@radix-ui/react-icons'
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<CheckIcon className="size-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
|
@ -5,10 +5,13 @@ import { cn } from '@/utils/index'
|
|||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full overscroll-none rounded-[inherit]">
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
|
||||
>(({ className, children, scrollLock = true, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
|
27
src/components/ui/Switch.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { version } from '@/../package.json'
|
||||
// https://www.webfx.com/tools/emoji-cheat-sheet/
|
||||
|
||||
export const EMOJI_LIST = [
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
|
@ -111,6 +112,7 @@ export const EMOJI_LIST = [
|
|||
'👽',
|
||||
'👾',
|
||||
'🤖',
|
||||
'👀',
|
||||
'😺',
|
||||
'😸',
|
||||
'😹',
|
||||
|
@ -162,7 +164,7 @@ export const EMOJI_LIST = [
|
|||
'🙏',
|
||||
'✍',
|
||||
'💅'
|
||||
]
|
||||
] as const
|
||||
|
||||
// https://night-tailwindcss.vercel.app/docs/breakpoints
|
||||
export const BREAKPOINTS = {
|
||||
|
@ -184,4 +186,26 @@ export const BREAKPOINTS = {
|
|||
|
||||
export const MESSAGE_MAX_LENGTH = 500 as const
|
||||
|
||||
export const STORAGE_NAME = 'WEB_CHAT' as const
|
||||
export const STORAGE_NAME = `WEB_CHAT_${version}` as const
|
||||
|
||||
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
|
||||
|
||||
export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
|
||||
|
||||
export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
|
||||
/**
|
||||
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
* 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
|
6
src/constants/event.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
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'
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
import { type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { createElement } from '@/utils'
|
||||
|
||||
export interface RootOptions {
|
||||
mode?: ShadowRootMode
|
||||
style?: string
|
||||
script?: string
|
||||
element?: Element
|
||||
}
|
||||
|
||||
const createShadowRoot = (
|
||||
name: string,
|
||||
options: RootOptions
|
||||
): Root & { shadowHost: Element; shadowRoot: ShadowRoot; appRoot: Element } => {
|
||||
const { mode = 'open', style = '', script = '', element = '' } = options ?? {}
|
||||
const shadowHost = createElement(`<${name}></${name}>`)
|
||||
const shadowRoot = shadowHost.attachShadow({ mode })
|
||||
const appRoot = createElement(`<div id="app"></div>`)
|
||||
const appStyle = style && createElement(`<style type="text/css">${style}</style>`)
|
||||
const appScript = script && createElement(`<script type="application/javascript">${script}</script>`)
|
||||
const reactRoot = createRoot(appRoot)
|
||||
|
||||
shadowRoot.append(appStyle, appRoot, appScript, element)
|
||||
|
||||
return {
|
||||
shadowHost,
|
||||
shadowRoot,
|
||||
appRoot,
|
||||
render: (children: ReactNode) => {
|
||||
document.body.appendChild(shadowHost)
|
||||
return reactRoot.render(children)
|
||||
},
|
||||
unmount: () => {
|
||||
reactRoot.unmount()
|
||||
shadowHost.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createShadowRoot
|
167
src/domain/AppStatus.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import StatusModule from './modules/Status'
|
||||
import { LocalStorageExtern } from './externs/Storage'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import StorageEffect from './modules/StorageEffect'
|
||||
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
export interface AppStatus {
|
||||
open: boolean
|
||||
unread: number
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
export const defaultStatusState = {
|
||||
open: false,
|
||||
unread: 0,
|
||||
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
|
||||
}
|
||||
|
||||
const AppStatusDomain = Remesh.domain({
|
||||
name: 'AppStatusDomain',
|
||||
impl: (domain) => {
|
||||
const storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: LocalStorageExtern,
|
||||
key: APP_STATUS_STORAGE_KEY
|
||||
})
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const StatusLoadModule = StatusModule(domain, {
|
||||
name: 'AppStatus.LoadStatusModule'
|
||||
})
|
||||
|
||||
const StatusLoadIsFinishedQuery = domain.query({
|
||||
name: 'AppStatus.StatusLoadIsFinishedQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusLoadModule.query.IsFinishedQuery())
|
||||
}
|
||||
})
|
||||
|
||||
const StatusState = domain.state<AppStatus>({
|
||||
name: 'AppStatus.StatusState',
|
||||
default: defaultStatusState
|
||||
})
|
||||
|
||||
const OpenQuery = domain.query({
|
||||
name: 'AppStatus.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).open
|
||||
}
|
||||
})
|
||||
|
||||
const UnreadQuery = domain.query({
|
||||
name: 'AppStatus.UnreadQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).unread
|
||||
}
|
||||
})
|
||||
|
||||
const PositionQuery = domain.query({
|
||||
name: 'AppStatus.PositionQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).position
|
||||
}
|
||||
})
|
||||
|
||||
const HasUnreadQuery = domain.query({
|
||||
name: 'AppStatus.HasUnreadQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).unread > 0
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateOpenCommand = domain.command({
|
||||
name: 'AppStatus.UpdateOpenCommand',
|
||||
impl: ({ get }, value: boolean) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
unread: value ? 0 : status.unread,
|
||||
open: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUnreadCommand = domain.command({
|
||||
name: 'AppStatus.UpdateUnreadCommand',
|
||||
impl: ({ get }, value: number) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
unread: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdatePositionCommand = domain.command({
|
||||
name: 'AppStatus.UpdatePositionCommand',
|
||||
impl: ({ get }, value: { x: number; y: number }) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
position: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateStatusCommand = domain.command({
|
||||
name: 'AppStatus.UpdateStatusCommand',
|
||||
impl: (_, value: AppStatus) => {
|
||||
return [StatusState().new(value), SyncToStorageEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStorageEvent = domain.event({
|
||||
name: 'UserInfo.SyncToStorageEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState())
|
||||
}
|
||||
})
|
||||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<AppStatus>((value) => [
|
||||
UpdateStatusCommand(value ?? defaultStatusState),
|
||||
StatusLoadModule.command.SetFinishedCommand()
|
||||
])
|
||||
.watch<AppStatus>((value) => [UpdateStatusCommand(value ?? defaultStatusState)])
|
||||
|
||||
domain.effect({
|
||||
name: 'OnMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
map((message) => {
|
||||
const status = get(StatusState())
|
||||
if (!status.open && message.type === SendType.Text) {
|
||||
return UpdateUnreadCommand(status.unread + 1)
|
||||
}
|
||||
return null
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
OpenQuery,
|
||||
UnreadQuery,
|
||||
HasUnreadQuery,
|
||||
PositionQuery,
|
||||
StatusLoadIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
UpdateOpenCommand,
|
||||
UpdateUnreadCommand,
|
||||
UpdatePositionCommand
|
||||
},
|
||||
event: {
|
||||
SyncToStorageEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default AppStatusDomain
|
690
src/domain/ChatRoom.ts
Normal file
|
@ -0,0 +1,690 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
|
||||
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
||||
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, getTextByteSize, upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
|
||||
import * as v from 'valibot'
|
||||
|
||||
export { MessageType }
|
||||
|
||||
export enum SendType {
|
||||
Like = 'Like',
|
||||
Hate = 'Hate',
|
||||
Text = 'Text',
|
||||
SyncUser = 'SyncUser',
|
||||
SyncHistory = 'SyncHistory'
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.SyncUser
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
sendTime: number
|
||||
lastMessageTime: number
|
||||
}
|
||||
|
||||
export interface SyncHistoryMessage extends MessageUser {
|
||||
type: SendType.SyncHistory
|
||||
sendTime: number
|
||||
id: string
|
||||
messages: NormalMessage[]
|
||||
}
|
||||
|
||||
export interface LikeMessage extends MessageUser {
|
||||
type: SendType.Like
|
||||
sendTime: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HateMessage extends MessageUser {
|
||||
type: SendType.Hate
|
||||
sendTime: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TextMessage extends MessageUser {
|
||||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage | SyncHistoryMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; joinTime: number }
|
||||
|
||||
const MessageUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string()
|
||||
}
|
||||
|
||||
const AtUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string(),
|
||||
positions: v.array(v.tuple([v.number(), v.number()]))
|
||||
}
|
||||
|
||||
const NormalMessageSchema = {
|
||||
id: v.string(),
|
||||
type: v.literal(MessageType.Normal),
|
||||
body: v.string(),
|
||||
sendTime: v.number(),
|
||||
receiveTime: v.number(),
|
||||
likeUsers: v.array(v.object(MessageUserSchema)),
|
||||
hateUsers: v.array(v.object(MessageUserSchema)),
|
||||
atUsers: v.array(v.object(AtUserSchema))
|
||||
}
|
||||
|
||||
const RoomMessageSchema = v.union([
|
||||
v.object({
|
||||
type: v.literal(SendType.Text),
|
||||
id: v.string(),
|
||||
body: v.string(),
|
||||
sendTime: v.number(),
|
||||
atUsers: v.array(v.object(AtUserSchema)),
|
||||
...MessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(SendType.Like),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
...MessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(SendType.Hate),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
...MessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(SendType.SyncUser),
|
||||
id: v.string(),
|
||||
peerId: v.string(),
|
||||
joinTime: v.number(),
|
||||
sendTime: v.number(),
|
||||
lastMessageTime: v.number(),
|
||||
...MessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(SendType.SyncHistory),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
messages: v.array(v.object(NormalMessageSchema)),
|
||||
...MessageUserSchema
|
||||
})
|
||||
])
|
||||
|
||||
// Check if the message conforms to the format
|
||||
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
|
||||
v.safeParse(RoomMessageSchema, message).success
|
||||
|
||||
const ChatRoomDomain = Remesh.domain({
|
||||
name: 'ChatRoomDomain',
|
||||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const chatRoomExtern = domain.getExtern(ChatRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: chatRoomExtern.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
name: 'Room.PeerIdQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerIdState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinStatusModule = StatusModule(domain, {
|
||||
name: 'Room.JoinStatusModule'
|
||||
})
|
||||
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'Room.UserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const SelfUserQuery = domain.query({
|
||||
name: 'Room.SelfUserQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListQuery()).find((user) => user.peerIds.includes(chatRoomExtern.peerId))!
|
||||
}
|
||||
})
|
||||
|
||||
const LastMessageTimeQuery = domain.query({
|
||||
name: 'Room.LastMessageTimeQuery',
|
||||
impl: ({ get }) => {
|
||||
return (
|
||||
get(messageListDomain.query.ListQuery())
|
||||
.filter((message) => message.type === MessageType.Normal)
|
||||
.toSorted((a, b) => b.sendTime - a.sendTime)[0]?.sendTime ?? new Date(1970, 1, 1).getTime()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
}),
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(chatRoomExtern.roomId),
|
||||
SelfJoinRoomEvent(chatRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
JoinRoomCommand.after(() => {
|
||||
chatRoomExtern.joinRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
}),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(chatRoomExtern.roomId),
|
||||
SelfLeaveRoomEvent(chatRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
LeaveRoomCommand.after(() => {
|
||||
chatRoomExtern.leaveRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
name: 'Room.SendTextMessageCommand',
|
||||
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||
const self = get(SelfUserQuery())
|
||||
|
||||
const textMessage: TextMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
sendTime: Date.now(),
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
...textMessage,
|
||||
type: MessageType.Normal,
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
chatRoomExtern.sendMessage(textMessage)
|
||||
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'Room.SendLikeMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const likeMessage: LikeMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
sendTime: Date.now(),
|
||||
type: SendType.Like
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
|
||||
}
|
||||
chatRoomExtern.sendMessage(likeMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendHateMessageCommand = domain.command({
|
||||
name: 'Room.SendHateMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const hateMessage: HateMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
sendTime: Date.now(),
|
||||
type: SendType.Hate
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
|
||||
}
|
||||
chatRoomExtern.sendMessage(hateMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
peerId: chatRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
lastMessageTime,
|
||||
type: SendType.SyncUser
|
||||
}
|
||||
|
||||
chatRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
return [SendSyncUserMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The maximum sync message is the historical records within 30 days, using the last message as the basis for judgment.
|
||||
* The number of synced messages may not be all messages within 30 days; if new messages are generated before syncing, they will not be synced.
|
||||
* Users A, B, C, D, and E: A and B are online, while C, D, and E are offline.
|
||||
* 1. A and B chat, generating two messages: messageA and messageB.
|
||||
* 2. A and B go offline.
|
||||
* 3. C and D come online, generating two messages: messageC and messageD.
|
||||
* 4. A and B come online, and C and D will push two messages, messageC and messageD, to A and B. However, A and B will not push messageA and messageB to C and D because C and D's latest message timestamps are earlier than A and B's.
|
||||
* 5. E comes online, and A, B, C, and D will all push messages messageA, messageB, messageC, and messageD to E.
|
||||
*
|
||||
* Final results:
|
||||
* A and B see 4 messages: messageC, messageD, messageA, and messageB.
|
||||
* C and D see 2 messages: messageA and messageB.
|
||||
* E sees 4 messages: messageA, messageB, messageC, and messageD.
|
||||
*
|
||||
* As shown above, C and D did not sync messages that were earlier than their own.
|
||||
* On one hand, if we want to fully sync 30 days of messages, we must diff the timestamps of messages within 30 days and then insert them. The current implementation only does incremental additions, and messages will accumulate over time.
|
||||
* For now, let's keep it this way and see if it's necessary to fully sync the data within 30 days later.
|
||||
*/
|
||||
const SendSyncHistoryMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncHistoryMessageCommand',
|
||||
impl: ({ get }, { peerId, lastMessageTime }: { peerId: string; lastMessageTime: number }) => {
|
||||
const self = get(SelfUserQuery())
|
||||
|
||||
const historyMessages = get(messageListDomain.query.ListQuery()).filter(
|
||||
(message) =>
|
||||
message.type === MessageType.Normal &&
|
||||
message.sendTime > lastMessageTime &&
|
||||
message.sendTime - Date.now() <= SYNC_HISTORY_MAX_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
|
||||
/**
|
||||
* Message chunking to ensure that each message does not exceed WEB_RTC_MAX_MESSAGE_SIZE
|
||||
* If the message itself exceeds the size limit, skip syncing that message directly.
|
||||
*/
|
||||
const pushHistoryMessageList = historyMessages.reduce<SyncHistoryMessage[]>((acc, cur) => {
|
||||
const pushHistoryMessage: SyncHistoryMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
sendTime: Date.now(),
|
||||
type: SendType.SyncHistory,
|
||||
messages: [cur as NormalMessage]
|
||||
}
|
||||
const pushHistoryMessageByteSize = getTextByteSize(JSON.stringify(pushHistoryMessage))
|
||||
|
||||
if (pushHistoryMessageByteSize < WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
if (acc.length) {
|
||||
const mergedSize = getTextByteSize(JSON.stringify(acc[acc.length - 1])) + pushHistoryMessageByteSize
|
||||
if (mergedSize < WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
acc[acc.length - 1].messages.push(cur as NormalMessage)
|
||||
} else {
|
||||
acc.push(pushHistoryMessage)
|
||||
}
|
||||
} else {
|
||||
acc.push(pushHistoryMessage)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return pushHistoryMessageList.map((message) => {
|
||||
chatRoomExtern.sendMessage(message, peerId)
|
||||
return SendSyncHistoryMessageEvent(message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'Room.UpdateUserListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; user: Omit<RoomUser, 'peerIds'> & { peerId: string } }) => {
|
||||
const userList = get(UserListState())
|
||||
const existUser = userList.find((user) => user.userId === action.user.userId)
|
||||
if (action.type === 'create') {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{ ...action.user, peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId] },
|
||||
'userId'
|
||||
)
|
||||
)
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{
|
||||
...action.user,
|
||||
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || []
|
||||
},
|
||||
'userId'
|
||||
).filter((user) => user.peerIds.length)
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncHistoryMessageEvent = domain.event<SyncHistoryMessage>({
|
||||
name: 'Room.SendSyncHistoryMessageEvent'
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.SendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<LikeMessage>({
|
||||
name: 'Room.SendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<HateMessage>({
|
||||
name: 'Room.SendHateMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.JoinRoomEvent'
|
||||
})
|
||||
|
||||
const LeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.LeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'Room.OnMessageEvent'
|
||||
})
|
||||
|
||||
const OnTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.OnTextMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnJoinRoomEvent'
|
||||
})
|
||||
|
||||
const SelfJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfJoinRoomEvent'
|
||||
})
|
||||
|
||||
const OnLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const SelfLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnErrorEvent = domain.event<Error>({
|
||||
name: 'Room.OnErrorEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(chatRoomExtern.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (chatRoomExtern.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: ({ get }) => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(chatRoomExtern.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// Filter out messages that do not conform to the format
|
||||
if (!checkMessageFormat(message)) {
|
||||
console.warn('Invalid message format', message)
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
const messageEvent$ = of(OnMessageEvent(message))
|
||||
|
||||
const textMessageEvent$ = of(message.type === SendType.Text ? OnTextMessageEvent(message) : null)
|
||||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.SyncUser: {
|
||||
const selfUser = get(SelfUserQuery())
|
||||
|
||||
// If a new user joins after the current user has entered the room, a join log message needs to be created.
|
||||
const existUser = get(UserListQuery()).find((user) => user.userId === message.userId)
|
||||
const isNewJoinUser = !existUser && message.joinTime > selfUser.joinTime
|
||||
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
const needSyncHistory = lastMessageTime > message.lastMessageTime
|
||||
|
||||
return of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
isNewJoinUser
|
||||
? messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
id: nanoid(),
|
||||
body: `"${message.username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
receiveTime: Date.now()
|
||||
})
|
||||
: null,
|
||||
needSyncHistory
|
||||
? SendSyncHistoryMessageCommand({
|
||||
peerId: message.peerId,
|
||||
lastMessageTime: message.lastMessageTime
|
||||
})
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
case SendType.SyncHistory: {
|
||||
return of(...message.messages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
|
||||
}
|
||||
|
||||
case SendType.Text:
|
||||
return of(
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
type: MessageType.Normal,
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
)
|
||||
case SendType.Like:
|
||||
case SendType.Hate: {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
return EMPTY
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
const type = message.type === 'Like' ? 'likeUsers' : 'hateUsers'
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
receiveTime: Date.now(),
|
||||
[type]: desert(
|
||||
_message[type],
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
}
|
||||
})()
|
||||
|
||||
return merge(messageEvent$, textMessageEvent$, messageCommand$)
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(chatRoomExtern.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
if (get(JoinStatusModule.query.IsInitialQuery())) {
|
||||
return null
|
||||
}
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
|
||||
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
|
||||
|
||||
if (existUser) {
|
||||
return [
|
||||
UpdateUserListCommand({ type: 'delete', user: { ...existUser, peerId } }),
|
||||
existUser.peerIds.length === 1
|
||||
? messageListDomain.command.CreateItemCommand({
|
||||
...existUser,
|
||||
id: nanoid(),
|
||||
body: `"${existUser.username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
})
|
||||
: null,
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(chatRoomExtern.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(error)
|
||||
return OnErrorEvent(error)
|
||||
})
|
||||
)
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
JoinIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
SendSyncUserMessageCommand,
|
||||
SendSyncHistoryMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendSyncUserMessageEvent,
|
||||
SendSyncHistoryMessageEvent,
|
||||
JoinRoomEvent,
|
||||
SelfJoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
SelfLeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnTextMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
OnErrorEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default ChatRoomDomain
|
162
src/domain/Danmaku.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { DanmakuExtern } from './externs/Danmaku'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const DanmakuDomain = Remesh.domain({
|
||||
name: 'DanmakuDomain',
|
||||
impl: (domain) => {
|
||||
const danmakuExtern = domain.getExtern(DanmakuExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const MountState = domain.state({
|
||||
name: 'Danmaku.MountState',
|
||||
default: false
|
||||
})
|
||||
const DanmakuEnabledState = domain.state<boolean>({
|
||||
name: 'Danmaku.EnabledState',
|
||||
default: false
|
||||
})
|
||||
|
||||
const IsEnabledQuery = domain.query({
|
||||
name: 'Danmaku.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(DanmakuEnabledState())
|
||||
}
|
||||
})
|
||||
|
||||
const EnableCommand = domain.command({
|
||||
name: 'Danmaku.EnableCommand',
|
||||
impl: () => {
|
||||
return DanmakuEnabledState().new(true)
|
||||
}
|
||||
})
|
||||
|
||||
const DisableCommand = domain.command({
|
||||
name: 'Danmaku.DisableCommand',
|
||||
impl: () => {
|
||||
return DanmakuEnabledState().new(false)
|
||||
}
|
||||
})
|
||||
|
||||
const IsMountedQuery = domain.query({
|
||||
name: 'Danmaku.IsMountedQuery',
|
||||
impl: ({ get }) => get(MountState())
|
||||
})
|
||||
|
||||
const PushCommand = domain.command({
|
||||
name: 'Danmaku.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmakuExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const UnshiftCommand = domain.command({
|
||||
name: 'Danmaku.UnshiftCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmakuExtern.unshift(message)
|
||||
return [UnshiftEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const ClearCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: () => {
|
||||
danmakuExtern.clear()
|
||||
return [ClearEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const MountCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: (_, container: HTMLElement) => {
|
||||
danmakuExtern.mount(container)
|
||||
return [MountEvent(container)]
|
||||
}
|
||||
})
|
||||
|
||||
const UnmountCommand = domain.command({
|
||||
name: 'Danmaku.UnmountCommand',
|
||||
impl: () => {
|
||||
danmakuExtern.unmount()
|
||||
return [UnmountEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const PushEvent = domain.event<TextMessage>({
|
||||
name: 'Danmaku.PushEvent'
|
||||
})
|
||||
|
||||
const UnshiftEvent = domain.event<TextMessage>({
|
||||
name: 'Danmaku.UnshiftEvent'
|
||||
})
|
||||
|
||||
const ClearEvent = domain.event({
|
||||
name: 'Danmaku.ClearEvent'
|
||||
})
|
||||
|
||||
const MountEvent = domain.event<HTMLElement>({
|
||||
name: 'Danmaku.MountEvent'
|
||||
})
|
||||
|
||||
const UnmountEvent = domain.event({
|
||||
name: 'Danmaku.UnmountEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Danmaku.OnUserInfoEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
|
||||
return onUserInfo$.pipe(
|
||||
map((userInfo) => {
|
||||
return userInfo?.danmakuEnabled ? EnableCommand() : DisableCommand()
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Danmaku.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const danmakuEnabled = get(IsEnabledQuery())
|
||||
return danmakuEnabled ? PushCommand(message) : null
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
IsMountedQuery,
|
||||
IsEnabledQuery
|
||||
},
|
||||
command: {
|
||||
EnableCommand,
|
||||
DisableCommand,
|
||||
PushCommand,
|
||||
UnshiftCommand,
|
||||
ClearCommand,
|
||||
MountCommand,
|
||||
UnmountCommand
|
||||
},
|
||||
event: {
|
||||
PushEvent,
|
||||
UnshiftEvent,
|
||||
ClearEvent,
|
||||
MountEvent,
|
||||
UnmountEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default DanmakuDomain
|
|
@ -1,8 +1,6 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import InputModule from './modules/Input'
|
||||
|
||||
export const MESSAGE_INPUT_STORAGE_KEY = 'MESSAGE_INPUT'
|
||||
|
||||
const MessageInputDomain = Remesh.domain({
|
||||
name: 'MessageInputDomain',
|
||||
impl: (domain) => {
|
||||
|
|
|
@ -1,25 +1,72 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { ListModule } from 'remesh/modules/list'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { from, map, tap, merge } from 'rxjs'
|
||||
import Storage from './externs/Storage'
|
||||
import { type Message } from '@/types'
|
||||
import { IndexDBStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
import StatusModule from './modules/Status'
|
||||
import { MESSAGE_LIST_STORAGE_KEY } from '@/constants/config'
|
||||
|
||||
export enum MessageType {
|
||||
Normal = 'normal',
|
||||
Prompt = 'prompt'
|
||||
}
|
||||
|
||||
export interface MessageUser {
|
||||
userId: string
|
||||
username: string
|
||||
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
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
type: MessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
}
|
||||
|
||||
export type Message = NormalMessage | PromptMessage
|
||||
|
||||
const MessageListDomain = Remesh.domain({
|
||||
name: 'MessageListDomain',
|
||||
impl: (domain) => {
|
||||
const storage = domain.getExtern(Storage)
|
||||
const storageKey = `${storage.name}.MESSAGE_LIST`
|
||||
const storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: IndexDBStorageExtern,
|
||||
key: MESSAGE_LIST_STORAGE_KEY
|
||||
})
|
||||
|
||||
const MessageListModule = ListModule<Message>(domain, {
|
||||
name: 'MessageListModule',
|
||||
key: (message) => message.id
|
||||
})
|
||||
|
||||
const LoadStatusModule = StatusModule(domain, {
|
||||
name: 'Message.ListLoadStatusModule'
|
||||
})
|
||||
|
||||
const ListQuery = MessageListModule.query.ItemListQuery
|
||||
|
||||
const ItemQuery = MessageListModule.query.ItemQuery
|
||||
|
||||
const HasItemQuery = MessageListModule.query.HasItemByKeyQuery
|
||||
|
||||
const LoadIsFinishedQuery = LoadStatusModule.query.IsFinishedQuery
|
||||
|
||||
const ChangeListEvent = domain.event({
|
||||
name: 'MessageList.ChangeListEvent',
|
||||
impl: ({ get }) => {
|
||||
|
@ -33,9 +80,13 @@ const MessageListDomain = Remesh.domain({
|
|||
|
||||
const CreateItemCommand = domain.command({
|
||||
name: 'MessageList.CreateItemCommand',
|
||||
impl: (_, message: Omit<Message, 'id'>) => {
|
||||
const newMessage = { ...message, id: nanoid() }
|
||||
return [MessageListModule.command.AddItemCommand(newMessage), CreateItemEvent(newMessage), ChangeListEvent()]
|
||||
impl: (_, message: Message) => {
|
||||
return [
|
||||
MessageListModule.command.AddItemCommand(message),
|
||||
CreateItemEvent(message),
|
||||
ChangeListEvent(),
|
||||
SyncToStorageEvent()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -46,7 +97,12 @@ const MessageListDomain = Remesh.domain({
|
|||
const UpdateItemCommand = domain.command({
|
||||
name: 'MessageList.UpdateItemCommand',
|
||||
impl: (_, message: Message) => {
|
||||
return [MessageListModule.command.UpdateItemCommand(message), UpdateItemEvent(message), ChangeListEvent()]
|
||||
return [
|
||||
MessageListModule.command.UpdateItemCommand(message),
|
||||
UpdateItemEvent(message),
|
||||
ChangeListEvent(),
|
||||
SyncToStorageEvent()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -57,10 +113,47 @@ const MessageListDomain = Remesh.domain({
|
|||
const DeleteItemCommand = domain.command({
|
||||
name: 'MessageList.DeleteItemCommand',
|
||||
impl: (_, id: string) => {
|
||||
return [MessageListModule.command.DeleteItemCommand(id), DeleteItemEvent(id), ChangeListEvent()]
|
||||
return [
|
||||
MessageListModule.command.DeleteItemCommand(id),
|
||||
DeleteItemEvent(id),
|
||||
ChangeListEvent(),
|
||||
SyncToStorageEvent()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
@ -68,54 +161,57 @@ const MessageListDomain = Remesh.domain({
|
|||
const ClearListCommand = domain.command({
|
||||
name: 'MessageList.ClearListCommand',
|
||||
impl: () => {
|
||||
return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent()]
|
||||
return [MessageListModule.command.DeleteAllCommand(), ClearListEvent(), ChangeListEvent(), SyncToStorageEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const InitListEvent = domain.event<Message[]>({
|
||||
name: 'MessageList.InitListEvent'
|
||||
const SyncToStorageEvent = domain.event({
|
||||
name: 'MessageList.SyncToStorageEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(ListQuery())
|
||||
}
|
||||
})
|
||||
|
||||
const InitListCommand = domain.command({
|
||||
name: 'MessageList.InitListCommand',
|
||||
const SyncToStateEvent = domain.event<Message[]>({
|
||||
name: 'MessageList.SyncToStateEvent'
|
||||
})
|
||||
|
||||
const SyncToStateCommand = domain.command({
|
||||
name: 'MessageList.SyncToStateCommand',
|
||||
impl: (_, messages: Message[]) => {
|
||||
return [MessageListModule.command.SetListCommand(messages), InitListEvent(messages)]
|
||||
return [MessageListModule.command.SetListCommand(messages), SyncToStateEvent(messages)]
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStorageToStateEffect',
|
||||
impl: () => {
|
||||
return from(storage.get<Message[]>(storageKey)).pipe(map((messages) => InitListCommand(messages ?? [])))
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'FormStateToStorageEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const createItem$ = fromEvent(ChangeListEvent).pipe(
|
||||
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
|
||||
)
|
||||
return merge(createItem$).pipe(map(() => null))
|
||||
}
|
||||
})
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
|
||||
|
||||
return {
|
||||
query: {
|
||||
HasItemQuery,
|
||||
ItemQuery,
|
||||
ListQuery
|
||||
ListQuery,
|
||||
LoadIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
CreateItemCommand,
|
||||
UpdateItemCommand,
|
||||
DeleteItemCommand,
|
||||
ClearListCommand
|
||||
UpsertItemCommand,
|
||||
ClearListCommand,
|
||||
ResetListCommand
|
||||
},
|
||||
event: {
|
||||
ChangeListEvent,
|
||||
CreateItemEvent,
|
||||
UpdateItemEvent,
|
||||
DeleteItemEvent,
|
||||
ClearListEvent
|
||||
UpsertItemEvent,
|
||||
ClearListEvent,
|
||||
ResetListEvent,
|
||||
SyncToStateEvent,
|
||||
SyncToStorageEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
117
src/domain/Notification.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { NotificationExtern } from './externs/Notification'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const NotificationDomain = Remesh.domain({
|
||||
name: 'NotificationDomain',
|
||||
impl: (domain) => {
|
||||
const notificationExtern = domain.getExtern(NotificationExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const NotificationEnabledState = domain.state<boolean>({
|
||||
name: 'Notification.EnabledState',
|
||||
default: false
|
||||
})
|
||||
|
||||
const IsEnabledQuery = domain.query({
|
||||
name: 'Notification.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(NotificationEnabledState())
|
||||
}
|
||||
})
|
||||
|
||||
const EnableCommand = domain.command({
|
||||
name: 'Notification.EnableCommand',
|
||||
impl: () => {
|
||||
return NotificationEnabledState().new(true)
|
||||
}
|
||||
})
|
||||
|
||||
const DisableCommand = domain.command({
|
||||
name: 'Notification.DisableCommand',
|
||||
impl: () => {
|
||||
return NotificationEnabledState().new(false)
|
||||
}
|
||||
})
|
||||
|
||||
const PushCommand = domain.command({
|
||||
name: 'Notification.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
notificationExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const PushEvent = domain.event<TextMessage>({
|
||||
name: 'Notification.PushEvent'
|
||||
})
|
||||
|
||||
const ClearEvent = domain.event<string>({
|
||||
name: 'Notification.ClearEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Notification.OnUserInfoEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
|
||||
return onUserInfo$.pipe(
|
||||
map((userInfo) => {
|
||||
return userInfo?.notificationEnabled ? EnableCommand() : DisableCommand()
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const notificationEnabled = get(IsEnabledQuery())
|
||||
if (notificationEnabled) {
|
||||
// Compatible with old versions, without the atUsers field
|
||||
if (message.atUsers) {
|
||||
const userInfo = get(userInfoDomain.query.UserInfoQuery())
|
||||
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
|
||||
if (userInfo?.notificationType === 'all') {
|
||||
return PushCommand(message)
|
||||
}
|
||||
if (userInfo?.notificationType === 'at' && hasAtSelf) {
|
||||
return PushCommand(message)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
return PushCommand(message)
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
IsEnabledQuery
|
||||
},
|
||||
command: {
|
||||
EnableCommand,
|
||||
DisableCommand,
|
||||
PushCommand
|
||||
},
|
||||
event: {
|
||||
PushEvent,
|
||||
ClearEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default NotificationDomain
|
57
src/domain/Toast.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
|
||||
export default ToastDomain
|
115
src/domain/UserInfo.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
import StatusModule from './modules/Status'
|
||||
import { USER_INFO_STORAGE_KEY } from '@/constants/config'
|
||||
|
||||
export interface UserInfo {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
createTime: number
|
||||
themeMode: 'system' | 'light' | 'dark'
|
||||
danmakuEnabled: boolean
|
||||
notificationEnabled: boolean
|
||||
notificationType: 'all' | 'at'
|
||||
}
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
name: 'UserInfoDomain',
|
||||
impl: (domain) => {
|
||||
const storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: BrowserSyncStorageExtern,
|
||||
key: USER_INFO_STORAGE_KEY
|
||||
})
|
||||
|
||||
const UserInfoState = domain.state<UserInfo | null>({
|
||||
name: 'UserInfo.UserInfoState',
|
||||
default: null
|
||||
})
|
||||
|
||||
const UserInfoLoadStatusModule = StatusModule(domain, {
|
||||
name: 'UserInfo.LoadStatusModule'
|
||||
})
|
||||
const UserInfoSetStatusModule = StatusModule(domain, {
|
||||
name: 'UserInfo.SetStatusModule'
|
||||
})
|
||||
|
||||
const UserInfoQuery = domain.query({
|
||||
name: 'UserInfo.UserInfoQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserInfoState())
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.UpdateUserInfoCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [
|
||||
UserInfoState().new(userInfo),
|
||||
UpdateUserInfoEvent(),
|
||||
SyncToStorageEvent(),
|
||||
userInfo
|
||||
? UserInfoSetStatusModule.command.SetFinishedCommand()
|
||||
: UserInfoSetStatusModule.command.SetInitialCommand()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserInfoEvent = domain.event({
|
||||
name: 'UserInfo.UpdateUserInfoEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(UserInfoState())
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStorageEvent = domain.event({
|
||||
name: 'UserInfo.SyncToStorageEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(UserInfoState())
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStateEvent = domain.event<UserInfo | null>({
|
||||
name: 'UserInfo.SyncToStateEvent'
|
||||
})
|
||||
|
||||
const SyncToStateCommand = domain.command({
|
||||
name: 'UserInfo.SyncToStateCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [
|
||||
UserInfoState().new(userInfo),
|
||||
UpdateUserInfoEvent(),
|
||||
SyncToStateEvent(userInfo),
|
||||
userInfo
|
||||
? UserInfoSetStatusModule.command.SetFinishedCommand()
|
||||
: UserInfoSetStatusModule.command.SetInitialCommand()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<UserInfo>((value) => [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()])
|
||||
.watch<UserInfo>((value) => [SyncToStateCommand(value)])
|
||||
|
||||
return {
|
||||
query: {
|
||||
UserInfoQuery,
|
||||
UserInfoLoadIsFinishedQuery: UserInfoLoadStatusModule.query.IsFinishedQuery,
|
||||
UserInfoSetIsFinishedQuery: UserInfoSetStatusModule.query.IsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
UpdateUserInfoCommand
|
||||
},
|
||||
event: {
|
||||
SyncToStateEvent,
|
||||
SyncToStorageEvent,
|
||||
UpdateUserInfoEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default UserInfoDomain
|
381
src/domain/VirtualRoom.ts
Normal file
|
@ -0,0 +1,381 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
|
||||
import { type MessageUser } from './MessageList'
|
||||
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import * as v from 'valibot'
|
||||
import getSiteInfo, { SiteInfo } from '@/utils/getSiteInfo'
|
||||
|
||||
export enum SendType {
|
||||
SyncUser = 'SyncUser'
|
||||
}
|
||||
|
||||
export interface FromInfo extends SiteInfo {
|
||||
peerId: string
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.SyncUser
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
sendTime: number
|
||||
fromInfo: FromInfo
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; fromInfos: FromInfo[]; joinTime: number }
|
||||
|
||||
const MessageUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string()
|
||||
}
|
||||
|
||||
const FromInfoSchema = {
|
||||
peerId: v.string(),
|
||||
host: v.string(),
|
||||
hostname: v.string(),
|
||||
href: v.string(),
|
||||
origin: v.string(),
|
||||
title: v.string(),
|
||||
icon: v.string(),
|
||||
description: v.string()
|
||||
}
|
||||
|
||||
const RoomMessageSchema = v.union([
|
||||
v.object({
|
||||
type: v.literal(SendType.SyncUser),
|
||||
id: v.string(),
|
||||
peerId: v.string(),
|
||||
joinTime: v.number(),
|
||||
sendTime: v.number(),
|
||||
fromInfo: v.object(FromInfoSchema),
|
||||
...MessageUserSchema
|
||||
})
|
||||
])
|
||||
|
||||
// Check if the message conforms to the format
|
||||
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
|
||||
v.safeParse(RoomMessageSchema, message).success
|
||||
|
||||
const VirtualRoomDomain = Remesh.domain({
|
||||
name: 'VirtualRoomDomain',
|
||||
impl: (domain) => {
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const virtualRoomExtern = domain.getExtern(VirtualRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: virtualRoomExtern.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
name: 'Room.PeerIdQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerIdState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinStatusModule = StatusModule(domain, {
|
||||
name: 'Room.JoinStatusModule'
|
||||
})
|
||||
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'Room.UserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const SelfUserQuery = domain.query({
|
||||
name: 'Room.SelfUserQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListQuery()).find((user) => user.peerIds.includes(virtualRoomExtern.peerId))!
|
||||
}
|
||||
})
|
||||
|
||||
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: {
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
}
|
||||
}),
|
||||
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(virtualRoomExtern.roomId),
|
||||
SelfJoinRoomEvent(virtualRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
JoinRoomCommand.after(() => {
|
||||
virtualRoomExtern.joinRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: {
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
}
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(virtualRoomExtern.roomId),
|
||||
SelfLeaveRoomEvent(virtualRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
LeaveRoomCommand.after(() => {
|
||||
virtualRoomExtern.leaveRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'Room.UpdateUserListCommand',
|
||||
impl: (
|
||||
{ get },
|
||||
action: {
|
||||
type: 'create' | 'delete'
|
||||
user: Omit<RoomUser, 'peerIds' | 'fromInfos'> & { peerId: string; fromInfo: FromInfo }
|
||||
}
|
||||
) => {
|
||||
const userList = get(UserListState())
|
||||
const existUser = userList.find((user) => user.userId === action.user.userId)
|
||||
if (action.type === 'create') {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{
|
||||
...action.user,
|
||||
peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId],
|
||||
fromInfos: upsert(existUser?.fromInfos || [], action.user.fromInfo, 'peerId')
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
)
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{
|
||||
...action.user,
|
||||
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || [],
|
||||
fromInfos: existUser?.fromInfos?.filter((fromInfo) => fromInfo.peerId !== action.user.peerId) || []
|
||||
},
|
||||
'userId'
|
||||
).filter((user) => user.peerIds.length)
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
type: SendType.SyncUser
|
||||
}
|
||||
|
||||
virtualRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
return [SendSyncUserMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.JoinRoomEvent'
|
||||
})
|
||||
|
||||
const LeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.LeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'Room.OnMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnJoinRoomEvent'
|
||||
})
|
||||
|
||||
const SelfJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfJoinRoomEvent'
|
||||
})
|
||||
|
||||
const OnLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const SelfLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnErrorEvent = domain.event<Error>({
|
||||
name: 'Room.OnErrorEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(virtualRoomExtern.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (virtualRoomExtern.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: () => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(virtualRoomExtern.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// Filter out messages that do not conform to the format
|
||||
if (!checkMessageFormat(message)) {
|
||||
console.warn('Invalid message format', message)
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
const messageEvent$ = of(OnMessageEvent(message))
|
||||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.SyncUser: {
|
||||
return of(UpdateUserListCommand({ type: 'create', user: message }))
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
}
|
||||
})()
|
||||
|
||||
return merge(messageEvent$, messageCommand$)
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(virtualRoomExtern.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
if (get(JoinStatusModule.query.IsInitialQuery())) {
|
||||
return null
|
||||
}
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
|
||||
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
|
||||
|
||||
if (existUser) {
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { ...existUser, peerId, fromInfo: { ...getSiteInfo(), peerId } }
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(virtualRoomExtern.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(error)
|
||||
return OnErrorEvent(error)
|
||||
})
|
||||
)
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
JoinIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendSyncUserMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendSyncUserMessageEvent,
|
||||
JoinRoomEvent,
|
||||
SelfJoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
SelfLeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
OnErrorEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default VirtualRoomDomain
|
42
src/domain/externs/ChatRoom.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '../ChatRoom'
|
||||
|
||||
export interface ChatRoom {
|
||||
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
|
||||
}
|
||||
|
||||
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
|
||||
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.')
|
||||
}
|
||||
}
|
||||
})
|
30
src/domain/externs/Danmaku.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Danmaku {
|
||||
push: (message: TextMessage) => void
|
||||
unshift: (message: TextMessage) => void
|
||||
clear: () => void
|
||||
mount: (root: HTMLElement) => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export const DanmakuExtern = Remesh.extern<Danmaku>({
|
||||
default: {
|
||||
mount: () => {
|
||||
throw new Error('"mount" not implemented.')
|
||||
},
|
||||
unmount() {
|
||||
throw new Error('"unmount" not implemented.')
|
||||
},
|
||||
clear: () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
},
|
||||
push: () => {
|
||||
throw new Error('"push" not implemented.')
|
||||
},
|
||||
unshift: () => {
|
||||
throw new Error('"unshift" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
14
src/domain/externs/Notification.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Notification {
|
||||
push: (message: TextMessage) => Promise<string>
|
||||
}
|
||||
|
||||
export const NotificationExtern = Remesh.extern<Notification>({
|
||||
default: {
|
||||
push: () => {
|
||||
throw new Error('"push" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|