Compare commits

..

No commits in common. "master" and "feature/sync" have entirely different histories.

51 changed files with 3395 additions and 2093 deletions

View file

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

View file

@ -1,7 +1,7 @@
{
"name": "web-chat",
"displayName": "WebChat",
"version": "1.7.1",
"version": "1.0.0",
"description": "Chat with anyone on any website.",
"type": "module",
"scripts": {
@ -45,39 +45,38 @@
"homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@number-flow/react": "^0.3.2",
"@lottiefiles/dotlottie-react": "^0.9.3",
"@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.2",
"@radix-ui/react-presence": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@resreq/event-hub": "^1.6.0",
"@resreq/timer": "^1.1.6",
"@rtco/client": "^0.2.17",
"@tailwindcss/typography": "^0.5.15",
"@webcomponents/custom-elements": "^1.6.0",
"@webext-core/messaging": "^2.1.0",
"@webext-core/messaging": "^2.0.2",
"@webext-core/proxy-service": "^1.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"danmu": "^0.14.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.17",
"framer-motion": "^11.11.11",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.456.0",
"lucide-react": "^0.454.0",
"nanoid": "^5.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-hook-form": "^7.53.1",
"react-markdown": "^9.0.1",
"react-use": "^17.5.1",
"react-virtuoso": "^4.12.0",
@ -87,16 +86,17 @@
"remesh-logger": "^4.1.0",
"remesh-react": "^4.1.2",
"rxjs": "^7.8.1",
"sonner": "^1.7.0",
"sonner": "^1.6.1",
"tailwind-merge": "^2.5.4",
"trystero": "^0.20.0",
"type-fest": "^4.26.1",
"unstorage": "^1.13.1",
"unstorage": "1.12.0",
"valibot": "1.0.0-beta.0"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.16.1",
"@eslint-react/eslint-plugin": "^1.15.2",
"@eslint/js": "^9.14.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
@ -104,10 +104,10 @@
"@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.9.0",
"@types/node": "^22.8.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.14.0",
"@typescript-eslint/parser": "^8.12.2",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
@ -115,23 +115,23 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.12.0",
"globals": "^15.11.0",
"husky": "^9.1.6",
"jiti": "^2.4.0",
"lint-staged": "^15.2.10",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.49",
"postcss": "^8.4.47",
"postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"semantic-release": "^24.2.0",
"tailwindcss": "^3.4.15",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"typescript-eslint": "^8.14.0",
"typescript-eslint": "^8.12.2",
"vite-plugin-svgr": "^4.3.0",
"webext-bridge": "^6.0.1",
"wxt": "^0.19.15"
"wxt": "^0.19.13"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,21 @@
import '@webcomponents/custom-elements'
import Header from '@/app/content/views/Header'
import Footer from '@/app/content/views/Footer'
import Main from '@/app/content/views/Main'
import AppButton from '@/app/content/views/AppButton'
import AppMain from '@/app/content/views/AppMain'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import ChatRoomDomain from '@/domain/ChatRoom'
import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList'
import { useEffect, useRef } from 'react'
import { 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 { checkDarkMode, cn } from '@/utils'
import VirtualRoomDomain from '@/domain/VirtualRoom'
import { cn } from '@/utils'
/**
* Fix requestAnimationFrame error in jest
@ -29,8 +28,7 @@ if (import.meta.env.FIREFOX) {
export default function App() {
const send = useRemeshSend()
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain())
@ -40,34 +38,23 @@ 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) {
joinRoom()
send(roomDomain.command.JoinRoomCommand())
} 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 () => {
@ -75,48 +62,31 @@ export default function App() {
}
}, [danmakuIsEnabled])
useEffect(() => {
window.addEventListener('beforeunload', leaveRoom)
return () => {
window.removeEventListener('beforeunload', leaveRoom)
}
}, [])
const themeMode =
userInfo?.themeMode === 'system'
? checkDarkMode()
? 'dark'
: 'light'
: (userInfo?.themeMode ?? (checkDarkMode() ? 'dark' : 'light'))
const danmakuContainerRef = useRef<HTMLDivElement>(null)
return (
<div id="app" className={cn('contents', themeMode)}>
{appStatusLoadIsFinished && (
<>
<AppMain>
<Header />
<Main />
<Footer />
{notUserInfo && <Setup></Setup>}
<Toaster
richColors
theme={themeMode}
offset="70px"
visibleToasts={1}
toastOptions={{
classNames: {
toast: 'dark:bg-slate-950 border dark:border-slate-600'
}
}}
position="top-center"
></Toaster>
</AppMain>
<AppButton></AppButton>
</>
)}
<DanmakuContainer ref={danmakuContainerRef} />
</div>
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>
<DanmakuContainer ref={danmakuContainerRef} />
</div>
)
)
}

View file

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

View file

@ -1,7 +1,6 @@
import { type MouseEvent, type FC, type ReactElement } from 'react'
import { Button } from '@/components/ui/Button'
import { cn } from '@/utils'
import NumberFlow from '@number-flow/react'
export interface LikeButtonIconProps {
children: JSX.Element
@ -41,11 +40,7 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
size="xs"
>
{children}
{!!count && (
<span className="min-w-0 text-xs">
{import.meta.env.FIREFOX ? <span className="tabular-nums">{count}</span> : <NumberFlow value={count} />}
</span>
)}
{!!count && <span className="min-w-0 text-xs">{count}</span>}
</Button>
)
}

View file

@ -1,5 +1,5 @@
import { type FC } from 'react'
import { FrownIcon, HeartIcon } from 'lucide-react'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
@ -71,7 +71,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
count={props.data.likeUsers.length}
>
<LikeButton.Icon>
<HeartIcon size={14}></HeartIcon>
<ThumbsUpIcon size={14}></ThumbsUpIcon>
</LikeButton.Icon>
</LikeButton>
<LikeButton

View file

@ -11,12 +11,10 @@ import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/
import { DanmakuImpl } from '@/domain/impls/Danmaku'
import { NotificationImpl } from '@/domain/impls/Notification'
import { ToastImpl } from '@/domain/impls/Toast'
import { ChatRoomImpl } from '@/domain/impls/ChatRoom'
import { VirtualRoomImpl } from '@/domain/impls/VirtualRoom'
// Remove import after merging: https://github.com/emilkowalski/sonner/pull/508
import '@/assets/styles/sonner.css'
import '@/assets/styles/overlay.css'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
import '@/assets/styles/tailwind.css'
import '@/assets/styles/sonner.css'
import NotificationDomain from '@/domain/Notification'
import { createElement } from '@/utils'
@ -38,8 +36,7 @@ export default defineContentScript({
LocalStorageImpl,
IndexDBStorageImpl,
BrowserSyncStorageImpl,
ChatRoomImpl,
VirtualRoomImpl,
PeerRoomImpl,
ToastImpl,
DanmakuImpl,
NotificationImpl
@ -59,13 +56,13 @@ export default defineContentScript({
container.append(app)
const root = createRoot(app)
root.render(
<React.StrictMode>
<RemeshRoot store={store}>
<RemeshScope domains={[NotificationDomain()]}>
<App />
</RemeshScope>
</RemeshRoot>
</React.StrictMode>
// <React.StrictMode>
<RemeshRoot store={store}>
<RemeshScope domains={[NotificationDomain()]}>
<App />
</RemeshScope>
</RemeshRoot>
// </React.StrictMode>
)
return root
},

View file

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/Button'
import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo'
import useTriggerAway from '@/hooks/useTriggerAway'
import { checkDarkMode, cn } from '@/utils'
import { checkSystemDarkMode, 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 useDraggable from '@/hooks/useDraggable'
import useDarg from '@/hooks/useDarg'
import useWindowResize from '@/hooks/useWindowResize'
export interface AppButtonProps {
@ -36,7 +36,8 @@ 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 : checkDarkMode()
const isDarkMode =
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
const [menuOpen, setMenuOpen] = useState(false)
@ -44,13 +45,13 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
x,
y,
setRef: appButtonRef
} = useDraggable({
} = useDarg({
initX: appPosition.x,
initY: appPosition.y,
minX: 50,
maxX: window.innerWidth - 50,
maxY: window.innerHeight - 22,
minY: 750
minY: window.innerHeight / 2
})
useWindowResize(({ width, height }) => {
@ -106,7 +107,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
<Button
onClick={handleSwitchTheme}
variant="outline"
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
className="relative size-10 overflow-hidden rounded-full p-0 shadow"
>
<div
className={cn(
@ -120,18 +121,10 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
</div>
</Button>
<Button
onClick={handleOpenOptionsPage}
variant="outline"
className="size-10 rounded-full p-0 shadow dark:border-slate-600"
>
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
<SettingsIcon size={20} />
</Button>
<Button
ref={appButtonRef}
variant="outline"
className="size-10 cursor-grab rounded-full p-0 shadow dark:border-slate-600"
>
<Button ref={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
<HandIcon size={20} />
</Button>
</motion.div>

View file

@ -6,7 +6,7 @@ import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import ChatRoomDomain from '@/domain/ChatRoom'
import RoomDomain from '@/domain/Room'
import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef'
import { Presence } from '@radix-ui/react-presence'
@ -25,12 +25,12 @@ import { nanoid } from 'nanoid'
const Footer: FC = () => {
const send = useRemeshSend()
const toastDomain = useRemeshDomain(ToastDomain())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
const inputRef = useRef<HTMLTextAreaElement>(null)
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
@ -143,7 +143,7 @@ const Footer: FC = () => {
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
}
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand())
}
@ -250,13 +250,7 @@ const Footer: FC = () => {
const handleInjectImage = async (file: File) => {
try {
setInputLoading(true)
const blob = await compressImage({
input: file,
targetSize: 30 * 1024,
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
})
const blob = await compressImage({ input: file, targetSize: 30 * 1024, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
const hash = nanoid()
const newMessage = `${message.slice(0, selectionEnd)}![Image](hash:${hash})${message.slice(selectionEnd)}`

View file

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

View file

@ -5,13 +5,13 @@ import MessageList from '../../components/MessageList'
import MessageItem from '../../components/MessageItem'
import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList'
const Main: FC = () => {
const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
@ -29,11 +29,11 @@ const Main: FC = () => {
.toSorted((a, b) => a.sendTime - b.sendTime)
const handleLikeChange = (messageId: string) => {
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
send(roomDomain.command.SendLikeMessageCommand(messageId))
}
const handleHateChange = (messageId: string) => {
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
send(roomDomain.command.SendHateMessageCommand(messageId))
}
return (

View file

@ -2,7 +2,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
import { generateRandomAvatar, generateRandomName } from '@/utils'
import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
import { UserIcon } from 'lucide-react'
import { nanoid } from 'nanoid'
import { FC, useEffect, useState } from 'react'
@ -39,7 +39,7 @@ const generateUserInfo = async (): Promise<UserInfo> => {
name: generateRandomName(),
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
createTime: Date.now(),
themeMode: 'system',
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true,
notificationEnabled: true,
notificationType: 'all'
@ -71,8 +71,8 @@ const Setup: FC = () => {
const [userInfo, setUserInfo] = useState<UserInfo>()
const handleSetup = () => {
send(messageListDomain.command.ClearListCommand())
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
send(messageListDomain.command.ClearListCommand())
}
const refreshUserInfo = async () => {

View file

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

View file

@ -9,7 +9,7 @@ import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input'
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
import { cn, generateRandomAvatar } from '@/utils'
import { checkSystemDarkMode, 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: 'system',
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true,
notificationEnabled: true,
notificationType: 'all'
@ -32,9 +32,9 @@ const defaultUserInfo: UserInfo = {
const formSchema = v.object({
id: v.string(),
createTime: v.number(),
// Pure numeric strings will be converted to number
// Issues: https://github.com/unjs/unstorage/issues/277
createTime: v.number(),
name: v.pipe(
v.string(),
v.trim(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -207,5 +207,3 @@ export const SYNC_HISTORY_MAX_DAYS = 30 as const
* Message max size is 256KiB; if the message is too large, it will cause the connection to drop.
*/
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
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 { ToastExtern } from './externs/Toast'
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import * as v from 'valibot'
export { MessageType }
@ -58,85 +58,19 @@ export interface TextMessage extends MessageUser {
export type RoomMessage = SyncUserMessage | SyncHistoryMessage | LikeMessage | HateMessage | TextMessage
export type RoomUser = MessageUser & { peerIds: string[]; joinTime: number }
export type RoomUser = MessageUser & { peerId: 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',
const RoomDomain = Remesh.domain({
name: 'RoomDomain',
impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain())
const chatRoomExtern = domain.getExtern(ChatRoomExtern)
const toast = domain.getExtern(ToastExtern)
const peerRoom = domain.getExtern(PeerRoomExtern)
const PeerIdState = domain.state<string>({
name: 'Room.PeerIdState',
default: chatRoomExtern.peerId
default: peerRoom.peerId
})
const PeerIdQuery = domain.query({
@ -165,7 +99,7 @@ const ChatRoomDomain = Remesh.domain({
const SelfUserQuery = domain.query({
name: 'Room.SelfUserQuery',
impl: ({ get }) => {
return get(UserListQuery()).find((user) => user.peerIds.includes(chatRoomExtern.peerId))!
return get(UserListQuery()).find((user) => user.peerId === get(PeerIdQuery()))!
}
})
@ -185,11 +119,13 @@ const ChatRoomDomain = Remesh.domain({
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: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
messageListDomain.command.CreateItemCommand({
id: nanoid(),
@ -202,20 +138,15 @@ const ChatRoomDomain = Remesh.domain({
receiveTime: Date.now()
}),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(chatRoomExtern.roomId),
SelfJoinRoomEvent(chatRoomExtern.roomId)
JoinRoomEvent(peerRoom.roomId)
]
}
})
JoinRoomCommand.after(() => {
chatRoomExtern.joinRoom()
return null
})
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({
@ -230,20 +161,14 @@ const ChatRoomDomain = Remesh.domain({
}),
UpdateUserListCommand({
type: 'delete',
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(chatRoomExtern.roomId),
SelfLeaveRoomEvent(chatRoomExtern.roomId)
LeaveRoomEvent(peerRoom.roomId)
]
}
})
LeaveRoomCommand.after(() => {
chatRoomExtern.leaveRoom()
return null
})
const SendTextMessageCommand = domain.command({
name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
@ -267,7 +192,7 @@ const ChatRoomDomain = Remesh.domain({
atUsers: typeof message === 'string' ? [] : message.atUsers
}
chatRoomExtern.sendMessage(textMessage)
peerRoom.sendMessage(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
}
})
@ -288,7 +213,7 @@ const ChatRoomDomain = Remesh.domain({
...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
}
chatRoomExtern.sendMessage(likeMessage)
peerRoom.sendMessage(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
}
})
@ -309,7 +234,7 @@ const ChatRoomDomain = Remesh.domain({
...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
}
chatRoomExtern.sendMessage(hateMessage)
peerRoom.sendMessage(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
}
})
@ -323,13 +248,12 @@ const ChatRoomDomain = Remesh.domain({
const syncUserMessage: SyncUserMessage = {
...self,
id: nanoid(),
peerId: chatRoomExtern.peerId,
sendTime: Date.now(),
lastMessageTime,
type: SendType.SyncUser
}
chatRoomExtern.sendMessage(syncUserMessage, peerId)
peerRoom.sendMessage(syncUserMessage, peerId)
return [SendSyncUserMessageEvent(syncUserMessage)]
}
})
@ -357,6 +281,7 @@ const ChatRoomDomain = Remesh.domain({
name: 'Room.SendSyncHistoryMessageCommand',
impl: ({ get }, { peerId, lastMessageTime }: { peerId: string; lastMessageTime: number }) => {
const self = get(SelfUserQuery())
console.log('SendSyncHistoryMessageCommand', peerId, peerRoom.peerId)
const historyMessages = get(messageListDomain.query.ListQuery()).filter(
(message) =>
@ -395,7 +320,7 @@ const ChatRoomDomain = Remesh.domain({
}, [])
return pushHistoryMessageList.map((message) => {
chatRoomExtern.sendMessage(message, peerId)
peerRoom.sendMessage(message, peerId)
return SendSyncHistoryMessageEvent(message)
})
}
@ -403,32 +328,12 @@ const ChatRoomDomain = Remesh.domain({
const UpdateUserListCommand = domain.command({
name: 'Room.UpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: Omit<RoomUser, 'peerIds'> & { peerId: string } }) => {
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
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'
)
)
]
return [UserListState().new(upsert(userList, action.user, 'userId'))]
} else {
return [
UserListState().new(
upsert(
userList,
{
...action.user,
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || []
},
'userId'
).filter((user) => user.peerIds.length)
)
]
return [UserListState().new(userList.filter(({ userId }) => userId !== action.user.userId))]
}
}
})
@ -473,18 +378,10 @@ const ChatRoomDomain = Remesh.domain({
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'
})
@ -492,10 +389,10 @@ const ChatRoomDomain = Remesh.domain({
domain.effect({
name: 'Room.OnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = fromEventPattern<string>(chatRoomExtern.onJoinRoom).pipe(
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
mergeMap((peerId) => {
// console.log('onJoinRoom', peerId)
if (chatRoomExtern.peerId === peerId) {
if (peerRoom.peerId === peerId) {
return [OnJoinRoomEvent(peerId)]
} else {
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
@ -509,13 +406,9 @@ const ChatRoomDomain = Remesh.domain({
domain.effect({
name: 'Room.OnMessageEffect',
impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(chatRoomExtern.onMessage).pipe(
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.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
}
// console.log('onMessage', message)
const messageEvent$ = of(OnMessageEvent(message))
@ -524,36 +417,41 @@ const ChatRoomDomain = Remesh.domain({
const messageCommand$ = (() => {
switch (message.type) {
case SendType.SyncUser: {
const userList = get(UserListQuery())
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
// 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 isRepeatJoin = userList.some((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
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
)
return isRepeatJoin
? EMPTY
: of(
UpdateUserListCommand({ type: 'create', user: message }),
isNewJoinEvent
? 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: {
toast.success('Syncing history messages.')
return of(...message.messages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
}
@ -606,28 +504,22 @@ const ChatRoomDomain = Remesh.domain({
domain.effect({
name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(chatRoomExtern.onLeaveRoom).pipe(
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => {
if (get(JoinStatusModule.query.IsInitialQuery())) {
return null
}
// console.log('onLeaveRoom', peerId)
console.log('onLeaveRoom', peerId, get(SelfUserQuery()).peerId)
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
if (existUser) {
if (user) {
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,
UpdateUserListCommand({ type: 'delete', user }),
messageListDomain.command.CreateItemCommand({
...user,
id: nanoid(),
body: `"${user.username}" left the chat`,
type: MessageType.Prompt,
sendTime: Date.now(),
receiveTime: Date.now()
}),
OnLeaveRoomEvent(peerId)
]
} else {
@ -642,7 +534,7 @@ const ChatRoomDomain = Remesh.domain({
domain.effect({
name: 'Room.OnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(chatRoomExtern.onError).pipe(
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
map((error) => {
console.error(error)
return OnErrorEvent(error)
@ -652,6 +544,21 @@ const ChatRoomDomain = Remesh.domain({
}
})
// 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(() => {
console.log('beforeunload')
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
})
)
return beforeUnload$
}
})
return {
query: {
PeerIdQuery,
@ -674,9 +581,7 @@ const ChatRoomDomain = Remesh.domain({
SendSyncUserMessageEvent,
SendSyncHistoryMessageEvent,
JoinRoomEvent,
SelfJoinRoomEvent,
LeaveRoomEvent,
SelfLeaveRoomEvent,
OnMessageEvent,
OnTextMessageEvent,
OnJoinRoomEvent,
@ -687,4 +592,4 @@ const ChatRoomDomain = Remesh.domain({
}
})
export default ChatRoomDomain
export default RoomDomain

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,19 @@
import { Remesh } from 'remesh'
import { RoomMessage } from '../ChatRoom'
import { RoomMessage } from '../Room'
export interface ChatRoom {
export interface PeerRoom {
readonly peerId: string
readonly roomId: string
joinRoom: () => ChatRoom
sendMessage: (message: RoomMessage, id?: string | string[]) => ChatRoom
onMessage: (callback: (message: RoomMessage) => void) => ChatRoom
leaveRoom: () => ChatRoom
onJoinRoom: (callback: (id: string) => void) => ChatRoom
onLeaveRoom: (callback: (id: string) => void) => ChatRoom
onError: (callback: (error: Error) => void) => ChatRoom
joinRoom: () => PeerRoom
sendMessage: (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
}
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
default: {
peerId: '',
roomId: '',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,28 +1,27 @@
import { Room } from '@rtco/client'
import { Artico, Room } from '@rtco/client'
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { stringToHex } from '@/utils'
import { nanoid } from 'nanoid'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '@/domain/ChatRoom'
import { RoomMessage } from '../Room'
import { JSONR } from '@/utils'
import Peer from './Peer'
export interface Config {
peer: Peer
peerId?: string
roomId: string
}
class ChatRoom extends EventHub {
readonly peer: Peer
class PeerRoom extends EventHub {
readonly roomId: string
private rtco?: Artico
readonly peerId: string
private room?: Room
constructor(config: Config) {
super()
this.peer = config.peer
this.roomId = config.roomId
this.peerId = config.peer.id
this.peerId = config.peerId || nanoid()
this.joinRoom = this.joinRoom.bind(this)
this.sendMessage = this.sendMessage.bind(this)
this.onMessage = this.onMessage.bind(this)
@ -33,23 +32,21 @@ class ChatRoom extends EventHub {
}
joinRoom() {
if (!this.rtco) {
this.rtco = new Artico({ id: this.peerId })
}
if (this.room) {
this.room = this.peer.join(this.roomId)
this.room = this.rtco.join(this.roomId)
} else {
if (this.peer.state === 'ready') {
this.room = this.peer.join(this.roomId)
this.rtco!.on('open', () => {
this.room = this.rtco!.join(this.roomId)
this.emit('action')
} else {
this.peer!.on('open', () => {
this.room = this.peer.join(this.roomId)
this.emit('action')
})
}
})
}
return this
}
sendMessage(message: RoomMessage, id?: string | string[]) {
sendMessage(message: RoomMessage, id?: string) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
@ -126,7 +123,7 @@ class ChatRoom extends EventHub {
return this
}
onError(callback: (error: Error) => void) {
this.peer?.on('error', (error) => callback(error))
this.rtco?.on('error', (error) => callback(error))
this.on('error', (error: Error) => callback(error))
return this
}
@ -134,9 +131,9 @@ class ChatRoom extends EventHub {
const hostRoomId = stringToHex(document.location.host)
const chatRoom = new ChatRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
const peerRoom = new PeerRoom({ roomId: hostRoomId })
export const ChatRoomImpl = ChatRoomExtern.impl(chatRoom)
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
// https://github.com/w3c/webextensions/issues/72
// https://issues.chromium.org/issues/40251342

View file

@ -7,11 +7,7 @@ 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
* */
import { JSONR } from '@/utils'
export const localStorage = createStorage({
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
@ -27,8 +23,8 @@ export const browserSyncStorage = createStorage({
export const LocalStorageImpl = LocalStorageExtern.impl({
name: STORAGE_NAME,
get: localStorage.getItem,
set: localStorage.setItem,
get: async (key) => JSONR.parse(await localStorage.getItem(key)),
set: (key, value) => localStorage.setItem(key, JSONR.stringify(value)!),
remove: localStorage.removeItem,
clear: localStorage.clear,
watch: async (callback) => {
@ -50,8 +46,8 @@ export const LocalStorageImpl = LocalStorageExtern.impl({
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
name: STORAGE_NAME,
get: indexDBStorage.getItem,
set: indexDBStorage.setItem,
get: async (key) => JSONR.parse(await indexDBStorage.getItem(key)),
set: (key, value) => indexDBStorage.setItem(key, JSONR.stringify(value)),
remove: indexDBStorage.removeItem,
clear: indexDBStorage.clear,
watch: indexDBStorage.watch as Storage['watch'],
@ -60,8 +56,8 @@ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
name: STORAGE_NAME,
get: browserSyncStorage.getItem,
set: browserSyncStorage.setItem,
get: async (key) => JSONR.parse(await browserSyncStorage.getItem(key)),
set: (key, value) => browserSyncStorage.setItem(key, JSONR.stringify(value)),
remove: browserSyncStorage.removeItem,
clear: browserSyncStorage.clear,
watch: browserSyncStorage.watch as Storage['watch'],

View file

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

View file

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

View file

@ -10,7 +10,7 @@ export interface DargOptions {
minY: number
}
const useDraggable = (options: DargOptions) => {
const useDarg = (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 useDraggable = (options: DargOptions) => {
return { setRef, ...position }
}
export default useDraggable
export default useDarg

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
import { buildFullURL } from '@/utils'
export interface SiteInfo {
host: string
hostname: string
@ -10,21 +8,6 @@ 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,
@ -32,10 +15,15 @@ const getSiteInfo = (): SiteInfo => {
href: document.location.href,
origin: document.location.origin,
title:
document.querySelector('meta[property="og:site_name" i]')?.getAttribute('content') ??
document.querySelector('meta[property="og:title" i]')?.getAttribute('content') ??
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.title,
icon: getIcon(),
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`,
description:
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??

View file

@ -4,7 +4,7 @@ export { default as createElement } from './createElement'
export { default as getSiteInfo } from './getSiteInfo'
export { default as compressImage } from './compressImage'
export { default as isNullish } from './isNullish'
export { default as checkDarkMode } from './checkDarkMode'
export { default as checkSystemDarkMode } from './checkSystemDarkMode'
export { default as stringToHex } from './stringToHex'
export { default as debounce } from './debounce'
export { default as throttle } from './throttle'
@ -17,5 +17,3 @@ 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'

View file

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

View file

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

View file

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