Compare commits
59 commits
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 |
59 changed files with 2674 additions and 3809 deletions
104
CHANGELOG.md
104
CHANGELOG.md
|
@ -1,3 +1,107 @@
|
|||
## [1.7.1](https://github.com/molvqingtai/WebChat/compare/v1.7.0...v1.7.1) (2024-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* parse icon url error ([7763f34](https://github.com/molvqingtai/WebChat/commit/7763f34d5d07a104f8a66e53b05a7f87a4e0da28))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* add number animation ([eb37dd2](https://github.com/molvqingtai/WebChat/commit/eb37dd28338d9e5420c91fb3d25c318411bdfd31))
|
||||
* compatible with rectangular icons ([b860b16](https://github.com/molvqingtai/WebChat/commit/b860b16e908a744f615c8cea35a3dcd4ca008f1a))
|
||||
* optimize scrollbar ([c5185e4](https://github.com/molvqingtai/WebChat/commit/c5185e419c5e175b8bc30e3f2b2207c18b9503b2))
|
||||
|
||||
# [1.7.0](https://github.com/molvqingtai/WebChat/compare/v1.6.6...v1.7.0) (2024-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ranking of users supporting online websites Closes [#48](https://github.com/molvqingtai/WebChat/issues/48) ([d0fea9e](https://github.com/molvqingtai/WebChat/commit/d0fea9e42d52d0e56171c08ed780066d66ebe3f1))
|
||||
|
||||
## [1.6.6](https://github.com/molvqingtai/WebChat/compare/v1.6.5...v1.6.6) (2024-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* the number of online users is inaccurate ([c6301a8](https://github.com/molvqingtai/WebChat/commit/c6301a826ebcf38a34b93a02c8013dd1ef9e7abc))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize taost dark mode ([00f0bd0](https://github.com/molvqingtai/WebChat/commit/00f0bd08b04e49f83cee60bb5767acd460a1b5d0))
|
||||
* theme mode is compatible with website themes by default ([6222e3f](https://github.com/molvqingtai/WebChat/commit/6222e3f8af1bf4fad2466a9bf88c3b3159478a86))
|
||||
|
||||
## [1.6.5](https://github.com/molvqingtai/WebChat/compare/v1.6.4...v1.6.5) (2024-11-07)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* delete setup exit animation ([d325be4](https://github.com/molvqingtai/WebChat/commit/d325be4becf562d2232a1a1e9a4e1582e44869a2))
|
||||
|
||||
## [1.6.4](https://github.com/molvqingtai/WebChat/compare/v1.6.3...v1.6.4) (2024-11-07)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* check message format ([f6864e0](https://github.com/molvqingtai/WebChat/commit/f6864e06be01fd434136901ae85278ed4eab4c03))
|
||||
|
||||
## [1.6.3](https://github.com/molvqingtai/WebChat/compare/v1.6.2...v1.6.3) (2024-11-06)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize image processing ([9438a31](https://github.com/molvqingtai/WebChat/commit/9438a3169dfda166776610ba6aac1ac168231636))
|
||||
|
||||
## [1.6.2](https://github.com/molvqingtai/WebChat/compare/v1.6.1...v1.6.2) (2024-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incompatible with old data of userInfo, causing crash ([d5ced07](https://github.com/molvqingtai/WebChat/commit/d5ced0718f586ca156e80c56078ae1f3de4ee917))
|
||||
|
||||
## [1.6.1](https://github.com/molvqingtai/WebChat/compare/v1.6.0...v1.6.1) (2024-11-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sooner style ([7e49ec2](https://github.com/molvqingtai/WebChat/commit/7e49ec210ed706a0ee94b3c2b7b17af719b604e1))
|
||||
|
||||
# [1.6.0](https://github.com/molvqingtai/WebChat/compare/v1.5.4...v1.6.0) (2024-11-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support offline message sync [#45](https://github.com/molvqingtai/WebChat/issues/45) ([7c4f655](https://github.com/molvqingtai/WebChat/commit/7c4f65573c591da2a8c8938e14066cee96d15b40))
|
||||
|
||||
## [1.5.4](https://github.com/molvqingtai/WebChat/compare/v1.5.3...v1.5.4) (2024-10-31)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* support reading image from the clipboard ([362d7db](https://github.com/molvqingtai/WebChat/commit/362d7db7386d978c6d053a3e7262adf844e24f55))
|
||||
|
||||
## [1.5.3](https://github.com/molvqingtai/WebChat/compare/v1.5.2...v1.5.3) (2024-10-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* insertion cursor position is incorrect ([2987c2d](https://github.com/molvqingtai/WebChat/commit/2987c2d85dd84639c06848ddc5cd4dc0b3288538))
|
||||
|
||||
## [1.5.2](https://github.com/molvqingtai/WebChat/compare/v1.5.1...v1.5.2) (2024-10-30)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize theme style ([7b91944](https://github.com/molvqingtai/WebChat/commit/7b91944fbf60c27d21274ddb7f28f97344c89ef5))
|
||||
|
||||
## [1.5.1](https://github.com/molvqingtai/WebChat/compare/v1.5.0...v1.5.1) (2024-10-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incompatibility with old data causes app to crash ([bd07bdc](https://github.com/molvqingtai/WebChat/commit/bd07bdc2c3df031d5a04d3eebade5d7fc7672600))
|
||||
|
||||
# [1.5.0](https://github.com/molvqingtai/WebChat/compare/v1.4.0...v1.5.0) (2024-10-29)
|
||||
|
||||
|
||||
|
|
10
README.md
10
README.md
|
@ -12,14 +12,13 @@ This is an anonymous chat browser extension that is decentralized and serverless
|
|||
|
||||
The aim is to add chat room functionality to any website, you'll never feel alone again.
|
||||
|
||||
|
||||
|
||||
### Install
|
||||
|
||||
**Install from Store**
|
||||
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf)
|
||||
- [Edge Web Store](https://microsoftedge.microsoft.com/addons/detail/mmfdplbomjjlgdffecapcpgjmhfhmiob)
|
||||
- [Firefox Addons](https://addons.mozilla.org/firefox/addon/webchat/)
|
||||
|
||||
**Manual Installation**
|
||||
|
||||
|
@ -34,11 +33,9 @@ The aim is to add chat room functionality to any website, you'll never feel alon
|
|||
|
||||
After installing the extension, you'll see a ghost icon in the bottom-right corner of any website. Click it, and you'll be able to chat happily with others on the same site!
|
||||
|
||||
|
||||
|
||||
### Video
|
||||
|
||||
https://github.com/user-attachments/assets/34890975-5926-4e38-9a5f-34a28e17ff36
|
||||
https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450
|
||||
|
||||
### Standing on the Shoulders of Giants
|
||||
|
||||
|
@ -50,7 +47,8 @@ In addition to the good idea of decentralized chat, it also leverages some fanta
|
|||
|
||||
- **[wxt](https://wxt.dev/)**: This is the best framework I’ve used for building browser extensions, bar none.
|
||||
|
||||
- **[trystero](https://github.com/dmotz/trystero)**: The core dependency for implementing decentralized communication, enabling connections to decentralized networks like IPFS, torrent, Nostr, etc.
|
||||
- ~~**[trystero](https://github.com/dmotz/trystero)**: The core dependency for implementing decentralized communication, enabling connections to decentralized networks like IPFS, torrent, Nostr, etc.~~
|
||||
- **[Artico](https://github.com/matallui/artico)**: A flexible set of libraries that help you create your own WebRTC-based solutions
|
||||
|
||||
- **[ugly-avatar](https://github.com/txstc55/ugly-avatar)**: Use it to create stunning random avatars.
|
||||
|
||||
|
|
46
package.json
46
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "web-chat",
|
||||
"displayName": "WebChat",
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.1",
|
||||
"description": "Chat with anyone on any website.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
@ -45,38 +45,39 @@
|
|||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.2",
|
||||
"@number-flow/react": "^0.3.2",
|
||||
"@perfsee/jsonr": "^1.13.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-portal": "^1.1.2",
|
||||
"@radix-ui/react-presence": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@resreq/event-hub": "^1.6.0",
|
||||
"@resreq/timer": "^1.1.6",
|
||||
"@rtco/client": "^0.2.17",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@webext-core/messaging": "^2.0.2",
|
||||
"@webcomponents/custom-elements": "^1.6.0",
|
||||
"@webext-core/messaging": "^2.1.0",
|
||||
"@webext-core/proxy-service": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.10",
|
||||
"framer-motion": "^11.11.17",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"lucide-react": "^0.456.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
|
@ -86,52 +87,51 @@
|
|||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.5.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.26.1",
|
||||
"unstorage": "1.12.0",
|
||||
"unstorage": "^1.13.1",
|
||||
"valibot": "1.0.0-beta.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@eslint-react/eslint-plugin": "^1.15.2",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@eslint-react/eslint-plugin": "^1.16.1",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.8.2",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.12.1",
|
||||
"@typescript-eslint/parser": "^8.14.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.11.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.3.3",
|
||||
"jiti": "^2.4.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"semantic-release": "^24.2.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.12.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"typescript-eslint": "^8.14.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.19.13"
|
||||
"wxt": "^0.19.15"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||
|
|
3592
pnpm-lock.yaml
generated
3592
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,21 +1,22 @@
|
|||
import '@webcomponents/custom-elements'
|
||||
import Header from '@/app/content/views/Header'
|
||||
import Footer from '@/app/content/views/Footer'
|
||||
import Main from '@/app/content/views/Main'
|
||||
import AppButton from '@/app/content/views/AppButton'
|
||||
import AppMain from '@/app/content/views/AppMain'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import Setup from '@/app/content/views/Setup'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import DanmakuContainer from './components/DanmakuContainer'
|
||||
import DanmakuDomain from '@/domain/Danmaku'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { cn } from '@/utils'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import VirtualRoomDomain from '@/domain/VirtualRoom'
|
||||
|
||||
/**
|
||||
* Fix requestAnimationFrame error in jest
|
||||
|
@ -28,7 +29,8 @@ if (import.meta.env.FIREFOX) {
|
|||
|
||||
export default function App() {
|
||||
const send = useRemeshSend()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
|
@ -38,23 +40,34 @@ export default function App() {
|
|||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||
const chatRoomJoinIsFinished = useRemeshQuery(chatRoomDomain.query.JoinIsFinishedQuery())
|
||||
const virtualRoomJoinIsFinished = useRemeshQuery(virtualRoomDomain.query.JoinIsFinishedQuery())
|
||||
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||
|
||||
const joinRoom = () => {
|
||||
send(chatRoomDomain.command.JoinRoomCommand())
|
||||
send(virtualRoomDomain.command.JoinRoomCommand())
|
||||
}
|
||||
|
||||
const leaveRoom = () => {
|
||||
chatRoomJoinIsFinished && send(chatRoomDomain.command.LeaveRoomCommand())
|
||||
virtualRoomJoinIsFinished && send(virtualRoomDomain.command.LeaveRoomCommand())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messageListLoadFinished) {
|
||||
if (userInfoSetFinished) {
|
||||
send(roomDomain.command.JoinRoomCommand())
|
||||
joinRoom()
|
||||
} else {
|
||||
// Clear simulated data when refreshing on the setup page
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}
|
||||
return () => leaveRoom()
|
||||
}, [userInfoSetFinished, messageListLoadFinished])
|
||||
|
||||
const danmakuContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
|
||||
return () => {
|
||||
|
@ -62,31 +75,48 @@ export default function App() {
|
|||
}
|
||||
}, [danmakuIsEnabled])
|
||||
|
||||
return (
|
||||
appStatusLoadIsFinished && (
|
||||
<div id="app" className={cn('contents', userInfo?.themeMode)}>
|
||||
<AppMain>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
<AnimatePresence>
|
||||
{notUserInfo && (
|
||||
<motion.div
|
||||
className="contents"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Setup></Setup>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
|
||||
</AppMain>
|
||||
<AppButton></AppButton>
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', leaveRoom)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', leaveRoom)
|
||||
}
|
||||
}, [])
|
||||
|
||||
<DanmakuContainer ref={danmakuContainerRef} />
|
||||
</div>
|
||||
)
|
||||
const themeMode =
|
||||
userInfo?.themeMode === 'system'
|
||||
? checkDarkMode()
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: (userInfo?.themeMode ?? (checkDarkMode() ? 'dark' : 'light'))
|
||||
|
||||
const danmakuContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div id="app" className={cn('contents', themeMode)}>
|
||||
{appStatusLoadIsFinished && (
|
||||
<>
|
||||
<AppMain>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
{notUserInfo && <Setup></Setup>}
|
||||
<Toaster
|
||||
richColors
|
||||
theme={themeMode}
|
||||
offset="70px"
|
||||
visibleToasts={1}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'dark:bg-slate-950 border dark:border-slate-600'
|
||||
}
|
||||
}}
|
||||
position="top-center"
|
||||
></Toaster>
|
||||
</AppMain>
|
||||
<AppButton></AppButton>
|
||||
</>
|
||||
)}
|
||||
<DanmakuContainer ref={danmakuContainerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { type MouseEvent, type FC, type ReactElement } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/utils'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
|
||||
export interface LikeButtonIconProps {
|
||||
children: JSX.Element
|
||||
|
@ -40,7 +41,11 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
|
|||
size="xs"
|
||||
>
|
||||
{children}
|
||||
{!!count && <span className="min-w-0 text-xs">{count}</span>}
|
||||
{!!count && (
|
||||
<span className="min-w-0 text-xs">
|
||||
{import.meta.env.FIREFOX ? <span className="tabular-nums">{count}</span> : <NumberFlow value={count} />}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
||||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent, ClipboardEvent } from 'react'
|
||||
|
||||
import { cn } from '@/utils'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
|
@ -14,6 +14,7 @@ export interface MessageInputProps {
|
|||
disabled?: boolean
|
||||
loading?: boolean
|
||||
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||
|
@ -31,6 +32,7 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
className,
|
||||
maxLength = 500,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
|
@ -45,6 +47,7 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type FC } from 'react'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { FrownIcon, HeartIcon } from 'lucide-react'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
@ -58,7 +58,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
||||
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.date}></FormatDate>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
|
@ -71,7 +71,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
count={props.data.likeUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<ThumbsUpIcon size={14}></ThumbsUpIcon>
|
||||
<HeartIcon size={14}></HeartIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
<LikeButton
|
||||
|
|
|
@ -11,10 +11,12 @@ import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/
|
|||
import { DanmakuImpl } from '@/domain/impls/Danmaku'
|
||||
import { NotificationImpl } from '@/domain/impls/Notification'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
|
||||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import { ChatRoomImpl } from '@/domain/impls/ChatRoom'
|
||||
import { VirtualRoomImpl } from '@/domain/impls/VirtualRoom'
|
||||
// Remove import after merging: https://github.com/emilkowalski/sonner/pull/508
|
||||
import '@/assets/styles/sonner.css'
|
||||
import '@/assets/styles/overlay.css'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import NotificationDomain from '@/domain/Notification'
|
||||
import { createElement } from '@/utils'
|
||||
|
||||
|
@ -36,7 +38,8 @@ export default defineContentScript({
|
|||
LocalStorageImpl,
|
||||
IndexDBStorageImpl,
|
||||
BrowserSyncStorageImpl,
|
||||
PeerRoomImpl,
|
||||
ChatRoomImpl,
|
||||
VirtualRoomImpl,
|
||||
ToastImpl,
|
||||
DanmakuImpl,
|
||||
NotificationImpl
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Button } from '@/components/ui/Button'
|
|||
import { EVENT } from '@/constants/event'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||
import LogoIcon1 from '@/assets/images/logo-1.svg'
|
||||
import LogoIcon2 from '@/assets/images/logo-2.svg'
|
||||
|
@ -18,7 +18,7 @@ import LogoIcon6 from '@/assets/images/logo-6.svg'
|
|||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { getDay } from 'date-fns'
|
||||
import { messenger } from '@/messenger'
|
||||
import useDarg from '@/hooks/useDarg'
|
||||
import useDraggable from '@/hooks/useDraggable'
|
||||
import useWindowResize from '@/hooks/useWindowResize'
|
||||
|
||||
export interface AppButtonProps {
|
||||
|
@ -36,8 +36,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
|
||||
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||
|
||||
const isDarkMode =
|
||||
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
|
||||
const isDarkMode = userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkDarkMode()
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
|
@ -45,13 +44,13 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
x,
|
||||
y,
|
||||
setRef: appButtonRef
|
||||
} = useDarg({
|
||||
} = useDraggable({
|
||||
initX: appPosition.x,
|
||||
initY: appPosition.y,
|
||||
minX: 50,
|
||||
maxX: window.innerWidth - 50,
|
||||
maxY: window.innerHeight - 22,
|
||||
minY: window.innerHeight / 2
|
||||
minY: 750
|
||||
})
|
||||
|
||||
useWindowResize(({ width, height }) => {
|
||||
|
@ -107,7 +106,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
<Button
|
||||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
className="relative size-10 overflow-hidden rounded-full p-0 shadow"
|
||||
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
@ -121,10 +120,18 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
</div>
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
||||
<Button
|
||||
onClick={handleOpenOptionsPage}
|
||||
variant="outline"
|
||||
className="size-10 rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
<Button ref={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||
<Button
|
||||
ref={appButtonRef}
|
||||
variant="outline"
|
||||
className="size-10 cursor-grab rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<HandIcon size={20} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC } from 'react'
|
||||
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC, ClipboardEvent } from 'react'
|
||||
import { CornerDownLeftIcon } from 'lucide-react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import MessageInput from '../../components/MessageInput'
|
||||
import EmojiButton from '../../components/EmojiButton'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||
import useShareRef from '@/hooks/useShareRef'
|
||||
import { Presence } from '@radix-ui/react-presence'
|
||||
|
@ -15,7 +15,7 @@ import useTriggerAway from '@/hooks/useTriggerAway'
|
|||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { blobToBase64, cn, compressImage, getRootNode, getTextSimilarity } from '@/utils'
|
||||
import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
|
@ -25,12 +25,12 @@ import { nanoid } from 'nanoid'
|
|||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
@ -136,7 +136,14 @@ const Footer: FC = () => {
|
|||
})
|
||||
.filter(Boolean)
|
||||
|
||||
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
|
||||
const newMessage = { body: transformedMessage, atUsers }
|
||||
const byteSize = getTextByteSize(JSON.stringify(newMessage))
|
||||
|
||||
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
|
||||
}
|
||||
|
||||
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
||||
|
@ -216,6 +223,13 @@ const Footer: FC = () => {
|
|||
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||
}
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.nativeEvent.clipboardData?.files[0]
|
||||
if (['image/png', 'image/jpeg', 'image/webp'].includes(file?.type ?? '')) {
|
||||
handleInjectImage(file!)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInjectEmoji = (emoji: string) => {
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||
|
||||
|
@ -236,7 +250,13 @@ const Footer: FC = () => {
|
|||
const handleInjectImage = async (file: File) => {
|
||||
try {
|
||||
setInputLoading(true)
|
||||
const blob = await compressImage({ input: file, targetSize: 30 * 1024, outputType: 'image/webp' })
|
||||
|
||||
const blob = await compressImage({
|
||||
input: file,
|
||||
targetSize: 30 * 1024,
|
||||
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
|
||||
})
|
||||
|
||||
const base64 = await blobToBase64(blob)
|
||||
const hash = nanoid()
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${message.slice(selectionEnd)}`
|
||||
|
@ -323,7 +343,7 @@ const Footer: FC = () => {
|
|||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
|
@ -335,6 +355,7 @@ const Footer: FC = () => {
|
|||
value={message}
|
||||
onInput={handleInput}
|
||||
loading={inputLoading}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
></MessageInput>
|
||||
|
|
|
@ -5,21 +5,44 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/H
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { cn, getSiteInfo } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import VirtualRoomDomain, { FromInfo, RoomUser } from '@/domain/VirtualRoom'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import AvatarCircles from '@/components/magicui/AvatarCircles'
|
||||
import Link from '@/components/Link'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const onlineCount = userList.length
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
|
||||
const chatOnlineCount = chatUserList.length
|
||||
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const virtualOnlineGroup = virtualUserList
|
||||
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
|
||||
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
|
||||
const existSite = acc.find((group) => group.origin === item.from.origin)
|
||||
if (existSite) {
|
||||
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
|
||||
!existUser && existSite.users.push(item.user)
|
||||
} else {
|
||||
acc.push({ ...item.from, users: [item.user] })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
|
||||
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg dark:bg-slate-950">
|
||||
<Avatar className="size-8">
|
||||
<Avatar className="size-8 rounded-sm">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
|
@ -27,60 +50,111 @@ const Header: FC = () => {
|
|||
</Avatar>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="overflow-hidden p-2" variant="link">
|
||||
<Button className="overflow-hidden rounded-md p-2" variant="link">
|
||||
<span className="truncate text-lg font-semibold text-slate-600 dark:text-slate-50">
|
||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 rounded-lg">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid items-center">
|
||||
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
||||
{siteInfo.description && (
|
||||
<p className="line-clamp-2 max-h-8 text-xs text-slate-500 dark:text-slate-300">
|
||||
{siteInfo.description}
|
||||
</p>
|
||||
<HoverCardContent className="w-80 rounded-lg p-0">
|
||||
<ScrollArea type="scroll" className="max-h-96 min-h-[72px] p-2" ref={setVirtualOnlineGroupScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={virtualOnlineGroup}
|
||||
defaultItemHeight={56}
|
||||
customScrollParent={virtualOnlineGroupScrollParentRef!}
|
||||
itemContent={(_index, site) => (
|
||||
<Link
|
||||
underline={false}
|
||||
href={site.origin}
|
||||
className="grid cursor-pointer grid-cols-[auto_1fr] items-center gap-x-2 rounded-lg px-2 py-1.5 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-10 rounded-sm">
|
||||
<AvatarImage src={site.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid items-center">
|
||||
<div className="flex items-center gap-x-1 overflow-hidden">
|
||||
<h4 className="flex-1 truncate text-sm font-semibold">{site.hostname.replace(/^www\./i, '')}</h4>
|
||||
<div className="shrink-0 text-sm">
|
||||
<div className="flex items-center gap-x-1 text-nowrap text-xs text-slate-500">
|
||||
<div className="flex items-center gap-x-1 pt-px">
|
||||
<span className="relative flex size-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
|
||||
site.users.length > 1 ? 'bg-green-400' : 'bg-orange-400'
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-full rounded-full',
|
||||
site.users.length > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span className="flex items-center leading-none dark:text-slate-50">
|
||||
<span className="py-[0.25em]">ONLINE</span>
|
||||
</span>
|
||||
</div>
|
||||
{import.meta.env.FIREFOX ? (
|
||||
<span className="tabular-nums">{site.users.length}</span>
|
||||
) : (
|
||||
<NumberFlow className="tabular-nums" willChange value={site.users.length} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarCircles max={9} size="xs" avatarUrls={site.users.map((user) => user.userAvatar)} />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="p-0" variant="link">
|
||||
<div className="flex items-center gap-x-1 text-nowrap text-xs text-slate-500">
|
||||
<span className="relative flex size-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
|
||||
onlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-2 rounded-full',
|
||||
onlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span className="dark:text-slate-50">ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
||||
<Button className=" rounded-md p-0 hover:no-underline" variant="link">
|
||||
<div className="relative flex items-center gap-x-1 text-nowrap text-xs text-slate-500 hover:after:absolute hover:after:bottom-0 hover:after:left-0 hover:after:h-px hover:after:w-full hover:after:bg-black">
|
||||
<div className="flex items-center gap-x-1 pt-px">
|
||||
<span className="relative flex size-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
|
||||
chatOnlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-full rounded-full',
|
||||
chatOnlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span className="flex items-center leading-none dark:text-slate-50">
|
||||
<span className="py-[0.25em]">ONLINE</span>
|
||||
</span>
|
||||
</div>
|
||||
{import.meta.env.FIREFOX ? (
|
||||
<span className="tabular-nums">{Math.min(chatUserList.length, 99)}</span>
|
||||
) : (
|
||||
<span className="tabular-nums">
|
||||
<NumberFlow className="tabular-nums" willChange value={Math.min(chatUserList.length, 99)} />
|
||||
{chatUserList.length > 99 && <span className="text-xs">+</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<ScrollArea type="scroll" className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={userList}
|
||||
data={chatUserList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
customScrollParent={chatUserListScrollParentRef!}
|
||||
itemContent={(_index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
|
|
|
@ -5,33 +5,35 @@ import MessageList from '../../components/MessageList'
|
|||
import MessageItem from '../../components/MessageItem'
|
||||
import PromptItem from '../../components/PromptItem'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import RoomDomain, { MessageType } from '@/domain/Room'
|
||||
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
|
||||
const messageList = _messageList.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
const messageList = _messageList
|
||||
.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendHateMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
|||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
|
||||
import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
|
||||
import { generateRandomAvatar, generateRandomName } from '@/utils'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
|
@ -33,15 +33,13 @@ const mockTextList = [
|
|||
``
|
||||
]
|
||||
|
||||
let printTextList = [...mockTextList]
|
||||
|
||||
const generateUserInfo = async (): Promise<UserInfo> => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: generateRandomName(),
|
||||
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
|
||||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
themeMode: 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
|
@ -52,8 +50,9 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
const { name: username, avatar: userAvatar, id: userId } = userInfo
|
||||
return {
|
||||
id: nanoid(),
|
||||
body: printTextList.shift()!,
|
||||
date: Date.now(),
|
||||
body: mockTextList.shift()!,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
type: MessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
|
@ -72,8 +71,8 @@ const Setup: FC = () => {
|
|||
const [userInfo, setUserInfo] = useState<UserInfo>()
|
||||
|
||||
const handleSetup = () => {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||
}
|
||||
|
||||
const refreshUserInfo = async () => {
|
||||
|
@ -87,19 +86,16 @@ const Setup: FC = () => {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
printTextList.length === 0 && (printTextList = [...mockTextList])
|
||||
const timer = new Timer(
|
||||
async () => {
|
||||
await createMessage(await refreshUserInfo())
|
||||
},
|
||||
{ delay: 2000, immediate: true, limit: printTextList.length }
|
||||
{ delay: 2000, immediate: true, limit: mockTextList.length }
|
||||
)
|
||||
timer.on('stop', () => {
|
||||
printTextList.length === 0 && send(messageListDomain.command.ClearListCommand())
|
||||
})
|
||||
timer.start()
|
||||
return () => {
|
||||
timer.stop()
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
@ -16,7 +16,15 @@ function App() {
|
|||
<VersionLink></VersionLink>
|
||||
<Main>
|
||||
<ProfileForm></ProfileForm>
|
||||
<Toaster richColors position="top-center" duration={1000000} />
|
||||
<Toaster
|
||||
richColors
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'dark:bg-slate-950 border dark:border-slate-600'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Main>
|
||||
<BadgeList></BadgeList>
|
||||
</Layout>
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Button } from '@/components/ui/Button'
|
|||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||
import { checkSystemDarkMode, cn, generateRandomAvatar } from '@/utils'
|
||||
import { cn, generateRandomAvatar } from '@/utils'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
|
@ -24,7 +24,7 @@ const defaultUserInfo: UserInfo = {
|
|||
name: '',
|
||||
avatar: '',
|
||||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
themeMode: 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
|
@ -32,9 +32,9 @@ const defaultUserInfo: UserInfo = {
|
|||
|
||||
const formSchema = v.object({
|
||||
id: v.string(),
|
||||
createTime: v.number(),
|
||||
// Pure numeric strings will be converted to number
|
||||
// Issues: https://github.com/unjs/unstorage/issues/277
|
||||
createTime: v.number(),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
|
|
17
src/assets/styles/overlay.css
Normal file
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;
|
||||
}
|
|
@ -61,6 +61,17 @@
|
|||
list-style: none;
|
||||
outline: none;
|
||||
z-index: 999999999;
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-lifted='true']) {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
:where([data-sonner-toaster][data-lifted='true']) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
:where([data-sonner-toaster][data-x-position='right']) {
|
||||
|
@ -234,7 +245,6 @@
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
background: var(--gray1);
|
||||
color: var(--gray12);
|
||||
border: 1px solid var(--gray4);
|
||||
transform: var(--toast-close-button-transform);
|
||||
|
@ -247,6 +257,10 @@
|
|||
border-color 200ms;
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-close-button] {
|
||||
background: var(--gray1);
|
||||
}
|
||||
|
||||
:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
|
||||
box-shadow:
|
||||
0px 4px 12px rgba(0, 0, 0, 0.1),
|
||||
|
@ -359,6 +373,10 @@
|
|||
transition: none;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-swiped='true'] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
|
||||
[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {
|
||||
animation: swipe-out 200ms ease-out forwards;
|
||||
|
@ -662,26 +680,7 @@
|
|||
transform 200ms;
|
||||
}
|
||||
|
||||
section:has([data-sonner-toaster]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Custom styles */
|
||||
:where([data-sonner-toaster]) {
|
||||
max-width: 300px;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
max-width: 300px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@
|
|||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
:host,
|
||||
:root {
|
||||
@apply !bg-background !text-foreground !text-base !visible;
|
||||
|
|
|
@ -5,11 +5,18 @@ export interface LinkProps {
|
|||
href: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
underline?: boolean
|
||||
}
|
||||
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children }, ref) => {
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
|
||||
return (
|
||||
<a href={href} target={href} rel="noopener noreferrer" className={cn('hover:underline', className)} ref={ref}>
|
||||
<a
|
||||
href={href}
|
||||
target={href}
|
||||
rel="noopener noreferrer"
|
||||
className={cn(underline && 'hover:underline', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
|
|
@ -46,12 +46,21 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|||
urlTransform={urlTransform}
|
||||
components={{
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1 className={cn('my-2 mt-0 font-semibold text-2xl', className)} {...props} />
|
||||
<h1 className={cn('my-2 mt-0 font-semibold text-2xl dark:text-slate-50', className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }) => (
|
||||
<h2 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
||||
),
|
||||
h3: ({ className, ...props }) => (
|
||||
<h3 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
||||
),
|
||||
h4: ({ className, ...props }) => (
|
||||
<h4 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0 font-semibold', className)} {...props} />,
|
||||
img: ({ className, alt, ...props }) => (
|
||||
<img className={cn('my-2 max-w-[100%] rounded', className)} alt={alt} {...props} />
|
||||
),
|
||||
strong: ({ className, ...props }) => <strong className={cn('dark:text-slate-50', className)} {...props} />,
|
||||
a: ({ className, ...props }) => (
|
||||
<a
|
||||
className={cn('text-blue-500', className)}
|
||||
|
|
69
src/components/magicui/AvatarCircles.tsx
Normal file
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
|
|
@ -1,8 +1,8 @@
|
|||
import { version } from '@/../package.json'
|
||||
// https://www.webfx.com/tools/emoji-cheat-sheet/
|
||||
|
||||
export const EMOJI_LIST = [
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
|
@ -112,6 +112,7 @@ export const EMOJI_LIST = [
|
|||
'👽',
|
||||
'👾',
|
||||
'🤖',
|
||||
'👀',
|
||||
'😺',
|
||||
'😸',
|
||||
'😹',
|
||||
|
@ -185,12 +186,12 @@ export const BREAKPOINTS = {
|
|||
|
||||
export const MESSAGE_MAX_LENGTH = 500 as const
|
||||
|
||||
export const STORAGE_NAME = 'WEB_CHAT' as const
|
||||
|
||||
export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
|
||||
export const STORAGE_NAME = `WEB_CHAT_${version}` as const
|
||||
|
||||
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
|
||||
|
||||
export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
|
||||
|
||||
export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
|
||||
/**
|
||||
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
|
@ -198,3 +199,13 @@ export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
|
|||
* 8kb * (1 - 0.33) = 5488 bytes
|
||||
*/
|
||||
export const MAX_AVATAR_SIZE = 5120 as const
|
||||
|
||||
export const SYNC_HISTORY_MAX_DAYS = 30 as const
|
||||
|
||||
/**
|
||||
* https://lgrahl.de/articles/demystifying-webrtc-dc-size-limit.html
|
||||
* Message max size is 256KiB; if the message is too large, it will cause the connection to drop.
|
||||
*/
|
||||
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
|
||||
|
||||
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const
|
||||
|
|
|
@ -3,7 +3,7 @@ import StatusModule from './modules/Status'
|
|||
import { LocalStorageExtern } from './externs/Storage'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import StorageEffect from './modules/StorageEffect'
|
||||
import RoomDomain, { SendType } from './Room'
|
||||
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
export interface AppStatus {
|
||||
|
@ -26,7 +26,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
extern: LocalStorageExtern,
|
||||
key: APP_STATUS_STORAGE_KEY
|
||||
})
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const StatusLoadModule = StatusModule(domain, {
|
||||
name: 'AppStatus.LoadStatusModule'
|
||||
|
@ -40,7 +40,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
})
|
||||
|
||||
const StatusState = domain.state<AppStatus>({
|
||||
name: 'AppStatus.OpenState',
|
||||
name: 'AppStatus.StatusState',
|
||||
default: defaultStatusState
|
||||
})
|
||||
|
||||
|
@ -131,7 +131,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'OnMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onMessage$ = fromEvent(roomDomain.event.OnMessageEvent).pipe(
|
||||
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
map((message) => {
|
||||
const status = get(StatusState())
|
||||
if (!status.open && message.type === SendType.Text) {
|
||||
|
|
690
src/domain/ChatRoom.ts
Normal file
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
|
|
@ -1,15 +1,15 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { DanmakuExtern } from './externs/Danmaku'
|
||||
import RoomDomain, { TextMessage } from './Room'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const DanmakuDomain = Remesh.domain({
|
||||
name: 'DanmakuDomain',
|
||||
impl: (domain) => {
|
||||
const danmaku = domain.getExtern(DanmakuExtern)
|
||||
const danmakuExtern = domain.getExtern(DanmakuExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const MountState = domain.state({
|
||||
name: 'Danmaku.MountState',
|
||||
|
@ -49,7 +49,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const PushCommand = domain.command({
|
||||
name: 'Danmaku.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmaku.push(message)
|
||||
danmakuExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -57,7 +57,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const UnshiftCommand = domain.command({
|
||||
name: 'Danmaku.UnshiftCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmaku.unshift(message)
|
||||
danmakuExtern.unshift(message)
|
||||
return [UnshiftEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -65,7 +65,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const ClearCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: () => {
|
||||
danmaku.clear()
|
||||
danmakuExtern.clear()
|
||||
return [ClearEvent()]
|
||||
}
|
||||
})
|
||||
|
@ -73,7 +73,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const MountCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: (_, container: HTMLElement) => {
|
||||
danmaku.mount(container)
|
||||
danmakuExtern.mount(container)
|
||||
return [MountEvent(container)]
|
||||
}
|
||||
})
|
||||
|
@ -81,7 +81,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const UnmountCommand = domain.command({
|
||||
name: 'Danmaku.UnmountCommand',
|
||||
impl: () => {
|
||||
danmaku.unmount()
|
||||
danmakuExtern.unmount()
|
||||
return [UnmountEvent()]
|
||||
}
|
||||
})
|
||||
|
@ -121,8 +121,8 @@ const DanmakuDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Danmaku.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const sendTextMessage$ = fromEvent(roomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
|
|
|
@ -24,7 +24,8 @@ export interface NormalMessage extends MessageUser {
|
|||
type: MessageType.Normal
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
|
@ -34,7 +35,8 @@ export interface PromptMessage extends MessageUser {
|
|||
type: MessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
}
|
||||
|
||||
export type Message = NormalMessage | PromptMessage
|
||||
|
@ -120,6 +122,38 @@ const MessageListDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const UpsertItemCommand = domain.command({
|
||||
name: 'MessageList.UpsertItemCommand',
|
||||
impl: (_, message: Message) => {
|
||||
return [
|
||||
MessageListModule.command.UpsertItemCommand(message),
|
||||
UpsertItemEvent(message),
|
||||
ChangeListEvent(),
|
||||
SyncToStorageEvent()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const UpsertItemEvent = domain.event<Message>({
|
||||
name: 'MessageList.UpsertItemEvent'
|
||||
})
|
||||
|
||||
const ResetListCommand = domain.command({
|
||||
name: 'MessageList.ResetListCommand',
|
||||
impl: (_, messages: Message[]) => {
|
||||
return [
|
||||
MessageListModule.command.SetListCommand(messages),
|
||||
ResetListEvent(messages),
|
||||
ChangeListEvent(),
|
||||
SyncToStorageEvent()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const ResetListEvent = domain.event<Message[]>({
|
||||
name: 'MessageList.ResetListEvent'
|
||||
})
|
||||
|
||||
const ClearListEvent = domain.event({
|
||||
name: 'MessageList.ClearListEvent'
|
||||
})
|
||||
|
@ -164,14 +198,18 @@ const MessageListDomain = Remesh.domain({
|
|||
CreateItemCommand,
|
||||
UpdateItemCommand,
|
||||
DeleteItemCommand,
|
||||
ClearListCommand
|
||||
UpsertItemCommand,
|
||||
ClearListCommand,
|
||||
ResetListCommand
|
||||
},
|
||||
event: {
|
||||
ChangeListEvent,
|
||||
CreateItemEvent,
|
||||
UpdateItemEvent,
|
||||
DeleteItemEvent,
|
||||
UpsertItemEvent,
|
||||
ClearListEvent,
|
||||
ResetListEvent,
|
||||
SyncToStateEvent,
|
||||
SyncToStorageEvent
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { NotificationExtern } from './externs/Notification'
|
||||
import RoomDomain, { TextMessage } from './Room'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const NotificationDomain = Remesh.domain({
|
||||
name: 'NotificationDomain',
|
||||
impl: (domain) => {
|
||||
const notification = domain.getExtern(NotificationExtern)
|
||||
const notificationExtern = domain.getExtern(NotificationExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const NotificationEnabledState = domain.state<boolean>({
|
||||
name: 'Notification.EnabledState',
|
||||
|
@ -40,7 +40,7 @@ const NotificationDomain = Remesh.domain({
|
|||
const PushCommand = domain.command({
|
||||
name: 'Notification.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
notification.push(message)
|
||||
notificationExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -68,7 +68,7 @@ const NotificationDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const notificationEnabled = get(IsEnabledQuery())
|
||||
|
|
|
@ -1,466 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
||||
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
|
||||
export { MessageType }
|
||||
|
||||
export enum SendType {
|
||||
Like = 'like',
|
||||
Hate = 'hate',
|
||||
Text = 'text',
|
||||
Join = 'join'
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.Join
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
}
|
||||
|
||||
export interface LikeMessage extends MessageUser {
|
||||
type: SendType.Like
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HateMessage extends MessageUser {
|
||||
type: SendType.Hate
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TextMessage extends MessageUser {
|
||||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerId: string; joinTime: number }
|
||||
|
||||
const RoomDomain = Remesh.domain({
|
||||
name: 'RoomDomain',
|
||||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const peerRoom = domain.getExtern(PeerRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: peerRoom.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
name: 'Room.PeerIdQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerIdState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinStatusModule = StatusModule(domain, {
|
||||
name: 'Room.JoinStatusModule'
|
||||
})
|
||||
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'Room.UserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
peerRoom.joinRoom()
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(peerRoom.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
peerRoom.leaveRoom()
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(peerRoom.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
name: 'Room.SendTextMessageCommand',
|
||||
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
|
||||
const textMessage: TextMessage = {
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
...textMessage,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(textMessage)
|
||||
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'Room.SendLikeMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const likeMessage: LikeMessage = {
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.Like
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage(likeMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendHateMessageCommand = domain.command({
|
||||
name: 'Room.SendHateMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const hateMessage: HateMessage = {
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.Hate
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage(hateMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendJoinMessageCommand = domain.command({
|
||||
name: 'Room.SendJoinMessageCommand',
|
||||
impl: ({ get }, targetPeerId: string) => {
|
||||
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
type: SendType.Join
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(syncUserMessage, targetPeerId)
|
||||
return [SendJoinMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'Room.UpdateUserListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
|
||||
const userList = get(UserListState())
|
||||
if (action.type === 'create') {
|
||||
return [UserListState().new(upsert(userList, action.user, 'userId'))]
|
||||
} else {
|
||||
return [UserListState().new(userList.filter(({ userId }) => userId !== action.user.userId))]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'Room.SendJoinMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.SendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<LikeMessage>({
|
||||
name: 'Room.SendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<HateMessage>({
|
||||
name: 'Room.SendHateMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.JoinRoomEvent'
|
||||
})
|
||||
|
||||
const LeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.LeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'Room.OnMessageEvent'
|
||||
})
|
||||
|
||||
const OnTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.OnTextMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnJoinRoomEvent'
|
||||
})
|
||||
|
||||
const OnLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnErrorEvent = domain.event<Error>({
|
||||
name: 'Room.OnErrorEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (peerRoom.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: ({ get }) => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// console.log('onMessage', message)
|
||||
|
||||
const messageEvent$ = of(OnMessageEvent(message))
|
||||
|
||||
const textMessageEvent$ = of(message.type === SendType.Text ? OnTextMessageEvent(message) : null)
|
||||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.Join: {
|
||||
const userList = get(UserListQuery())
|
||||
const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)!
|
||||
// If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId
|
||||
const isSelfJoinEvent = !!userList.find((user) => user.userId === message.userId)
|
||||
// When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event
|
||||
// Use joinTime to determine if it's a new user
|
||||
const isNewJoinEvent = selfUser.joinTime < message.joinTime
|
||||
|
||||
return isSelfJoinEvent
|
||||
? EMPTY
|
||||
: of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
isNewJoinEvent
|
||||
? messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
id: nanoid(),
|
||||
body: `"${message.username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
})
|
||||
: null
|
||||
)
|
||||
}
|
||||
case SendType.Text:
|
||||
return of(
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
)
|
||||
case SendType.Like:
|
||||
case SendType.Hate: {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
return EMPTY
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
const type = message.type === 'like' ? 'likeUsers' : 'hateUsers'
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
[type]: desert(
|
||||
_message[type],
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
}
|
||||
})()
|
||||
|
||||
return merge(messageEvent$, textMessageEvent$, messageCommand$)
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
|
||||
|
||||
if (user) {
|
||||
return [
|
||||
UpdateUserListCommand({ type: 'delete', user }),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...user,
|
||||
id: nanoid(),
|
||||
body: `"${user.username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(error)
|
||||
return OnErrorEvent(error)
|
||||
})
|
||||
)
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes
|
||||
domain.effect({
|
||||
name: 'Room.OnUnloadEffect',
|
||||
impl: ({ get }) => {
|
||||
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
|
||||
map(() => {
|
||||
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
|
||||
})
|
||||
)
|
||||
return beforeUnload$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
JoinIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
SendJoinMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendJoinMessageEvent,
|
||||
JoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnTextMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
OnErrorEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default RoomDomain
|
|
@ -1,27 +1,55 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import ToastModule from './modules/Toast'
|
||||
import RoomDomain from './Room'
|
||||
import { map, merge } from 'rxjs'
|
||||
import ChatRoomDomain, { SendType } from './ChatRoom'
|
||||
import VirtualRoomDomain from './VirtualRoom'
|
||||
import { filter, map, merge } from 'rxjs'
|
||||
|
||||
const ToastDomain = Remesh.domain({
|
||||
name: 'ToastDomain',
|
||||
impl: (domain) => {
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = domain.getDomain(VirtualRoomDomain())
|
||||
const toastModule = ToastModule(domain)
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnRoomSelfJoinRoomEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomJoin$ = fromEvent(chatRoomDomain.event.SelfJoinRoomEvent).pipe(
|
||||
map(() => toastModule.command.LoadingCommand({ message: 'Connected to the chat.', duration: 3000 }))
|
||||
)
|
||||
|
||||
return onRoomJoin$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnRoomErrorEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomError$ = fromEvent(roomDomain.event.OnErrorEvent)
|
||||
|
||||
const onError$ = merge(onRoomError$).pipe(
|
||||
const onRoomError$ = merge(
|
||||
fromEvent(chatRoomDomain.event.OnErrorEvent),
|
||||
fromEvent(virtualRoomDomain.event.OnErrorEvent)
|
||||
).pipe(
|
||||
map((error) => {
|
||||
return toastModule.command.ErrorCommand(error.message)
|
||||
})
|
||||
)
|
||||
|
||||
return onError$
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnSyncHistoryEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
filter((message) => message.type === SendType.SyncHistory),
|
||||
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
|
||||
)
|
||||
|
||||
return onSyncHistory$
|
||||
}
|
||||
})
|
||||
|
||||
return toastModule
|
||||
}
|
||||
})
|
||||
|
|
381
src/domain/VirtualRoom.ts
Normal file
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
|
|
@ -1,19 +1,19 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '../ChatRoom'
|
||||
|
||||
export interface PeerRoom {
|
||||
export interface ChatRoom {
|
||||
readonly peerId: string
|
||||
readonly roomId: string
|
||||
joinRoom: () => PeerRoom
|
||||
sendMessage: (message: RoomMessage, id?: string) => PeerRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => PeerRoom
|
||||
leaveRoom: () => PeerRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onError: (callback: (error: Error) => void) => PeerRoom
|
||||
joinRoom: () => ChatRoom
|
||||
sendMessage: (message: RoomMessage, id?: string | string[]) => ChatRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => ChatRoom
|
||||
leaveRoom: () => ChatRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => ChatRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => ChatRoom
|
||||
onError: (callback: (error: Error) => void) => ChatRoom
|
||||
}
|
||||
|
||||
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
|
||||
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
|
||||
default: {
|
||||
peerId: '',
|
||||
roomId: '',
|
|
@ -1,5 +1,5 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Danmaku {
|
||||
push: (message: TextMessage) => void
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Notification {
|
||||
push: (message: TextMessage) => Promise<string>
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { Remesh } from 'remesh'
|
||||
|
||||
export interface Toast {
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
info: (message: string) => void
|
||||
warning: (message: string) => void
|
||||
success: (message: string, duration?: number) => number | string
|
||||
error: (message: string, duration?: number) => number | string
|
||||
info: (message: string, duration?: number) => number | string
|
||||
warning: (message: string, duration?: number) => number | string
|
||||
loading: (message: string, duration?: number) => number | string
|
||||
cancel: (id: number | string) => number | string
|
||||
}
|
||||
|
||||
export const ToastExtern = Remesh.extern<Toast>({
|
||||
|
@ -20,6 +22,12 @@ export const ToastExtern = Remesh.extern<Toast>({
|
|||
},
|
||||
warning: () => {
|
||||
throw new Error('"warning" not implemented.')
|
||||
},
|
||||
loading: () => {
|
||||
throw new Error('"loading" not implemented.')
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error('"cancel" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
42
src/domain/externs/VirtualRoom.ts
Normal file
42
src/domain/externs/VirtualRoom.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '@/domain/VirtualRoom'
|
||||
|
||||
export interface VirtualRoom {
|
||||
readonly peerId: string
|
||||
readonly roomId: string
|
||||
joinRoom: () => VirtualRoom
|
||||
sendMessage: (message: RoomMessage, id?: string | string[]) => VirtualRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => VirtualRoom
|
||||
leaveRoom: () => VirtualRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => VirtualRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => VirtualRoom
|
||||
onError: (callback: (error: Error) => void) => VirtualRoom
|
||||
}
|
||||
|
||||
export const VirtualRoomExtern = Remesh.extern<VirtualRoom>({
|
||||
default: {
|
||||
peerId: '',
|
||||
roomId: '',
|
||||
joinRoom: () => {
|
||||
throw new Error('"joinRoom" not implemented.')
|
||||
},
|
||||
sendMessage: () => {
|
||||
throw new Error('"sendMessage" not implemented.')
|
||||
},
|
||||
onMessage: () => {
|
||||
throw new Error('"onMessage" not implemented.')
|
||||
},
|
||||
leaveRoom: () => {
|
||||
throw new Error('"leaveRoom" not implemented.')
|
||||
},
|
||||
onJoinRoom: () => {
|
||||
throw new Error('"onJoinRoom" not implemented.')
|
||||
},
|
||||
onLeaveRoom: () => {
|
||||
throw new Error('"onLeaveRoom" not implemented.')
|
||||
},
|
||||
onError: () => {
|
||||
throw new Error('"onError" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,25 +1,28 @@
|
|||
import { Artico, Room } from '@rtco/client'
|
||||
import { Room } from '@rtco/client'
|
||||
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
|
||||
import { stringToHex } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '@/domain/ChatRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
peer: Peer
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class PeerRoom extends EventHub {
|
||||
class ChatRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
readonly roomId: string
|
||||
private rtco?: Artico
|
||||
readonly peerId: string
|
||||
private room?: Room
|
||||
|
||||
constructor(config: Config) {
|
||||
super()
|
||||
this.peer = config.peer
|
||||
this.roomId = config.roomId
|
||||
this.peerId = config.peerId || nanoid()
|
||||
this.peerId = config.peer.id
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -30,31 +33,33 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
if (!this.rtco) {
|
||||
this.rtco = new Artico({ id: this.peerId })
|
||||
}
|
||||
if (this.room) {
|
||||
this.room = this.rtco.join(this.roomId)
|
||||
this.room = this.peer.join(this.roomId)
|
||||
} else {
|
||||
this.rtco!.on('open', () => {
|
||||
this.room = this.rtco!.join(this.roomId)
|
||||
if (this.peer.state === 'ready') {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
} else {
|
||||
this.peer!.on('open', () => {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
sendMessage(message: RoomMessage, id?: string) {
|
||||
sendMessage(message: RoomMessage, id?: string | string[]) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -65,11 +70,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -121,7 +126,7 @@ class PeerRoom extends EventHub {
|
|||
return this
|
||||
}
|
||||
onError(callback: (error: Error) => void) {
|
||||
this.rtco?.on('error', (error) => callback(error))
|
||||
this.peer?.on('error', (error) => callback(error))
|
||||
this.on('error', (error: Error) => callback(error))
|
||||
return this
|
||||
}
|
||||
|
@ -129,9 +134,9 @@ class PeerRoom extends EventHub {
|
|||
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
const chatRoom = new ChatRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
export const ChatRoomImpl = ChatRoomExtern.impl(chatRoom)
|
||||
|
||||
// https://github.com/w3c/webextensions/issues/72
|
||||
// https://issues.chromium.org/issues/40251342
|
|
@ -1,6 +1,6 @@
|
|||
import { DanmakuExtern } from '@/domain/externs/Danmaku'
|
||||
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { createElement } from 'react'
|
||||
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { NotificationExtern } from '@/domain/externs/Notification'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import { messenger } from '@/messenger'
|
||||
|
||||
|
|
22
src/domain/impls/Peer.ts
Normal file
22
src/domain/impls/Peer.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
import { Artico } from '@rtco/client'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
}
|
||||
|
||||
export default class Peer extends Artico {
|
||||
private static instance: Peer | null = null
|
||||
private constructor(config: Config = {}) {
|
||||
const { peerId = nanoid() } = config
|
||||
super({ id: peerId })
|
||||
}
|
||||
|
||||
public static createInstance(config: Config = {}) {
|
||||
return (this.instance ??= new Peer(config))
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
return this.instance
|
||||
}
|
||||
}
|
|
@ -8,6 +8,11 @@ import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
|||
import { Storage } from '@/domain/externs/Storage'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
/**
|
||||
* Waiting to be resolved
|
||||
* @see https://github.com/unjs/unstorage/issues/277
|
||||
* */
|
||||
|
||||
export const localStorage = createStorage({
|
||||
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
|
||||
})
|
||||
|
|
|
@ -2,16 +2,24 @@ import { toast } from 'sonner'
|
|||
import { ToastExtern } from '@/domain/externs/Toast'
|
||||
|
||||
export const ToastImpl = ToastExtern.impl({
|
||||
success: (message: string) => {
|
||||
toast.success(message)
|
||||
success: (message: string, duration: number = 4000) => {
|
||||
return toast.success(message, { duration })
|
||||
},
|
||||
error: (message: string) => {
|
||||
toast.error(message)
|
||||
error: (message: string, duration: number = 4000) => {
|
||||
return toast.error(message, { duration })
|
||||
},
|
||||
info: (message: string) => {
|
||||
toast.info(message)
|
||||
info: (message: string, duration: number = 4000) => {
|
||||
return toast.info(message, { duration })
|
||||
},
|
||||
warning: (message: string) => {
|
||||
toast.warning(message)
|
||||
warning: (message: string, duration: number = 4000) => {
|
||||
return toast.warning(message, { duration })
|
||||
},
|
||||
loading: (message: string, duration: number = 4000) => {
|
||||
const id = toast.loading(message, { duration })
|
||||
setTimeout(() => toast.dismiss(id), duration)
|
||||
return id
|
||||
},
|
||||
cancel: (id: number | string) => {
|
||||
return toast.dismiss(id)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,27 +1,29 @@
|
|||
import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
|
||||
import { Room } from '@rtco/client'
|
||||
|
||||
// import { joinRoom } from 'trystero/firebase'
|
||||
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
|
||||
import { stringToHex } from '@/utils'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '@/domain/VirtualRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import { VIRTUAL_ROOM_ID } from '@/constants/config'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
peer: Peer
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class PeerRoom extends EventHub {
|
||||
readonly appId: string
|
||||
private room?: Room
|
||||
class VirtualRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
readonly roomId: string
|
||||
readonly peerId: string
|
||||
private room?: Room
|
||||
|
||||
constructor(config: Config) {
|
||||
super()
|
||||
this.appId = __NAME__
|
||||
this.peer = config.peer
|
||||
this.roomId = config.roomId
|
||||
this.peerId = selfId
|
||||
this.peerId = config.peer.id
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -32,34 +34,34 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
this.room = joinRoom({ appId: this.appId }, this.roomId)
|
||||
/**
|
||||
* If we wait to join, it will result in not being able to listen to our own join event.
|
||||
* This might be related to the fact that:
|
||||
* (If called more than once, only the latest callback registered is ever called.)
|
||||
* Multiple listeners may overwrite each other.
|
||||
* @see: https://github.com/dmotz/trystero?tab=readme-ov-file#onpeerjoincallback
|
||||
*/
|
||||
// this.room.onPeerJoin(() => this.emit('action'))
|
||||
this.emit('action')
|
||||
if (this.room) {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
} else {
|
||||
if (this.peer.state === 'ready') {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
} else {
|
||||
this.peer!.on('open', () => {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
sendMessage(message: RoomMessage, id?: string) {
|
||||
sendMessage(message: RoomMessage, id?: string | string[]) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as any as DataPayload, id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as any as DataPayload, id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -69,13 +71,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as any as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as any as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -86,15 +86,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
this.room.on('join', (id) => callback(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
this.room.on('join', (id) => callback(id))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -105,11 +101,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -130,19 +126,15 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
return this
|
||||
}
|
||||
|
||||
onError(callback: (error: Error) => void) {
|
||||
this.peer?.on('error', (error) => callback(error))
|
||||
this.on('error', (error: Error) => callback(error))
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
const hostRoomId = stringToHex(VIRTUAL_ROOM_ID)
|
||||
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
const virtualRoom = new VirtualRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
|
||||
// https://github.com/w3c/webextensions/issues/72
|
||||
// https://issues.chromium.org/issues/40251342
|
||||
// https://github.com/w3c/webrtc-extensions/issues/77
|
||||
// https://github.com/aklinker1/webext-core/pull/70
|
||||
export const VirtualRoomImpl = VirtualRoomExtern.impl(virtualRoom)
|
|
@ -6,53 +6,92 @@ export interface ToastOptions {
|
|||
}
|
||||
|
||||
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
const toastExtern = domain.getExtern(ToastExtern)
|
||||
|
||||
const SuccessEvent = domain.event({
|
||||
const SuccessEvent = domain.event<number | string>({
|
||||
name: `${options.name}.SuccessEvent`
|
||||
})
|
||||
|
||||
const SuccessCommand = domain.command({
|
||||
name: `${options.name}.SuccessCommand`,
|
||||
impl: (_, message: string) => {
|
||||
toast.success(message)
|
||||
return [SuccessEvent()]
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toastExtern.success(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
return [SuccessEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
const ErrorEvent = domain.event({
|
||||
const ErrorEvent = domain.event<number | string>({
|
||||
name: `${options.name}.ErrorEvent`
|
||||
})
|
||||
|
||||
const ErrorCommand = domain.command({
|
||||
name: `${options.name}.ErrorCommand`,
|
||||
impl: (_, message: string) => {
|
||||
toast.error(message)
|
||||
return [ErrorEvent()]
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toastExtern.error(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
return [ErrorEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
const InfoEvent = domain.event({
|
||||
const InfoEvent = domain.event<number | string>({
|
||||
name: `${options.name}.InfoEvent`
|
||||
})
|
||||
|
||||
const InfoCommand = domain.command({
|
||||
name: `${options.name}.InfoCommand`,
|
||||
impl: (_, message: string) => {
|
||||
toast.info(message)
|
||||
return [InfoEvent()]
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toastExtern.info(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
return [InfoEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
const WarningEvent = domain.event({
|
||||
const WarningEvent = domain.event<number | string>({
|
||||
name: `${options.name}.WarningEvent`
|
||||
})
|
||||
|
||||
const WarningCommand = domain.command({
|
||||
name: `${options.name}.WarningCommand`,
|
||||
impl: (_, message: string) => {
|
||||
toast.warning(message)
|
||||
return [WarningEvent()]
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toastExtern.warning(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
return [WarningEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
const LoadingEvent = domain.event<number | string>({
|
||||
name: `${options.name}.LoadingEvent`
|
||||
})
|
||||
|
||||
const LoadingCommand = domain.command({
|
||||
name: `${options.name}.LoadingCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toastExtern.loading(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
return [LoadingEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
const CancelEvent = domain.event<number | string>({
|
||||
name: `${options.name}.CancelEvent`
|
||||
})
|
||||
|
||||
const CancelCommand = domain.command({
|
||||
name: `${options.name}.CancelCommand`,
|
||||
impl: (_, id: number | string) => {
|
||||
toastExtern.cancel(id)
|
||||
return [CancelEvent(id)]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -61,13 +100,17 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
SuccessEvent,
|
||||
ErrorEvent,
|
||||
InfoEvent,
|
||||
WarningEvent
|
||||
WarningEvent,
|
||||
LoadingEvent,
|
||||
CancelEvent
|
||||
},
|
||||
command: {
|
||||
SuccessCommand,
|
||||
ErrorCommand,
|
||||
InfoCommand,
|
||||
WarningCommand
|
||||
WarningCommand,
|
||||
LoadingCommand,
|
||||
CancelCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,12 +20,14 @@ const useCursorPosition = () => {
|
|||
handleRef.current.removeEventListener('input', handler)
|
||||
handleRef.current.removeEventListener('keydown', handler)
|
||||
handleRef.current.removeEventListener('keyup', handler)
|
||||
handleRef.current.removeEventListener('focus', handler)
|
||||
}
|
||||
if (node) {
|
||||
node.addEventListener('click', handler)
|
||||
node.addEventListener('input', handler)
|
||||
node.addEventListener('keydown', handler)
|
||||
node.addEventListener('keyup', handler)
|
||||
node.addEventListener('focus', handler)
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
|
|
|
@ -10,7 +10,7 @@ export interface DargOptions {
|
|||
minY: number
|
||||
}
|
||||
|
||||
const useDarg = (options: DargOptions) => {
|
||||
const useDraggable = (options: DargOptions) => {
|
||||
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
|
||||
|
||||
const mousePosition = useRef({ x: 0, y: 0 })
|
||||
|
@ -91,4 +91,4 @@ const useDarg = (options: DargOptions) => {
|
|||
return { setRef, ...position }
|
||||
}
|
||||
|
||||
export default useDarg
|
||||
export default useDraggable
|
|
@ -1,6 +1,6 @@
|
|||
import { EVENT } from '@/constants/event'
|
||||
import { defineExtensionMessaging } from '@webext-core/messaging'
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
interface ProtocolMap {
|
||||
[EVENT.OPTIONS_PAGE_OPEN]: () => void
|
||||
|
|
11
src/utils/checkDarkMode.ts
Normal file
11
src/utils/checkDarkMode.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
const checkDarkMode = () => {
|
||||
const colorScheme = document.documentElement.style.getPropertyValue('color-scheme').trim()
|
||||
|
||||
if (colorScheme === 'dark') {
|
||||
return true // Prefer the website's color-scheme property value
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches // Otherwise, check the system theme
|
||||
}
|
||||
|
||||
export default checkDarkMode
|
|
@ -1,3 +0,0 @@
|
|||
const checkSystemDarkMode = () => window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
export default checkSystemDarkMode
|
|
@ -59,15 +59,21 @@ const compress = async (
|
|||
|
||||
const compressImage = async (options: Options) => {
|
||||
const { input, targetSize, toleranceSize = -1024 } = options
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
|
||||
throw new Error('Only PNG, JPEG and WebP image are supported.')
|
||||
}
|
||||
|
||||
if (input.size <= targetSize) {
|
||||
return input
|
||||
if (toleranceSize % 1024 !== 0) {
|
||||
throw new Error('Tolerance size must be a multiple of 1024.')
|
||||
}
|
||||
|
||||
const outputType = options.outputType || (input.type as ImageType)
|
||||
|
||||
if (input.size <= targetSize && input.type === outputType) {
|
||||
return input
|
||||
}
|
||||
|
||||
const imageBitmap = await createImageBitmap(input)
|
||||
|
||||
// Initialize quality range
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import generateUglyAvatar from '@/lib/uglyAvatar'
|
||||
import compressImage from './compressImage'
|
||||
import compressImage, { ImageType } from './compressImage'
|
||||
|
||||
const generateRandomAvatar = async (targetSize: number) => {
|
||||
const generateRandomAvatar = async (targetSize: number, outputType: ImageType = 'image/webp') => {
|
||||
const svgBlob = generateUglyAvatar()
|
||||
|
||||
// compressImage can't directly compress svg, need to convert to jpeg first
|
||||
|
@ -11,13 +11,13 @@ const generateRandomAvatar = async (targetSize: number) => {
|
|||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(image, 0, 0)
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg' })
|
||||
const blob = await canvas.convertToBlob({ type: outputType })
|
||||
resolve(blob)
|
||||
}
|
||||
image.onerror = () => reject(new Error('Failed to load SVG'))
|
||||
image.src = URL.createObjectURL(svgBlob)
|
||||
})
|
||||
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize })
|
||||
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize, outputType })
|
||||
const miniAvatarBase64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target?.result as string)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { buildFullURL } from '@/utils'
|
||||
|
||||
export interface SiteInfo {
|
||||
host: string
|
||||
hostname: string
|
||||
|
@ -8,6 +10,21 @@ export interface SiteInfo {
|
|||
description: string
|
||||
}
|
||||
|
||||
const getIcon = (): string => {
|
||||
const path =
|
||||
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('meta[property="og:image" i]')?.getAttribute('content') ??
|
||||
document.querySelector('link[rel="apple-touch-icon" i]')?.getAttribute('href') ??
|
||||
`/favicon.ico`
|
||||
|
||||
if (path.startsWith('data:') || path.startsWith('//')) {
|
||||
return path
|
||||
} else {
|
||||
return buildFullURL(document.location.origin, path)
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteInfo = (): SiteInfo => {
|
||||
return {
|
||||
host: document.location.host,
|
||||
|
@ -15,15 +32,10 @@ const getSiteInfo = (): SiteInfo => {
|
|||
href: document.location.href,
|
||||
origin: document.location.origin,
|
||||
title:
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[property="og:site_name" i]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[property="og:title" i]')?.getAttribute('content') ??
|
||||
document.title,
|
||||
icon:
|
||||
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
|
||||
`${document.location.origin}/favicon.ico`,
|
||||
icon: getIcon(),
|
||||
description:
|
||||
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
|
||||
|
|
3
src/utils/getTextByteSize.ts
Normal file
3
src/utils/getTextByteSize.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const getTextByteSize = (text: string) => {
|
||||
return new TextEncoder().encode(text).length
|
||||
}
|
|
@ -4,7 +4,7 @@ export { default as createElement } from './createElement'
|
|||
export { default as getSiteInfo } from './getSiteInfo'
|
||||
export { default as compressImage } from './compressImage'
|
||||
export { default as isNullish } from './isNullish'
|
||||
export { default as checkSystemDarkMode } from './checkSystemDarkMode'
|
||||
export { default as checkDarkMode } from './checkDarkMode'
|
||||
export { default as stringToHex } from './stringToHex'
|
||||
export { default as debounce } from './debounce'
|
||||
export { default as throttle } from './throttle'
|
||||
|
@ -15,3 +15,7 @@ export { default as getCursorPosition } from './getCursorPosition'
|
|||
export { default as getTextSimilarity } from './getTextSimilarity'
|
||||
export { default as getRootNode } from './getRootNode'
|
||||
export { default as blobToBase64 } from './blobToBase64'
|
||||
export * as JSONR from './jsonr'
|
||||
export { getTextByteSize } from './getTextByteSize'
|
||||
export { default as isEqual } from './isEqual'
|
||||
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'
|
||||
|
|
5
src/utils/isEqual.ts
Normal file
5
src/utils/isEqual.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
const isEqual = (a: object, b: object) => {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
export default isEqual
|
10
src/utils/jsonr.ts
Normal file
10
src/utils/jsonr.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import JSONR from '@perfsee/jsonr'
|
||||
import { isNullish } from '@/utils'
|
||||
|
||||
export const parse = <T = any>(value: string | number | boolean | null): T | null => {
|
||||
return !isNullish(value) ? JSONR.parse(value!.toString()) : null
|
||||
}
|
||||
|
||||
export const stringify = (value: any): string | null => {
|
||||
return !isNullish(value) ? JSONR.stringify(value) : null
|
||||
}
|
36
src/utils/url.ts
Normal file
36
src/utils/url.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
export const cleanURL = (url: string) => url.replace(/([^:]\/)\/+/g, '$1').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Determines whether the specified URL is absolute
|
||||
* Reference: https://github.com/axios/axios/blob/v1.x/lib/helpers/isAbsoluteURL.js
|
||||
*/
|
||||
export const isAbsoluteURL = (url: string) => {
|
||||
// A URL is considered absolute if it begins with "<scheme>://" or "//" (protocol-relative URL).
|
||||
// RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed
|
||||
// by any combination of letters, digits, plus, period, or hyphen.
|
||||
return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add params to the URL
|
||||
*/
|
||||
export const assembleURL = (url: string, params: Record<string, string>) => {
|
||||
return Object.entries(params)
|
||||
.reduce((url, [key, value]) => {
|
||||
url.searchParams.append(key, value)
|
||||
return url
|
||||
}, new URL(url))
|
||||
.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new URL by combining the baseURL with the requestedURL,
|
||||
* only when the requestedURL is not already an absolute URL.
|
||||
* If the requestURL is absolute, this function returns the requestedURL untouched.
|
||||
*
|
||||
* reference: https://github.com/axios/axios/blob/v1.x/lib/core/buildFullPath.js
|
||||
*/
|
||||
export const buildFullURL = (baseURL: string = '', pathURL: string = '', params: Record<string, any> = {}) => {
|
||||
const url = cleanURL(isAbsoluteURL(pathURL) ? pathURL : `${baseURL}/${pathURL}`)
|
||||
return assembleURL(url, params)
|
||||
}
|
|
@ -11,6 +11,9 @@ export default {
|
|||
padding: '2rem'
|
||||
},
|
||||
extend: {
|
||||
fontSize: {
|
||||
'2xs': '0.625rem'
|
||||
},
|
||||
zIndex: {
|
||||
infinity: 'calc(infinity)'
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue