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",
"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"

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 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()

View file

@ -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'

View file

@ -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

View file

@ -6,7 +6,7 @@ import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import 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())
}

View file

@ -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">

View file

@ -5,13 +5,13 @@ import MessageList from '../../components/MessageList'
import MessageItem from '../../components/MessageItem'
import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import 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 (

View file

@ -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>
)

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.
*/
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const

View file

@ -3,7 +3,7 @@ import StatusModule from './modules/Status'
import { LocalStorageExtern } from './externs/Storage'
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
import StorageEffect from './modules/StorageEffect'
import 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) {

View file

@ -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

View file

@ -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) => {

View file

@ -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())

View file

@ -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
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 { 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: '',

View file

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

View file

@ -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>

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 { 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

View file

@ -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'

View file

@ -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
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 { 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)

View file

@ -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)]
}
})

View file

@ -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

View file

@ -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') ??

View file

@ -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
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'
},
extend: {
fontSize: {
'2xs': '0.625rem'
},
zIndex: {
infinity: 'calc(infinity)'
},