feat: ranking of users supporting online websites Closes #48

This commit is contained in:
molvqingtai 2024-11-13 19:24:16 +08:00
parent 00f0bd08b0
commit d0fea9e42d
33 changed files with 1148 additions and 3036 deletions

View file

@ -45,7 +45,6 @@
"homepage": "https://github.com/molvqingtai/WebChat", "homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@lottiefiles/dotlottie-react": "^0.9.3",
"@perfsee/jsonr": "^1.13.0", "@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
@ -57,7 +56,7 @@
"@radix-ui/react-portal": "^1.1.2", "@radix-ui/react-portal": "^1.1.2",
"@radix-ui/react-presence": "^1.1.1", "@radix-ui/react-presence": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@resreq/event-hub": "^1.6.0", "@resreq/event-hub": "^1.6.0",
@ -70,13 +69,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"danmu": "^0.14.0", "danmu": "^0.14.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.13",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.456.0",
"nanoid": "^5.0.8", "nanoid": "^5.0.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.1", "react-hook-form": "^7.53.2",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-use": "^17.5.1", "react-use": "^17.5.1",
"react-virtuoso": "^4.12.0", "react-virtuoso": "^4.12.0",
@ -88,7 +87,6 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^1.7.0", "sonner": "^1.7.0",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"trystero": "^0.20.0",
"type-fest": "^4.26.1", "type-fest": "^4.26.1",
"unstorage": "^1.13.1", "unstorage": "^1.13.1",
"valibot": "1.0.0-beta.0" "valibot": "1.0.0-beta.0"
@ -96,18 +94,18 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.15.2", "@eslint-react/eslint-plugin": "^1.16.1",
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3", "@types/eslint__js": "^8.42.3",
"@types/node": "^22.8.7", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.12.2", "@typescript-eslint/parser": "^8.14.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -115,12 +113,12 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.11.0", "globals": "^15.12.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"jiti": "^2.4.0", "jiti": "^2.4.0",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.47", "postcss": "^8.4.49",
"postcss-rem-to-responsive-pixel": "^6.0.2", "postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
@ -128,7 +126,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.12.2", "typescript-eslint": "^8.14.0",
"vite-plugin-svgr": "^4.3.0", "vite-plugin-svgr": "^4.3.0",
"webext-bridge": "^6.0.1", "webext-bridge": "^6.0.1",
"wxt": "^0.19.13" "wxt": "^0.19.13"

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import Main from '@/app/content/views/Main'
import AppButton from '@/app/content/views/AppButton' import AppButton from '@/app/content/views/AppButton'
import AppMain from '@/app/content/views/AppMain' import AppMain from '@/app/content/views/AppMain'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import RoomDomain from '@/domain/Room' import ChatRoomDomain from '@/domain/ChatRoom'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup' import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
@ -15,6 +15,7 @@ import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku' import DanmakuDomain from '@/domain/Danmaku'
import AppStatusDomain from '@/domain/AppStatus' import AppStatusDomain from '@/domain/AppStatus'
import { checkDarkMode, cn } from '@/utils' import { checkDarkMode, cn } from '@/utils'
import VirtualRoomDomain from '@/domain/VirtualRoom'
/** /**
* Fix requestAnimationFrame error in jest * Fix requestAnimationFrame error in jest
@ -27,7 +28,8 @@ if (import.meta.env.FIREFOX) {
export default function App() { export default function App() {
const send = useRemeshSend() const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain()) const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain()) const danmakuDomain = useRemeshDomain(DanmakuDomain())
@ -37,19 +39,32 @@ export default function App() {
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery()) const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const appStatusDomain = useRemeshDomain(AppStatusDomain()) const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery()) 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 userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished 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(() => { useEffect(() => {
if (messageListLoadFinished) { if (messageListLoadFinished) {
if (userInfoSetFinished) { if (userInfoSetFinished) {
send(roomDomain.command.JoinRoomCommand()) joinRoom()
} else { } else {
// Clear simulated data when refreshing on the setup page // Clear simulated data when refreshing on the setup page
send(messageListDomain.command.ClearListCommand()) send(messageListDomain.command.ClearListCommand())
} }
} }
return () => leaveRoom()
}, [userInfoSetFinished, messageListLoadFinished]) }, [userInfoSetFinished, messageListLoadFinished])
useEffect(() => { useEffect(() => {
@ -59,6 +74,13 @@ export default function App() {
} }
}, [danmakuIsEnabled]) }, [danmakuIsEnabled])
useEffect(() => {
window.addEventListener('beforeunload', leaveRoom)
return () => {
window.removeEventListener('beforeunload', leaveRoom)
}
}, [])
const themeMode = const themeMode =
userInfo?.themeMode === 'system' userInfo?.themeMode === 'system'
? checkDarkMode() ? checkDarkMode()

View file

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

View file

@ -11,8 +11,8 @@ import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/
import { DanmakuImpl } from '@/domain/impls/Danmaku' import { DanmakuImpl } from '@/domain/impls/Danmaku'
import { NotificationImpl } from '@/domain/impls/Notification' import { NotificationImpl } from '@/domain/impls/Notification'
import { ToastImpl } from '@/domain/impls/Toast' import { ToastImpl } from '@/domain/impls/Toast'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom' import { ChatRoomImpl } from '@/domain/impls/ChatRoom'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2' import { VirtualRoomImpl } from '@/domain/impls/VirtualRoom'
// Remove import after merging: https://github.com/emilkowalski/sonner/pull/508 // Remove import after merging: https://github.com/emilkowalski/sonner/pull/508
import '@/assets/styles/sonner.css' import '@/assets/styles/sonner.css'
import '@/assets/styles/overlay.css' import '@/assets/styles/overlay.css'
@ -38,7 +38,8 @@ export default defineContentScript({
LocalStorageImpl, LocalStorageImpl,
IndexDBStorageImpl, IndexDBStorageImpl,
BrowserSyncStorageImpl, BrowserSyncStorageImpl,
PeerRoomImpl, ChatRoomImpl,
VirtualRoomImpl,
ToastImpl, ToastImpl,
DanmakuImpl, DanmakuImpl,
NotificationImpl NotificationImpl

View file

@ -6,7 +6,7 @@ import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput' import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config' import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import RoomDomain from '@/domain/Room' import ChatRoomDomain from '@/domain/ChatRoom'
import useCursorPosition from '@/hooks/useCursorPosition' import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef' import useShareRef from '@/hooks/useShareRef'
import { Presence } from '@radix-ui/react-presence' import { Presence } from '@radix-ui/react-presence'
@ -25,12 +25,12 @@ import { nanoid } from 'nanoid'
const Footer: FC = () => { const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()
const toastDomain = useRemeshDomain(ToastDomain()) const toastDomain = useRemeshDomain(ToastDomain())
const roomDomain = useRemeshDomain(RoomDomain()) const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain()) const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.MessageQuery()) const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userList = useRemeshQuery(roomDomain.query.UserListQuery()) const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition() const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
@ -143,7 +143,7 @@ const Footer: FC = () => {
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.')) return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
} }
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers })) send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand()) send(messageInputDomain.command.ClearCommand())
} }

View file

@ -5,17 +5,39 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/H
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { cn, getSiteInfo } from '@/utils' import { cn, getSiteInfo } from '@/utils'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react' import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import RoomDomain from '@/domain/Room' import ChatRoomDomain from '@/domain/ChatRoom'
import VirtualRoomDomain, { FromInfo, RoomUser } from '@/domain/VirtualRoom'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso } from 'react-virtuoso' import { Virtuoso } from 'react-virtuoso'
import AvatarCircles from '@/components/magicui/AvatarCircles'
import Link from '@/components/Link'
const Header: FC = () => { const Header: FC = () => {
const siteInfo = getSiteInfo() const siteInfo = getSiteInfo()
const roomDomain = useRemeshDomain(RoomDomain()) const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const userList = useRemeshQuery(roomDomain.query.UserListQuery()) const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
const onlineCount = userList.length const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
const chatOnlineCount = chatUserList.length
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null) const virtualOnlineGroup = virtualUserList
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
const existSite = acc.find((group) => group.origin === item.from.origin)
if (existSite) {
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
!existUser && existSite.users.push(item.user)
} else {
acc.push({ ...item.from, users: [item.user] })
}
return acc
}, [])
.sort((a, b) => b.users.length - a.users.length)
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
null
)
return ( 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"> <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">
@ -33,23 +55,55 @@ const Header: FC = () => {
</span> </span>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-80 rounded-lg"> <HoverCardContent className="w-80 rounded-lg p-0">
<div className="grid grid-cols-[auto_1fr] gap-x-4"> <ScrollArea className="max-h-96 min-h-[72px] p-2" ref={setVirtualOnlineGroupScrollParentRef}>
<Avatar className="size-14"> <Virtuoso
<AvatarImage src={siteInfo.icon} alt="favicon" /> data={virtualOnlineGroup}
<AvatarFallback> defaultItemHeight={56}
<Globe2Icon size="100%" className="text-gray-400" /> customScrollParent={virtualOnlineGroupScrollParentRef!}
</AvatarFallback> itemContent={(_index, site) => (
</Avatar> <Link
<div className="grid items-center"> underline={false}
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4> href={site.origin}
{siteInfo.description && ( 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"
<p className="line-clamp-2 max-h-8 text-xs text-slate-500 dark:text-slate-300"> >
{siteInfo.description} <Avatar className="size-10">
</p> <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">
<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="dark:text-slate-50">
ONLINE {site.users.length > 99 ? '99+' : site.users.length}
</span>
</div>
</div>
</div>
<AvatarCircles max={9} size="xs" avatarUrls={site.users.map((user) => user.userAvatar)} />
</div>
</Link>
)} )}
</div> ></Virtuoso>
</div> </ScrollArea>
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
<HoverCard> <HoverCard>
@ -60,26 +114,26 @@ const Header: FC = () => {
<span <span
className={cn( className={cn(
'absolute inline-flex size-full animate-ping rounded-full opacity-75', 'absolute inline-flex size-full animate-ping rounded-full opacity-75',
onlineCount > 1 ? 'bg-green-400' : 'bg-orange-400' chatOnlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
)} )}
></span> ></span>
<span <span
className={cn( className={cn(
'relative inline-flex size-2 rounded-full', 'relative inline-flex size-full rounded-full',
onlineCount > 1 ? 'bg-green-500' : 'bg-orange-500' chatOnlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
)} )}
></span> ></span>
</span> </span>
<span className="dark:text-slate-50">ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span> <span className="dark:text-slate-50">ONLINE {chatOnlineCount > 99 ? '99+' : chatOnlineCount}</span>
</div> </div>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-36 rounded-lg p-0"> <HoverCardContent className="w-36 rounded-lg p-0">
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}> <ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
<Virtuoso <Virtuoso
data={userList} data={chatUserList}
defaultItemHeight={28} defaultItemHeight={28}
customScrollParent={scrollParentRef!} customScrollParent={chatUserListScrollParentRef!}
itemContent={(index, user) => ( itemContent={(index, user) => (
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}> <div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
<Avatar className="size-4 shrink-0"> <Avatar className="size-4 shrink-0">

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs' import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
import { AtUser, NormalMessage, type MessageUser } from './MessageList' import { AtUser, NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom' import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList' import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import { desert, getTextByteSize, upsert } from '@/utils' import { desert, getTextByteSize, upsert } from '@/utils'
@ -127,16 +127,16 @@ const RoomMessageSchema = v.union([
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) => const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
v.safeParse(RoomMessageSchema, message).success v.safeParse(RoomMessageSchema, message).success
const RoomDomain = Remesh.domain({ const ChatRoomDomain = Remesh.domain({
name: 'RoomDomain', name: 'ChatRoomDomain',
impl: (domain) => { impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain()) const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain()) const userInfoDomain = domain.getDomain(UserInfoDomain())
const peerRoom = domain.getExtern(PeerRoomExtern) const chatRoomExtern = domain.getExtern(ChatRoomExtern)
const PeerIdState = domain.state<string>({ const PeerIdState = domain.state<string>({
name: 'Room.PeerIdState', name: 'Room.PeerIdState',
default: peerRoom.peerId default: chatRoomExtern.peerId
}) })
const PeerIdQuery = domain.query({ const PeerIdQuery = domain.query({
@ -165,7 +165,7 @@ const RoomDomain = Remesh.domain({
const SelfUserQuery = domain.query({ const SelfUserQuery = domain.query({
name: 'Room.SelfUserQuery', name: 'Room.SelfUserQuery',
impl: ({ get }) => { impl: ({ get }) => {
return get(UserListQuery()).find((user) => user.peerIds.includes(peerRoom.peerId))! return get(UserListQuery()).find((user) => user.peerIds.includes(chatRoomExtern.peerId))!
} }
}) })
@ -189,7 +189,7 @@ const RoomDomain = Remesh.domain({
return [ return [
UpdateUserListCommand({ UpdateUserListCommand({
type: 'create', type: 'create',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
}), }),
messageListDomain.command.CreateItemCommand({ messageListDomain.command.CreateItemCommand({
id: nanoid(), id: nanoid(),
@ -202,14 +202,14 @@ const RoomDomain = Remesh.domain({
receiveTime: Date.now() receiveTime: Date.now()
}), }),
JoinStatusModule.command.SetFinishedCommand(), JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId), JoinRoomEvent(chatRoomExtern.roomId),
SelfJoinRoomEvent(peerRoom.roomId) SelfJoinRoomEvent(chatRoomExtern.roomId)
] ]
} }
}) })
JoinRoomCommand.after(() => { JoinRoomCommand.after(() => {
peerRoom.joinRoom() chatRoomExtern.joinRoom()
return null return null
}) })
@ -230,17 +230,17 @@ const RoomDomain = Remesh.domain({
}), }),
UpdateUserListCommand({ UpdateUserListCommand({
type: 'delete', type: 'delete',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
}), }),
JoinStatusModule.command.SetInitialCommand(), JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(peerRoom.roomId), LeaveRoomEvent(chatRoomExtern.roomId),
SelfLeaveRoomEvent(peerRoom.roomId) SelfLeaveRoomEvent(chatRoomExtern.roomId)
] ]
} }
}) })
LeaveRoomCommand.after(() => { LeaveRoomCommand.after(() => {
peerRoom.leaveRoom() chatRoomExtern.leaveRoom()
return null return null
}) })
@ -267,7 +267,7 @@ const RoomDomain = Remesh.domain({
atUsers: typeof message === 'string' ? [] : message.atUsers atUsers: typeof message === 'string' ? [] : message.atUsers
} }
peerRoom.sendMessage(textMessage) chatRoomExtern.sendMessage(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)] return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
} }
}) })
@ -288,7 +288,7 @@ const RoomDomain = Remesh.domain({
...localMessage, ...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId') likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
} }
peerRoom.sendMessage(likeMessage) chatRoomExtern.sendMessage(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)] return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
} }
}) })
@ -309,7 +309,7 @@ const RoomDomain = Remesh.domain({
...localMessage, ...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId') hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
} }
peerRoom.sendMessage(hateMessage) chatRoomExtern.sendMessage(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)] return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
} }
}) })
@ -323,13 +323,13 @@ const RoomDomain = Remesh.domain({
const syncUserMessage: SyncUserMessage = { const syncUserMessage: SyncUserMessage = {
...self, ...self,
id: nanoid(), id: nanoid(),
peerId: peerRoom.peerId, peerId: chatRoomExtern.peerId,
sendTime: Date.now(), sendTime: Date.now(),
lastMessageTime, lastMessageTime,
type: SendType.SyncUser type: SendType.SyncUser
} }
peerRoom.sendMessage(syncUserMessage, peerId) chatRoomExtern.sendMessage(syncUserMessage, peerId)
return [SendSyncUserMessageEvent(syncUserMessage)] return [SendSyncUserMessageEvent(syncUserMessage)]
} }
}) })
@ -395,7 +395,7 @@ const RoomDomain = Remesh.domain({
}, []) }, [])
return pushHistoryMessageList.map((message) => { return pushHistoryMessageList.map((message) => {
peerRoom.sendMessage(message, peerId) chatRoomExtern.sendMessage(message, peerId)
return SendSyncHistoryMessageEvent(message) return SendSyncHistoryMessageEvent(message)
}) })
} }
@ -411,7 +411,7 @@ const RoomDomain = Remesh.domain({
UserListState().new( UserListState().new(
upsert( upsert(
userList, userList,
{ ...action.user, peerIds: [...(existUser?.peerIds || []), action.user.peerId] }, { ...action.user, peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId] },
'userId' 'userId'
) )
) )
@ -492,10 +492,10 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'Room.OnJoinRoomEffect', name: 'Room.OnJoinRoomEffect',
impl: () => { impl: () => {
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe( const onJoinRoom$ = fromEventPattern<string>(chatRoomExtern.onJoinRoom).pipe(
mergeMap((peerId) => { mergeMap((peerId) => {
// console.log('onJoinRoom', peerId) // console.log('onJoinRoom', peerId)
if (peerRoom.peerId === peerId) { if (chatRoomExtern.peerId === peerId) {
return [OnJoinRoomEvent(peerId)] return [OnJoinRoomEvent(peerId)]
} else { } else {
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)] return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
@ -509,7 +509,7 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'Room.OnMessageEffect', name: 'Room.OnMessageEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe( const onMessage$ = fromEventPattern<RoomMessage>(chatRoomExtern.onMessage).pipe(
mergeMap((message) => { mergeMap((message) => {
// Filter out messages that do not conform to the format // Filter out messages that do not conform to the format
if (!checkMessageFormat(message)) { if (!checkMessageFormat(message)) {
@ -606,7 +606,7 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'Room.OnLeaveRoomEffect', name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe( const onLeaveRoom$ = fromEventPattern<string>(chatRoomExtern.onLeaveRoom).pipe(
map((peerId) => { map((peerId) => {
if (get(JoinStatusModule.query.IsInitialQuery())) { if (get(JoinStatusModule.query.IsInitialQuery())) {
return null return null
@ -642,7 +642,7 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'Room.OnErrorEffect', name: 'Room.OnErrorEffect',
impl: () => { impl: () => {
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe( const onRoomError$ = fromEventPattern<Error>(chatRoomExtern.onError).pipe(
map((error) => { map((error) => {
console.error(error) console.error(error)
return OnErrorEvent(error) return OnErrorEvent(error)
@ -652,18 +652,6 @@ const RoomDomain = Remesh.domain({
} }
}) })
domain.effect({
name: 'Room.OnUnloadEffect',
impl: ({ get }) => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
map(() => {
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
})
)
return beforeUnload$
}
})
return { return {
query: { query: {
PeerIdQuery, PeerIdQuery,
@ -699,4 +687,4 @@ const RoomDomain = Remesh.domain({
} }
}) })
export default RoomDomain export default ChatRoomDomain

View file

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

View file

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

View file

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

381
src/domain/VirtualRoom.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

22
src/domain/impls/Peer.ts Normal file
View file

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

View file

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

View file

@ -6,7 +6,7 @@ export interface ToastOptions {
} }
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => { const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
const toast = domain.getExtern(ToastExtern) const toastExtern = domain.getExtern(ToastExtern)
const SuccessEvent = domain.event<number | string>({ const SuccessEvent = domain.event<number | string>({
name: `${options.name}.SuccessEvent` name: `${options.name}.SuccessEvent`
@ -15,7 +15,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const SuccessCommand = domain.command({ const SuccessCommand = domain.command({
name: `${options.name}.SuccessCommand`, name: `${options.name}.SuccessCommand`,
impl: (_, message: string | { message: string; duration?: number }) => { impl: (_, message: string | { message: string; duration?: number }) => {
const id = toast.success( const id = toastExtern.success(
typeof message === 'string' ? message : message.message, typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration typeof message === 'string' ? undefined : message.duration
) )
@ -30,7 +30,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const ErrorCommand = domain.command({ const ErrorCommand = domain.command({
name: `${options.name}.ErrorCommand`, name: `${options.name}.ErrorCommand`,
impl: (_, message: string | { message: string; duration?: number }) => { impl: (_, message: string | { message: string; duration?: number }) => {
const id = toast.error( const id = toastExtern.error(
typeof message === 'string' ? message : message.message, typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration typeof message === 'string' ? undefined : message.duration
) )
@ -45,7 +45,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const InfoCommand = domain.command({ const InfoCommand = domain.command({
name: `${options.name}.InfoCommand`, name: `${options.name}.InfoCommand`,
impl: (_, message: string | { message: string; duration?: number }) => { impl: (_, message: string | { message: string; duration?: number }) => {
const id = toast.info( const id = toastExtern.info(
typeof message === 'string' ? message : message.message, typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration typeof message === 'string' ? undefined : message.duration
) )
@ -60,7 +60,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const WarningCommand = domain.command({ const WarningCommand = domain.command({
name: `${options.name}.WarningCommand`, name: `${options.name}.WarningCommand`,
impl: (_, message: string | { message: string; duration?: number }) => { impl: (_, message: string | { message: string; duration?: number }) => {
const id = toast.warning( const id = toastExtern.warning(
typeof message === 'string' ? message : message.message, typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration typeof message === 'string' ? undefined : message.duration
) )
@ -75,7 +75,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const LoadingCommand = domain.command({ const LoadingCommand = domain.command({
name: `${options.name}.LoadingCommand`, name: `${options.name}.LoadingCommand`,
impl: (_, message: string | { message: string; duration?: number }) => { impl: (_, message: string | { message: string; duration?: number }) => {
const id = toast.loading( const id = toastExtern.loading(
typeof message === 'string' ? message : message.message, typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration typeof message === 'string' ? undefined : message.duration
) )
@ -90,7 +90,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
const CancelCommand = domain.command({ const CancelCommand = domain.command({
name: `${options.name}.CancelCommand`, name: `${options.name}.CancelCommand`,
impl: (_, id: number | string) => { impl: (_, id: number | string) => {
toast.cancel(id) toastExtern.cancel(id)
return [CancelEvent(id)] return [CancelEvent(id)]
} }
}) })

View file

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

View file

@ -1,3 +1,5 @@
import { buildFullURL } from '@/utils'
export interface SiteInfo { export interface SiteInfo {
host: string host: string
hostname: string hostname: string
@ -15,15 +17,17 @@ const getSiteInfo = (): SiteInfo => {
href: document.location.href, href: document.location.href,
origin: document.location.origin, origin: document.location.origin,
title: title:
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ?? document.querySelector('meta[property="og:site_name" i]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ?? document.querySelector('meta[property="og:title" i]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
document.title, document.title,
icon: icon: buildFullURL(
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ?? document.location.origin,
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ?? document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ?? document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
`${document.location.origin}/favicon.ico`, document.querySelector('meta[property="og:image" i]')?.getAttribute('content') ??
document.querySelector('link[rel="apple-touch-icon" i]')?.getAttribute('href') ??
`/favicon.ico`
),
description: description:
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ?? document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
document.querySelector('meta[name="description" i]')?.getAttribute('content') ?? document.querySelector('meta[name="description" i]')?.getAttribute('content') ??

View file

@ -17,3 +17,5 @@ export { default as getRootNode } from './getRootNode'
export { default as blobToBase64 } from './blobToBase64' export { default as blobToBase64 } from './blobToBase64'
export * as JSONR from './jsonr' export * as JSONR from './jsonr'
export { getTextByteSize } from './getTextByteSize' export { getTextByteSize } from './getTextByteSize'
export { default as isEqual } from './isEqual'
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'

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

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

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

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

View file

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