feat: ranking of users supporting online websites Closes #48
This commit is contained in:
parent
00f0bd08b0
commit
d0fea9e42d
33 changed files with 1148 additions and 3036 deletions
24
package.json
24
package.json
|
@ -45,7 +45,6 @@
|
|||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@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",
|
||||
|
@ -57,7 +56,7 @@
|
|||
"@radix-ui/react-portal": "^1.1.2",
|
||||
"@radix-ui/react-presence": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@resreq/event-hub": "^1.6.0",
|
||||
|
@ -70,13 +69,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.11",
|
||||
"framer-motion": "^11.11.13",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"lucide-react": "^0.456.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
|
@ -88,7 +87,6 @@
|
|||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.26.1",
|
||||
"unstorage": "^1.13.1",
|
||||
"valibot": "1.0.0-beta.0"
|
||||
|
@ -96,18 +94,18 @@
|
|||
"devDependencies": {
|
||||
"@commitlint/cli": "^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",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@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-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.12.2",
|
||||
"@typescript-eslint/parser": "^8.14.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
|
@ -115,12 +113,12 @@
|
|||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.11.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.4.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
|
@ -128,7 +126,7 @@
|
|||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.12.2",
|
||||
"typescript-eslint": "^8.14.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.19.13"
|
||||
|
|
3118
pnpm-lock.yaml
3118
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -4,7 +4,7 @@ import Main from '@/app/content/views/Main'
|
|||
import AppButton from '@/app/content/views/AppButton'
|
||||
import AppMain from '@/app/content/views/AppMain'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import Setup from '@/app/content/views/Setup'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
|
@ -15,6 +15,7 @@ import DanmakuContainer from './components/DanmakuContainer'
|
|||
import DanmakuDomain from '@/domain/Danmaku'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import VirtualRoomDomain from '@/domain/VirtualRoom'
|
||||
|
||||
/**
|
||||
* Fix requestAnimationFrame error in jest
|
||||
|
@ -27,7 +28,8 @@ if (import.meta.env.FIREFOX) {
|
|||
|
||||
export default function App() {
|
||||
const send = useRemeshSend()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
|
@ -37,19 +39,32 @@ export default function App() {
|
|||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||
const chatRoomJoinIsFinished = useRemeshQuery(chatRoomDomain.query.JoinIsFinishedQuery())
|
||||
const virtualRoomJoinIsFinished = useRemeshQuery(virtualRoomDomain.query.JoinIsFinishedQuery())
|
||||
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||
|
||||
const joinRoom = () => {
|
||||
send(chatRoomDomain.command.JoinRoomCommand())
|
||||
send(virtualRoomDomain.command.JoinRoomCommand())
|
||||
}
|
||||
|
||||
const leaveRoom = () => {
|
||||
chatRoomJoinIsFinished && send(chatRoomDomain.command.LeaveRoomCommand())
|
||||
virtualRoomJoinIsFinished && send(virtualRoomDomain.command.LeaveRoomCommand())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messageListLoadFinished) {
|
||||
if (userInfoSetFinished) {
|
||||
send(roomDomain.command.JoinRoomCommand())
|
||||
joinRoom()
|
||||
} else {
|
||||
// Clear simulated data when refreshing on the setup page
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}
|
||||
return () => leaveRoom()
|
||||
}, [userInfoSetFinished, messageListLoadFinished])
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -59,6 +74,13 @@ export default function App() {
|
|||
}
|
||||
}, [danmakuIsEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', leaveRoom)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', leaveRoom)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const themeMode =
|
||||
userInfo?.themeMode === 'system'
|
||||
? checkDarkMode()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
|
|
|
@ -11,8 +11,8 @@ import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/
|
|||
import { DanmakuImpl } from '@/domain/impls/Danmaku'
|
||||
import { NotificationImpl } from '@/domain/impls/Notification'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
|
||||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||
import { 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'
|
||||
|
@ -38,7 +38,8 @@ export default defineContentScript({
|
|||
LocalStorageImpl,
|
||||
IndexDBStorageImpl,
|
||||
BrowserSyncStorageImpl,
|
||||
PeerRoomImpl,
|
||||
ChatRoomImpl,
|
||||
VirtualRoomImpl,
|
||||
ToastImpl,
|
||||
DanmakuImpl,
|
||||
NotificationImpl
|
||||
|
|
|
@ -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 RoomDomain from '@/domain/Room'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
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 roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
@ -143,7 +143,7 @@ const Footer: FC = () => {
|
|||
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())
|
||||
}
|
||||
|
||||
|
|
|
@ -5,17 +5,39 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/H
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { cn, getSiteInfo } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import VirtualRoomDomain, { FromInfo, RoomUser } from '@/domain/VirtualRoom'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import AvatarCircles from '@/components/magicui/AvatarCircles'
|
||||
import Link from '@/components/Link'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const onlineCount = userList.length
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
|
||||
const chatOnlineCount = chatUserList.length
|
||||
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const virtualOnlineGroup = virtualUserList
|
||||
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
|
||||
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
|
||||
const existSite = acc.find((group) => group.origin === item.from.origin)
|
||||
if (existSite) {
|
||||
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
|
||||
!existUser && existSite.users.push(item.user)
|
||||
} else {
|
||||
acc.push({ ...item.from, users: [item.user] })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
|
||||
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg dark:bg-slate-950">
|
||||
|
@ -33,23 +55,55 @@ const Header: FC = () => {
|
|||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 rounded-lg">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid items-center">
|
||||
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
||||
{siteInfo.description && (
|
||||
<p className="line-clamp-2 max-h-8 text-xs text-slate-500 dark:text-slate-300">
|
||||
{siteInfo.description}
|
||||
</p>
|
||||
<HoverCardContent className="w-80 rounded-lg p-0">
|
||||
<ScrollArea 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">
|
||||
<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>
|
||||
</div>
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<HoverCard>
|
||||
|
@ -60,26 +114,26 @@ const Header: FC = () => {
|
|||
<span
|
||||
className={cn(
|
||||
'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
|
||||
className={cn(
|
||||
'relative inline-flex size-2 rounded-full',
|
||||
onlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
'relative inline-flex size-full rounded-full',
|
||||
chatOnlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></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>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<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
|
||||
data={userList}
|
||||
data={chatUserList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={scrollParentRef!}
|
||||
customScrollParent={chatUserListScrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
|
|
|
@ -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 RoomDomain, { MessageType } from '@/domain/Room'
|
||||
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
|
||||
|
@ -29,11 +29,11 @@ const Main: FC = () => {
|
|||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendHateMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -5,11 +5,18 @@ export interface LinkProps {
|
|||
href: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
underline?: boolean
|
||||
}
|
||||
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children }, ref) => {
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
|
||||
return (
|
||||
<a href={href} target={href} rel="noopener noreferrer" className={cn('hover:underline', className)} ref={ref}>
|
||||
<a
|
||||
href={href}
|
||||
target={href}
|
||||
rel="noopener noreferrer"
|
||||
className={cn(underline && 'hover:underline', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
|
69
src/components/magicui/AvatarCircles.tsx
Normal file
69
src/components/magicui/AvatarCircles.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
interface AvatarCirclesProps {
|
||||
className?: string
|
||||
avatarUrls: string[]
|
||||
size?: VariantProps<typeof SizeVariants>['size']
|
||||
max?: number
|
||||
}
|
||||
|
||||
const SizeVariants = cva('z-10 flex -space-x-4 rtl:space-x-reverse', {
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 min-w-10',
|
||||
sm: 'h-8 min-w-8',
|
||||
xs: 'h-6 min-w-6',
|
||||
lg: 'h-12 min-w-12'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const spaceVariants = cva('flex -space-x-4 rtl:space-x-reverse', {
|
||||
variants: {
|
||||
size: {
|
||||
default: '-space-x-4',
|
||||
sm: '-space-x-3',
|
||||
xs: '-space-x-2',
|
||||
lg: '-space-x-5'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const AvatarCircles = ({ className, avatarUrls, size, max = 10 }: AvatarCirclesProps) => {
|
||||
return (
|
||||
<div className={cn(spaceVariants({ size }), className)}>
|
||||
{avatarUrls.slice(0, max).map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded-full border-2 border-white dark:border-slate-800 aspect-square',
|
||||
SizeVariants({ size })
|
||||
)}
|
||||
src={url}
|
||||
alt={`Avatar ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full border-2 border-white bg-slate-600 text-center text-xs font-medium text-white dark:border-slate-800 p-1',
|
||||
SizeVariants({ size }),
|
||||
size === 'xs' && 'text-2xs'
|
||||
)}
|
||||
>
|
||||
+{avatarUrls.length}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvatarCircles
|
|
@ -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.
|
||||
*/
|
||||
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
|
||||
|
||||
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const
|
||||
|
|
|
@ -3,7 +3,7 @@ import StatusModule from './modules/Status'
|
|||
import { LocalStorageExtern } from './externs/Storage'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import StorageEffect from './modules/StorageEffect'
|
||||
import RoomDomain, { SendType } from './Room'
|
||||
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
export interface AppStatus {
|
||||
|
@ -26,7 +26,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
extern: LocalStorageExtern,
|
||||
key: APP_STATUS_STORAGE_KEY
|
||||
})
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const StatusLoadModule = StatusModule(domain, {
|
||||
name: 'AppStatus.LoadStatusModule'
|
||||
|
@ -131,7 +131,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'OnMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onMessage$ = fromEvent(roomDomain.event.OnMessageEvent).pipe(
|
||||
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
map((message) => {
|
||||
const status = get(StatusState())
|
||||
if (!status.open && message.type === SendType.Text) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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 { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, getTextByteSize, upsert } from '@/utils'
|
||||
|
@ -127,16 +127,16 @@ const RoomMessageSchema = v.union([
|
|||
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
|
||||
v.safeParse(RoomMessageSchema, message).success
|
||||
|
||||
const RoomDomain = Remesh.domain({
|
||||
name: 'RoomDomain',
|
||||
const ChatRoomDomain = Remesh.domain({
|
||||
name: 'ChatRoomDomain',
|
||||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const peerRoom = domain.getExtern(PeerRoomExtern)
|
||||
const chatRoomExtern = domain.getExtern(ChatRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: peerRoom.peerId
|
||||
default: chatRoomExtern.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
|
@ -165,7 +165,7 @@ const RoomDomain = Remesh.domain({
|
|||
const SelfUserQuery = domain.query({
|
||||
name: 'Room.SelfUserQuery',
|
||||
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 [
|
||||
UpdateUserListCommand({
|
||||
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({
|
||||
id: nanoid(),
|
||||
|
@ -202,14 +202,14 @@ const RoomDomain = Remesh.domain({
|
|||
receiveTime: Date.now()
|
||||
}),
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(peerRoom.roomId),
|
||||
SelfJoinRoomEvent(peerRoom.roomId)
|
||||
JoinRoomEvent(chatRoomExtern.roomId),
|
||||
SelfJoinRoomEvent(chatRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
JoinRoomCommand.after(() => {
|
||||
peerRoom.joinRoom()
|
||||
chatRoomExtern.joinRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
|
@ -230,17 +230,17 @@ const RoomDomain = Remesh.domain({
|
|||
}),
|
||||
UpdateUserListCommand({
|
||||
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(),
|
||||
LeaveRoomEvent(peerRoom.roomId),
|
||||
SelfLeaveRoomEvent(peerRoom.roomId)
|
||||
LeaveRoomEvent(chatRoomExtern.roomId),
|
||||
SelfLeaveRoomEvent(chatRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
LeaveRoomCommand.after(() => {
|
||||
peerRoom.leaveRoom()
|
||||
chatRoomExtern.leaveRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
|
@ -267,7 +267,7 @@ const RoomDomain = Remesh.domain({
|
|||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(textMessage)
|
||||
chatRoomExtern.sendMessage(textMessage)
|
||||
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
|
||||
}
|
||||
})
|
||||
|
@ -288,7 +288,7 @@ const RoomDomain = Remesh.domain({
|
|||
...localMessage,
|
||||
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage(likeMessage)
|
||||
chatRoomExtern.sendMessage(likeMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
|
||||
}
|
||||
})
|
||||
|
@ -309,7 +309,7 @@ const RoomDomain = Remesh.domain({
|
|||
...localMessage,
|
||||
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage(hateMessage)
|
||||
chatRoomExtern.sendMessage(hateMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
|
||||
}
|
||||
})
|
||||
|
@ -323,13 +323,13 @@ const RoomDomain = Remesh.domain({
|
|||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
peerId: peerRoom.peerId,
|
||||
peerId: chatRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
lastMessageTime,
|
||||
type: SendType.SyncUser
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(syncUserMessage, peerId)
|
||||
chatRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
return [SendSyncUserMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
@ -395,7 +395,7 @@ const RoomDomain = Remesh.domain({
|
|||
}, [])
|
||||
|
||||
return pushHistoryMessageList.map((message) => {
|
||||
peerRoom.sendMessage(message, peerId)
|
||||
chatRoomExtern.sendMessage(message, peerId)
|
||||
return SendSyncHistoryMessageEvent(message)
|
||||
})
|
||||
}
|
||||
|
@ -411,7 +411,7 @@ const RoomDomain = Remesh.domain({
|
|||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{ ...action.user, peerIds: [...(existUser?.peerIds || []), action.user.peerId] },
|
||||
{ ...action.user, peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId] },
|
||||
'userId'
|
||||
)
|
||||
)
|
||||
|
@ -492,10 +492,10 @@ const RoomDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Room.OnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
|
||||
const onJoinRoom$ = fromEventPattern<string>(chatRoomExtern.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (peerRoom.peerId === peerId) {
|
||||
if (chatRoomExtern.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
|
@ -509,7 +509,7 @@ const RoomDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: ({ get }) => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(chatRoomExtern.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// Filter out messages that do not conform to the format
|
||||
if (!checkMessageFormat(message)) {
|
||||
|
@ -606,7 +606,7 @@ const RoomDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Room.OnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
|
||||
const onLeaveRoom$ = fromEventPattern<string>(chatRoomExtern.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
if (get(JoinStatusModule.query.IsInitialQuery())) {
|
||||
return null
|
||||
|
@ -642,7 +642,7 @@ const RoomDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Room.OnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
|
||||
const onRoomError$ = fromEventPattern<Error>(chatRoomExtern.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(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 {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
|
@ -699,4 +687,4 @@ const RoomDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
export default RoomDomain
|
||||
export default ChatRoomDomain
|
|
@ -1,15 +1,15 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { DanmakuExtern } from './externs/Danmaku'
|
||||
import RoomDomain, { TextMessage } from './Room'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const DanmakuDomain = Remesh.domain({
|
||||
name: 'DanmakuDomain',
|
||||
impl: (domain) => {
|
||||
const danmaku = domain.getExtern(DanmakuExtern)
|
||||
const danmakuExtern = domain.getExtern(DanmakuExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const MountState = domain.state({
|
||||
name: 'Danmaku.MountState',
|
||||
|
@ -49,7 +49,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const PushCommand = domain.command({
|
||||
name: 'Danmaku.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmaku.push(message)
|
||||
danmakuExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -57,7 +57,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const UnshiftCommand = domain.command({
|
||||
name: 'Danmaku.UnshiftCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmaku.unshift(message)
|
||||
danmakuExtern.unshift(message)
|
||||
return [UnshiftEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -65,7 +65,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const ClearCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: () => {
|
||||
danmaku.clear()
|
||||
danmakuExtern.clear()
|
||||
return [ClearEvent()]
|
||||
}
|
||||
})
|
||||
|
@ -73,7 +73,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const MountCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: (_, container: HTMLElement) => {
|
||||
danmaku.mount(container)
|
||||
danmakuExtern.mount(container)
|
||||
return [MountEvent(container)]
|
||||
}
|
||||
})
|
||||
|
@ -81,7 +81,7 @@ const DanmakuDomain = Remesh.domain({
|
|||
const UnmountCommand = domain.command({
|
||||
name: 'Danmaku.UnmountCommand',
|
||||
impl: () => {
|
||||
danmaku.unmount()
|
||||
danmakuExtern.unmount()
|
||||
return [UnmountEvent()]
|
||||
}
|
||||
})
|
||||
|
@ -121,8 +121,8 @@ const DanmakuDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Danmaku.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const sendTextMessage$ = fromEvent(roomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { NotificationExtern } from './externs/Notification'
|
||||
import RoomDomain, { TextMessage } from './Room'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const NotificationDomain = Remesh.domain({
|
||||
name: 'NotificationDomain',
|
||||
impl: (domain) => {
|
||||
const notification = domain.getExtern(NotificationExtern)
|
||||
const notificationExtern = domain.getExtern(NotificationExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const NotificationEnabledState = domain.state<boolean>({
|
||||
name: 'Notification.EnabledState',
|
||||
|
@ -40,7 +40,7 @@ const NotificationDomain = Remesh.domain({
|
|||
const PushCommand = domain.command({
|
||||
name: 'Notification.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
notification.push(message)
|
||||
notificationExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
@ -68,7 +68,7 @@ const NotificationDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const notificationEnabled = get(IsEnabledQuery())
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import ToastModule from './modules/Toast'
|
||||
import RoomDomain, { SendType } from './Room'
|
||||
import { filter, map } from 'rxjs'
|
||||
import ChatRoomDomain, { SendType } from './ChatRoom'
|
||||
import VirtualRoomDomain from './VirtualRoom'
|
||||
import { filter, map, merge } from 'rxjs'
|
||||
|
||||
const ToastDomain = Remesh.domain({
|
||||
name: 'ToastDomain',
|
||||
impl: (domain) => {
|
||||
const roomDomain = domain.getDomain(RoomDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = domain.getDomain(VirtualRoomDomain())
|
||||
const toastModule = ToastModule(domain)
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnRoomSelfJoinRoomEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomJoin$ = fromEvent(roomDomain.event.SelfJoinRoomEvent).pipe(
|
||||
const onRoomJoin$ = fromEvent(chatRoomDomain.event.SelfJoinRoomEvent).pipe(
|
||||
map(() => toastModule.command.LoadingCommand({ message: 'Connected to the chat.', duration: 3000 }))
|
||||
)
|
||||
|
||||
|
@ -23,7 +25,10 @@ const ToastDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Toast.OnRoomErrorEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomError$ = fromEvent(roomDomain.event.OnErrorEvent).pipe(
|
||||
const onRoomError$ = merge(
|
||||
fromEvent(chatRoomDomain.event.OnErrorEvent),
|
||||
fromEvent(virtualRoomDomain.event.OnErrorEvent)
|
||||
).pipe(
|
||||
map((error) => {
|
||||
return toastModule.command.ErrorCommand(error.message)
|
||||
})
|
||||
|
@ -36,7 +41,7 @@ const ToastDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'Toast.OnSyncHistoryEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onSyncHistory$ = fromEvent(roomDomain.event.OnMessageEvent).pipe(
|
||||
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
filter((message) => message.type === SendType.SyncHistory),
|
||||
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
|
||||
)
|
||||
|
|
381
src/domain/VirtualRoom.ts
Normal file
381
src/domain/VirtualRoom.ts
Normal file
|
@ -0,0 +1,381 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
|
||||
import { type MessageUser } from './MessageList'
|
||||
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import * as v from 'valibot'
|
||||
import getSiteInfo, { SiteInfo } from '@/utils/getSiteInfo'
|
||||
|
||||
export enum SendType {
|
||||
SyncUser = 'SyncUser'
|
||||
}
|
||||
|
||||
export interface FromInfo extends SiteInfo {
|
||||
peerId: string
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.SyncUser
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
sendTime: number
|
||||
fromInfo: FromInfo
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; fromInfos: FromInfo[]; joinTime: number }
|
||||
|
||||
const MessageUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string()
|
||||
}
|
||||
|
||||
const FromInfoSchema = {
|
||||
peerId: v.string(),
|
||||
host: v.string(),
|
||||
hostname: v.string(),
|
||||
href: v.string(),
|
||||
origin: v.string(),
|
||||
title: v.string(),
|
||||
icon: v.string(),
|
||||
description: v.string()
|
||||
}
|
||||
|
||||
const RoomMessageSchema = v.union([
|
||||
v.object({
|
||||
type: v.literal(SendType.SyncUser),
|
||||
id: v.string(),
|
||||
peerId: v.string(),
|
||||
joinTime: v.number(),
|
||||
sendTime: v.number(),
|
||||
fromInfo: v.object(FromInfoSchema),
|
||||
...MessageUserSchema
|
||||
})
|
||||
])
|
||||
|
||||
// Check if the message conforms to the format
|
||||
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
|
||||
v.safeParse(RoomMessageSchema, message).success
|
||||
|
||||
const VirtualRoomDomain = Remesh.domain({
|
||||
name: 'VirtualRoomDomain',
|
||||
impl: (domain) => {
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const virtualRoomExtern = domain.getExtern(VirtualRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: virtualRoomExtern.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
name: 'Room.PeerIdQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerIdState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinStatusModule = StatusModule(domain, {
|
||||
name: 'Room.JoinStatusModule'
|
||||
})
|
||||
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'Room.UserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const SelfUserQuery = domain.query({
|
||||
name: 'Room.SelfUserQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListQuery()).find((user) => user.peerIds.includes(virtualRoomExtern.peerId))!
|
||||
}
|
||||
})
|
||||
|
||||
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: {
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
}
|
||||
}),
|
||||
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(virtualRoomExtern.roomId),
|
||||
SelfJoinRoomEvent(virtualRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
JoinRoomCommand.after(() => {
|
||||
virtualRoomExtern.joinRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: {
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
}
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(virtualRoomExtern.roomId),
|
||||
SelfLeaveRoomEvent(virtualRoomExtern.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
LeaveRoomCommand.after(() => {
|
||||
virtualRoomExtern.leaveRoom()
|
||||
return null
|
||||
})
|
||||
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'Room.UpdateUserListCommand',
|
||||
impl: (
|
||||
{ get },
|
||||
action: {
|
||||
type: 'create' | 'delete'
|
||||
user: Omit<RoomUser, 'peerIds' | 'fromInfos'> & { peerId: string; fromInfo: FromInfo }
|
||||
}
|
||||
) => {
|
||||
const userList = get(UserListState())
|
||||
const existUser = userList.find((user) => user.userId === action.user.userId)
|
||||
if (action.type === 'create') {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{
|
||||
...action.user,
|
||||
peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId],
|
||||
fromInfos: upsert(existUser?.fromInfos || [], action.user.fromInfo, 'peerId')
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
)
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{
|
||||
...action.user,
|
||||
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || [],
|
||||
fromInfos: existUser?.fromInfos?.filter((fromInfo) => fromInfo.peerId !== action.user.peerId) || []
|
||||
},
|
||||
'userId'
|
||||
).filter((user) => user.peerIds.length)
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
peerId: virtualRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
|
||||
type: SendType.SyncUser
|
||||
}
|
||||
|
||||
virtualRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
return [SendSyncUserMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.JoinRoomEvent'
|
||||
})
|
||||
|
||||
const LeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.LeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'Room.OnMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnJoinRoomEvent'
|
||||
})
|
||||
|
||||
const SelfJoinRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfJoinRoomEvent'
|
||||
})
|
||||
|
||||
const OnLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.OnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const SelfLeaveRoomEvent = domain.event<string>({
|
||||
name: 'Room.SelfLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnErrorEvent = domain.event<Error>({
|
||||
name: 'Room.OnErrorEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(virtualRoomExtern.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (virtualRoomExtern.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: () => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(virtualRoomExtern.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// Filter out messages that do not conform to the format
|
||||
if (!checkMessageFormat(message)) {
|
||||
console.warn('Invalid message format', message)
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
const messageEvent$ = of(OnMessageEvent(message))
|
||||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.SyncUser: {
|
||||
return of(UpdateUserListCommand({ type: 'create', user: message }))
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
}
|
||||
})()
|
||||
|
||||
return merge(messageEvent$, messageCommand$)
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(virtualRoomExtern.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
if (get(JoinStatusModule.query.IsInitialQuery())) {
|
||||
return null
|
||||
}
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
|
||||
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
|
||||
|
||||
if (existUser) {
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { ...existUser, peerId, fromInfo: { ...getSiteInfo(), peerId } }
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(virtualRoomExtern.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(error)
|
||||
return OnErrorEvent(error)
|
||||
})
|
||||
)
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
JoinIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendSyncUserMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendSyncUserMessageEvent,
|
||||
JoinRoomEvent,
|
||||
SelfJoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
SelfLeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
OnErrorEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default VirtualRoomDomain
|
|
@ -1,19 +1,19 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '../ChatRoom'
|
||||
|
||||
export interface PeerRoom {
|
||||
export interface ChatRoom {
|
||||
readonly peerId: string
|
||||
readonly roomId: string
|
||||
joinRoom: () => PeerRoom
|
||||
sendMessage: (message: RoomMessage, id?: string | string[]) => PeerRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => PeerRoom
|
||||
leaveRoom: () => PeerRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onError: (callback: (error: Error) => void) => PeerRoom
|
||||
joinRoom: () => ChatRoom
|
||||
sendMessage: (message: RoomMessage, id?: string | string[]) => ChatRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => ChatRoom
|
||||
leaveRoom: () => ChatRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => ChatRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => ChatRoom
|
||||
onError: (callback: (error: Error) => void) => ChatRoom
|
||||
}
|
||||
|
||||
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
|
||||
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
|
||||
default: {
|
||||
peerId: '',
|
||||
roomId: '',
|
|
@ -1,5 +1,5 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Danmaku {
|
||||
push: (message: TextMessage) => void
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Notification {
|
||||
push: (message: TextMessage) => Promise<string>
|
||||
|
|
42
src/domain/externs/VirtualRoom.ts
Normal file
42
src/domain/externs/VirtualRoom.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '@/domain/VirtualRoom'
|
||||
|
||||
export interface VirtualRoom {
|
||||
readonly peerId: string
|
||||
readonly roomId: string
|
||||
joinRoom: () => VirtualRoom
|
||||
sendMessage: (message: RoomMessage, id?: string | string[]) => VirtualRoom
|
||||
onMessage: (callback: (message: RoomMessage) => void) => VirtualRoom
|
||||
leaveRoom: () => VirtualRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => VirtualRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => VirtualRoom
|
||||
onError: (callback: (error: Error) => void) => VirtualRoom
|
||||
}
|
||||
|
||||
export const VirtualRoomExtern = Remesh.extern<VirtualRoom>({
|
||||
default: {
|
||||
peerId: '',
|
||||
roomId: '',
|
||||
joinRoom: () => {
|
||||
throw new Error('"joinRoom" not implemented.')
|
||||
},
|
||||
sendMessage: () => {
|
||||
throw new Error('"sendMessage" not implemented.')
|
||||
},
|
||||
onMessage: () => {
|
||||
throw new Error('"onMessage" not implemented.')
|
||||
},
|
||||
leaveRoom: () => {
|
||||
throw new Error('"leaveRoom" not implemented.')
|
||||
},
|
||||
onJoinRoom: () => {
|
||||
throw new Error('"onJoinRoom" not implemented.')
|
||||
},
|
||||
onLeaveRoom: () => {
|
||||
throw new Error('"onLeaveRoom" not implemented.')
|
||||
},
|
||||
onError: () => {
|
||||
throw new Error('"onError" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,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 { nanoid } from 'nanoid'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '@/domain/ChatRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
peer: Peer
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class PeerRoom extends EventHub {
|
||||
class ChatRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
readonly roomId: string
|
||||
private rtco?: Artico
|
||||
readonly peerId: string
|
||||
private room?: Room
|
||||
|
||||
constructor(config: Config) {
|
||||
super()
|
||||
this.peer = config.peer
|
||||
this.roomId = config.roomId
|
||||
this.peerId = config.peerId || nanoid()
|
||||
this.peerId = config.peer.id
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -32,16 +33,18 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
if (!this.rtco) {
|
||||
this.rtco = new Artico({ id: this.peerId })
|
||||
}
|
||||
if (this.room) {
|
||||
this.room = this.rtco.join(this.roomId)
|
||||
this.room = this.peer.join(this.roomId)
|
||||
} else {
|
||||
this.rtco!.on('open', () => {
|
||||
this.room = this.rtco!.join(this.roomId)
|
||||
if (this.peer.state === 'ready') {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
} else {
|
||||
this.peer!.on('open', () => {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -123,7 +126,7 @@ class PeerRoom extends EventHub {
|
|||
return this
|
||||
}
|
||||
onError(callback: (error: Error) => void) {
|
||||
this.rtco?.on('error', (error) => callback(error))
|
||||
this.peer?.on('error', (error) => callback(error))
|
||||
this.on('error', (error: Error) => callback(error))
|
||||
return this
|
||||
}
|
||||
|
@ -131,9 +134,9 @@ class PeerRoom extends EventHub {
|
|||
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
const chatRoom = new ChatRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
export const ChatRoomImpl = ChatRoomExtern.impl(chatRoom)
|
||||
|
||||
// https://github.com/w3c/webextensions/issues/72
|
||||
// https://issues.chromium.org/issues/40251342
|
|
@ -1,6 +1,6 @@
|
|||
import { DanmakuExtern } from '@/domain/externs/Danmaku'
|
||||
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { createElement } from 'react'
|
||||
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { NotificationExtern } from '@/domain/externs/Notification'
|
||||
import { TextMessage } from '../Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import { messenger } from '@/messenger'
|
||||
|
||||
|
|
22
src/domain/impls/Peer.ts
Normal file
22
src/domain/impls/Peer.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
import { Artico } from '@rtco/client'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
}
|
||||
|
||||
export default class Peer extends Artico {
|
||||
private static instance: Peer | null = null
|
||||
private constructor(config: Config = {}) {
|
||||
const { peerId = nanoid() } = config
|
||||
super({ id: peerId })
|
||||
}
|
||||
|
||||
public static createInstance(config: Config = {}) {
|
||||
return (this.instance ??= new Peer(config))
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
return this.instance
|
||||
}
|
||||
}
|
|
@ -1,27 +1,29 @@
|
|||
import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
|
||||
import { Room } from '@rtco/client'
|
||||
|
||||
// import { joinRoom } from 'trystero/firebase'
|
||||
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
|
||||
import { stringToHex } from '@/utils'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { RoomMessage } from '@/domain/VirtualRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import { VIRTUAL_ROOM_ID } from '@/constants/config'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
peer: Peer
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class PeerRoom extends EventHub {
|
||||
readonly appId: string
|
||||
private room?: Room
|
||||
class VirtualRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
readonly roomId: string
|
||||
readonly peerId: string
|
||||
private room?: Room
|
||||
|
||||
constructor(config: Config) {
|
||||
super()
|
||||
this.appId = __NAME__
|
||||
this.peer = config.peer
|
||||
this.roomId = config.roomId
|
||||
this.peerId = selfId
|
||||
this.peerId = config.peer.id
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -32,16 +34,19 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
this.room = joinRoom({ appId: this.appId }, this.roomId)
|
||||
/**
|
||||
* If we wait to join, it will result in not being able to listen to our own join event.
|
||||
* This might be related to the fact that:
|
||||
* (If called more than once, only the latest callback registered is ever called.)
|
||||
* Multiple listeners may overwrite each other.
|
||||
* @see: https://github.com/dmotz/trystero?tab=readme-ov-file#onpeerjoincallback
|
||||
*/
|
||||
// this.room.onPeerJoin(() => this.emit('action'))
|
||||
this.emit('action')
|
||||
if (this.room) {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
} else {
|
||||
if (this.peer.state === 'ready') {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
} else {
|
||||
this.peer!.on('open', () => {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.emit('action')
|
||||
})
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -51,15 +56,12 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as any as DataPayload, id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as any as DataPayload, id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
|
@ -69,13 +71,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as any as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as any as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -86,15 +86,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
this.room.on('join', (id) => callback(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
this.room.on('join', (id) => callback(id))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -105,11 +101,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -130,19 +126,15 @@ class PeerRoom extends EventHub {
|
|||
}
|
||||
return this
|
||||
}
|
||||
|
||||
onError(callback: (error: Error) => void) {
|
||||
this.peer?.on('error', (error) => callback(error))
|
||||
this.on('error', (error: Error) => callback(error))
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
const hostRoomId = stringToHex(VIRTUAL_ROOM_ID)
|
||||
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
const virtualRoom = new VirtualRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
|
||||
// https://github.com/w3c/webextensions/issues/72
|
||||
// https://issues.chromium.org/issues/40251342
|
||||
// https://github.com/w3c/webrtc-extensions/issues/77
|
||||
// https://github.com/aklinker1/webext-core/pull/70
|
||||
export const VirtualRoomImpl = VirtualRoomExtern.impl(virtualRoom)
|
|
@ -6,7 +6,7 @@ export interface ToastOptions {
|
|||
}
|
||||
|
||||
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
const toastExtern = domain.getExtern(ToastExtern)
|
||||
|
||||
const SuccessEvent = domain.event<number | string>({
|
||||
name: `${options.name}.SuccessEvent`
|
||||
|
@ -15,7 +15,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const SuccessCommand = domain.command({
|
||||
name: `${options.name}.SuccessCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toast.success(
|
||||
const id = toastExtern.success(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
|
@ -30,7 +30,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const ErrorCommand = domain.command({
|
||||
name: `${options.name}.ErrorCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toast.error(
|
||||
const id = toastExtern.error(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
|
@ -45,7 +45,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const InfoCommand = domain.command({
|
||||
name: `${options.name}.InfoCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toast.info(
|
||||
const id = toastExtern.info(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
|
@ -60,7 +60,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const WarningCommand = domain.command({
|
||||
name: `${options.name}.WarningCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toast.warning(
|
||||
const id = toastExtern.warning(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
|
@ -75,7 +75,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const LoadingCommand = domain.command({
|
||||
name: `${options.name}.LoadingCommand`,
|
||||
impl: (_, message: string | { message: string; duration?: number }) => {
|
||||
const id = toast.loading(
|
||||
const id = toastExtern.loading(
|
||||
typeof message === 'string' ? message : message.message,
|
||||
typeof message === 'string' ? undefined : message.duration
|
||||
)
|
||||
|
@ -90,7 +90,7 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
const CancelCommand = domain.command({
|
||||
name: `${options.name}.CancelCommand`,
|
||||
impl: (_, id: number | string) => {
|
||||
toast.cancel(id)
|
||||
toastExtern.cancel(id)
|
||||
return [CancelEvent(id)]
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { EVENT } from '@/constants/event'
|
||||
import { defineExtensionMessaging } from '@webext-core/messaging'
|
||||
import { TextMessage } from '@/domain/Room'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
interface ProtocolMap {
|
||||
[EVENT.OPTIONS_PAGE_OPEN]: () => void
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { buildFullURL } from '@/utils'
|
||||
|
||||
export interface SiteInfo {
|
||||
host: string
|
||||
hostname: string
|
||||
|
@ -15,15 +17,17 @@ const getSiteInfo = (): SiteInfo => {
|
|||
href: document.location.href,
|
||||
origin: document.location.origin,
|
||||
title:
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[property="og:site_name" i]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[property="og:title" i]')?.getAttribute('content') ??
|
||||
document.title,
|
||||
icon:
|
||||
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
|
||||
icon: buildFullURL(
|
||||
document.location.origin,
|
||||
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
|
||||
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
|
||||
`${document.location.origin}/favicon.ico`,
|
||||
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`
|
||||
),
|
||||
description:
|
||||
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
|
||||
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
|
||||
|
|
|
@ -17,3 +17,5 @@ export { default as getRootNode } from './getRootNode'
|
|||
export { default as blobToBase64 } from './blobToBase64'
|
||||
export * as JSONR from './jsonr'
|
||||
export { getTextByteSize } from './getTextByteSize'
|
||||
export { default as isEqual } from './isEqual'
|
||||
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'
|
||||
|
|
5
src/utils/isEqual.ts
Normal file
5
src/utils/isEqual.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
const isEqual = (a: object, b: object) => {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
}
|
||||
|
||||
export default isEqual
|
36
src/utils/url.ts
Normal file
36
src/utils/url.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
export const cleanURL = (url: string) => url.replace(/([^:]\/)\/+/g, '$1').replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Determines whether the specified URL is absolute
|
||||
* Reference: https://github.com/axios/axios/blob/v1.x/lib/helpers/isAbsoluteURL.js
|
||||
*/
|
||||
export const isAbsoluteURL = (url: string) => {
|
||||
// A URL is considered absolute if it begins with "<scheme>://" or "//" (protocol-relative URL).
|
||||
// RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed
|
||||
// by any combination of letters, digits, plus, period, or hyphen.
|
||||
return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add params to the URL
|
||||
*/
|
||||
export const assembleURL = (url: string, params: Record<string, string>) => {
|
||||
return Object.entries(params)
|
||||
.reduce((url, [key, value]) => {
|
||||
url.searchParams.append(key, value)
|
||||
return url
|
||||
}, new URL(url))
|
||||
.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new URL by combining the baseURL with the requestedURL,
|
||||
* only when the requestedURL is not already an absolute URL.
|
||||
* If the requestURL is absolute, this function returns the requestedURL untouched.
|
||||
*
|
||||
* reference: https://github.com/axios/axios/blob/v1.x/lib/core/buildFullPath.js
|
||||
*/
|
||||
export const buildFullURL = (baseURL: string = '', pathURL: string = '', params: Record<string, any> = {}) => {
|
||||
const url = cleanURL(isAbsoluteURL(pathURL) ? pathURL : `${baseURL}/${pathURL}`)
|
||||
return assembleURL(url, params)
|
||||
}
|
|
@ -11,6 +11,9 @@ export default {
|
|||
padding: '2rem'
|
||||
},
|
||||
extend: {
|
||||
fontSize: {
|
||||
'2xs': '0.625rem'
|
||||
},
|
||||
zIndex: {
|
||||
infinity: 'calc(infinity)'
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue