mirror of
https://github.com/molvqingtai/WebChat.git
synced 2025-11-25 03:15:08 +08:00
Merge branch 'develop'
This commit is contained in:
@@ -25,8 +25,8 @@ const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouse
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage src={data.sender.avatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.sender.name.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="max-w-44 truncate">{data.body}</div>
|
||||
</Button>
|
||||
|
||||
@@ -5,11 +5,11 @@ import FormatDate from './format-date'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
|
||||
import { Markdown } from '@/components/markdown'
|
||||
import { type NormalMessage } from '@/domain/MessageList'
|
||||
import { type TextMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: NormalMessage
|
||||
data: TextMessage
|
||||
index?: number
|
||||
like: boolean
|
||||
hate: boolean
|
||||
@@ -28,18 +28,18 @@ const MessageItem: FC<MessageItemProps> = memo((props) => {
|
||||
|
||||
let content = props.data.body
|
||||
|
||||
// Check if the field exists, compatible with old data
|
||||
if (props.data.atUsers) {
|
||||
const atUserPositions = props.data.atUsers.flatMap((user) =>
|
||||
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
|
||||
// Check if mentions exist
|
||||
if (props.data.mentions && props.data.mentions.length > 0) {
|
||||
const mentionPositions = props.data.mentions.flatMap((user) =>
|
||||
user.positions.map((position) => ({ name: user.name, id: user.id, position }))
|
||||
)
|
||||
|
||||
// Replace from back to front according to position to avoid affecting previous indices
|
||||
atUserPositions
|
||||
mentionPositions
|
||||
.sort((a, b) => b.position[0] - a.position[0])
|
||||
.forEach(({ position, username }) => {
|
||||
.forEach(({ position, name }) => {
|
||||
const [start, end] = position
|
||||
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
|
||||
content = `${content.slice(0, start)} **@${name}** ${content.slice(end + 1)}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,13 +52,15 @@ const MessageItem: FC<MessageItemProps> = memo((props) => {
|
||||
)}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage src={props.data.sender.avatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{props.data.sender.name.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
||||
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
|
||||
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">
|
||||
{props.data.sender.name}
|
||||
</div>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sentAt}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
@@ -68,7 +70,7 @@ const MessageItem: FC<MessageItemProps> = memo((props) => {
|
||||
<LikeButton
|
||||
checked={props.like}
|
||||
onChange={(checked) => handleLikeChange(checked)}
|
||||
count={props.data.likeUsers.length}
|
||||
count={props.data.reactions.likes.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<HeartIcon size={14}></HeartIcon>
|
||||
@@ -77,7 +79,7 @@ const MessageItem: FC<MessageItemProps> = memo((props) => {
|
||||
<LikeButton
|
||||
checked={props.hate}
|
||||
onChange={(checked) => handleHateChange(checked)}
|
||||
count={props.data.hateUsers.length}
|
||||
count={props.data.reactions.hates.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<FrownIcon size={14}></FrownIcon>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { PromptMessage } from '@/domain/MessageList'
|
||||
import type { SystemPromptMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { type FC, memo } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: PromptMessage
|
||||
data: SystemPromptMessage
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ const PromptItem: FC<PromptItemProps> = memo(({ data, className }) => {
|
||||
<div className={cn('flex justify-center py-1 px-4 ', className)}>
|
||||
<Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400 dark:bg-slate-800">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage src={data.sender.avatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.sender.name.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{data.body}
|
||||
</Badge>
|
||||
|
||||
@@ -84,8 +84,8 @@ const Footer: FC = () => {
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const positionList = [...item].filter((item) => {
|
||||
const username = message.slice(item[0], item[1] + 1)
|
||||
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||
const name = message.slice(item[0], item[1] + 1)
|
||||
return name === `@${userList.find((user) => user.id === userId)?.name}`
|
||||
})
|
||||
if (positionList.length) {
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
@@ -102,10 +102,10 @@ const Footer: FC = () => {
|
||||
|
||||
const autoCompleteList = useMemo(() => {
|
||||
return userList
|
||||
.filter((user) => user.userId !== userInfo?.id)
|
||||
.filter((user) => user.id !== userInfo?.id)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.name.toLowerCase())
|
||||
}))
|
||||
.toSorted((a, b) => b.similarity - a.similarity)
|
||||
}, [searchNameKeyword, userList, userInfo])
|
||||
@@ -136,14 +136,14 @@ const Footer: FC = () => {
|
||||
return
|
||||
}
|
||||
const transformedMessage = await transformMessage(message)
|
||||
const atUsers = [...atUserRecord.current]
|
||||
const mentions = [...atUserRecord.current]
|
||||
.map(([userId, positions]) => {
|
||||
const user = userList.find((user) => user.userId === userId)
|
||||
const user = userList.find((user) => user.id === userId)
|
||||
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const newMessage = { body: transformedMessage, atUsers }
|
||||
const newMessage = { body: transformedMessage, mentions }
|
||||
const byteSize = getTextByteSize(JSON.stringify(newMessage))
|
||||
|
||||
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
@@ -151,7 +151,7 @@ const Footer: FC = () => {
|
||||
}
|
||||
|
||||
send([
|
||||
chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }),
|
||||
chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, mentions }),
|
||||
messageInputDomain.command.ClearCommand()
|
||||
])
|
||||
}
|
||||
@@ -193,7 +193,7 @@ const Footer: FC = () => {
|
||||
if (isComposing.current) return
|
||||
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
handleInjectAtSyntax(selectedUser.username)
|
||||
handleInjectAtSyntax(selectedUser.name)
|
||||
} else {
|
||||
handleSend()
|
||||
}
|
||||
@@ -314,7 +314,7 @@ const Footer: FC = () => {
|
||||
// Calculate the difference after replacing @text with @user
|
||||
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||
|
||||
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.id)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
requestIdleCallback(() => {
|
||||
@@ -343,8 +343,8 @@ const Footer: FC = () => {
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
onClick={() => handleInjectAtSyntax(user.username)}
|
||||
key={user.id}
|
||||
onClick={() => handleInjectAtSyntax(user.name)}
|
||||
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||
@@ -354,10 +354,10 @@ const Footer: FC = () => {
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage className="size-full" src={user.avatar} alt="avatar" />
|
||||
<AvatarFallback>{user.name.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FC } from 'react'
|
||||
import { useState, useMemo, type FC } from 'react'
|
||||
import { Globe2Icon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { cn, getSiteMeta } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import type { FromInfo, RoomUser } from '@/domain/WorldRoom'
|
||||
import type { FromSite, RoomUser } from '@/domain/WorldRoom'
|
||||
import WorldRoomDomain from '@/domain/WorldRoom'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
@@ -22,19 +22,23 @@ const Header: FC = () => {
|
||||
const worldUserList = useRemeshQuery(worldRoomDomain.query.UserListQuery())
|
||||
const chatOnlineCount = chatUserList.length
|
||||
|
||||
const worldOnlineGroup = worldUserList
|
||||
.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 worldOnlineGroup = useMemo(
|
||||
() =>
|
||||
worldUserList
|
||||
.flatMap((user) => user.fromSites.map((from) => ({ from, user })))
|
||||
.reduce<(FromSite & { users: RoomUser[] })[]>((acc, item) => {
|
||||
const existSite = acc.find((group) => group.origin === item.from.origin)
|
||||
if (existSite) {
|
||||
const existUser = existSite.users.find((user: RoomUser) => user.id === item.user.id)
|
||||
!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),
|
||||
[worldUserList]
|
||||
)
|
||||
|
||||
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const [worldOnlineGroupScrollParentRef, setWorldOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
@@ -60,7 +64,11 @@ const Header: FC = () => {
|
||||
<Virtuoso
|
||||
data={worldOnlineGroup}
|
||||
defaultItemHeight={56}
|
||||
increaseViewportBy={200}
|
||||
overscan={200}
|
||||
customScrollParent={worldOnlineGroupScrollParentRef!}
|
||||
computeItemKey={(_, site) => site.origin}
|
||||
skipAnimationFrameInResizeObserver
|
||||
itemContent={(_index, site) => (
|
||||
<Link
|
||||
underline={false}
|
||||
@@ -107,7 +115,11 @@ const Header: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarCircles maxLength={9} size="xs" avatarUrls={site.users.map((user) => user.userAvatar)} />
|
||||
<AvatarCircles
|
||||
maxLength={9}
|
||||
size="xs"
|
||||
avatarUrls={site.users.map((user: RoomUser) => user.avatar)}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
@@ -154,14 +166,18 @@ const Header: FC = () => {
|
||||
<Virtuoso
|
||||
data={chatUserList}
|
||||
defaultItemHeight={28}
|
||||
increaseViewportBy={200}
|
||||
overscan={200}
|
||||
customScrollParent={chatUserListScrollParentRef!}
|
||||
computeItemKey={(_, user) => user.id}
|
||||
skipAnimationFrameInResizeObserver
|
||||
itemContent={(_index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
<AvatarImage className="size-full" src={user.avatar} alt="avatar" />
|
||||
<AvatarFallback>{user.name.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
|
||||
@@ -8,7 +8,7 @@ import UserInfoDomain from '@/domain/UserInfo'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import useDataId from '@/hooks/useDataId'
|
||||
import { ChatRoomMessageType } from '@/protocol'
|
||||
import { compareHLC } from '@/utils'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
@@ -24,31 +24,31 @@ const Main: FC = () => {
|
||||
() =>
|
||||
_messageList
|
||||
.map((message) => {
|
||||
if (message.type === ChatRoomMessageType.Normal) {
|
||||
if (message.type === 'text') {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
like: message.reactions.likes.some((likeUser) => likeUser.id === userInfo?.id),
|
||||
hate: message.reactions.hates.some((hateUser) => hateUser.id === userInfo?.id)
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime),
|
||||
.toSorted((a, b) => compareHLC(a.hlc, b.hlc)),
|
||||
[messageListId, userInfo?.id]
|
||||
)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendReactionCommand({ messageId, reaction: 'like' }))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
|
||||
send(chatRoomDomain.command.SendReactionCommand({ messageId, reaction: 'hate' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageList>
|
||||
{messageList.map((message, index) =>
|
||||
message.type === ChatRoomMessageType.Normal ? (
|
||||
message.type === 'text' ? (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Message } from '@/domain/MessageList'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import type { UserInfo } from '@/domain/UserInfo'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { generateRandomAvatar, generateRandomName, setIntervalImmediate } from '@/utils'
|
||||
import { createHLC, generateRandomAvatar, generateRandomName, setIntervalImmediate } from '@/utils'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { FC } from 'react'
|
||||
@@ -15,7 +15,7 @@ import { PulsatingButton } from '@/components/magicui/pulsating-button'
|
||||
import { BlurFade } from '@/components/magicui/blur-fade'
|
||||
import { motion } from 'framer-motion'
|
||||
import { WordRotate } from '@/components/magicui/word-rotate'
|
||||
import { ChatRoomMessageType } from '@/protocol'
|
||||
import { MESSAGE_TYPE } from '@/protocol/Message'
|
||||
|
||||
const mockTextList = [
|
||||
`你問我支持不支持,我說我支持`,
|
||||
@@ -50,19 +50,25 @@ const generateUserInfo = async (): Promise<UserInfo> => {
|
||||
}
|
||||
|
||||
const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
||||
const { name: username, avatar: userAvatar, id: userId } = userInfo
|
||||
const { name, avatar, id } = userInfo
|
||||
const now = Date.now()
|
||||
return {
|
||||
id: nanoid(),
|
||||
type: MESSAGE_TYPE.TEXT,
|
||||
hlc: createHLC(),
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id,
|
||||
name,
|
||||
avatar
|
||||
},
|
||||
body: mockTextList.shift()!,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
type: ChatRoomMessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||
hateUsers: [],
|
||||
atUsers: []
|
||||
mentions: [],
|
||||
reactions: {
|
||||
likes: mockTextList.length ? [] : [{ id, name, avatar }],
|
||||
hates: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import StorageEffect from './modules/StorageEffect'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import { map } from 'rxjs'
|
||||
import { ChatRoomSendType } from '@/protocol'
|
||||
import { MESSAGE_TYPE } from '@/protocol/Message'
|
||||
|
||||
export interface AppStatus {
|
||||
open: boolean
|
||||
@@ -136,7 +136,7 @@ const AppStatusDomain = Remesh.domain({
|
||||
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
map((message) => {
|
||||
const status = get(StatusState())
|
||||
if (!status.open && message.type === ChatRoomSendType.Text) {
|
||||
if (!status.open && message.type === MESSAGE_TYPE.TEXT) {
|
||||
return UpdateUnreadCommand(status.unread + 1)
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEventPattern, bufferTime, filter } from 'rxjs'
|
||||
import type { AtUser, NormalMessage } from './MessageList'
|
||||
import { type MessageUser } from './MessageList'
|
||||
import type { MessageUser, MentionedUser } from './MessageList'
|
||||
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, getTextByteSize, upsert } from '@/utils'
|
||||
import HLCClockDomain from '@/domain/HLCClock'
|
||||
import { desert, getTextByteSize, upsert, compareHLC, sendEvent } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
|
||||
import hash from 'hash-it'
|
||||
import {
|
||||
checkChatRoomMessage,
|
||||
ChatRoomMessageType,
|
||||
ChatRoomSendType,
|
||||
type ChatRoomMessage,
|
||||
type ChatRoomTextMessage,
|
||||
type ChatRoomLikeMessage,
|
||||
type ChatRoomHateMessage,
|
||||
type ChatRoomSyncUserMessage,
|
||||
type ChatRoomSyncHistoryMessage
|
||||
} from '@/protocol'
|
||||
validateNetworkMessage,
|
||||
type NetworkMessage,
|
||||
type TextMessage,
|
||||
type ReactionMessage,
|
||||
type PeerSyncMessage,
|
||||
type HistorySyncMessage,
|
||||
MESSAGE_TYPE,
|
||||
REACTION_TYPE,
|
||||
PROMPT_TYPE
|
||||
} from '@/protocol/Message'
|
||||
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; joinTime: number }
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; joinedAt: number }
|
||||
|
||||
const ChatRoomDomain = Remesh.domain({
|
||||
name: 'ChatRoomDomain',
|
||||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const hlcClockDomain = domain.getDomain(HLCClockDomain())
|
||||
const chatRoomExtern = domain.getExtern(ChatRoomExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
@@ -66,14 +67,18 @@ const ChatRoomDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const LastMessageTimeQuery = domain.query({
|
||||
name: 'Room.LastMessageTimeQuery',
|
||||
const LastMessageHLCQuery = domain.query({
|
||||
name: 'Room.LastMessageHLCQuery',
|
||||
impl: ({ get }) => {
|
||||
return (
|
||||
get(messageListDomain.query.ListQuery())
|
||||
.filter((message) => message.type === ChatRoomMessageType.Normal)
|
||||
.toSorted((a, b) => b.sendTime - a.sendTime)[0]?.sendTime ?? new Date(1970, 1, 1).getTime()
|
||||
const messages = get(messageListDomain.query.ListQuery()).filter(
|
||||
(message) => message.type === MESSAGE_TYPE.TEXT
|
||||
)
|
||||
|
||||
if (!messages.length) {
|
||||
return { timestamp: 0, counter: 0 }
|
||||
}
|
||||
|
||||
return messages.reduce((latest, msg) => (compareHLC(msg.hlc, latest.hlc) > 0 ? msg : latest)).hlc
|
||||
}
|
||||
})
|
||||
|
||||
@@ -99,63 +104,66 @@ const ChatRoomDomain = Remesh.domain({
|
||||
*/
|
||||
const HandleJoinLeaveMessageCommand = domain.command({
|
||||
name: 'Room.HandleJoinLeaveMessageCommand',
|
||||
impl: (
|
||||
{ get },
|
||||
payload: { userId: string; username: string; userAvatar: string; messageType: 'join' | 'leave' }
|
||||
) => {
|
||||
const { userId, username, userAvatar, messageType } = payload
|
||||
impl: ({ get }, payload: { id: string; name: string; avatar: string; messageType: 'join' | 'leave' }) => {
|
||||
const { id, name, avatar, messageType } = payload
|
||||
const now = Date.now()
|
||||
const messageBody = messageType === 'join' ? `"${username}" joined the chat` : `"${username}" left the chat`
|
||||
const currentHLC = get(hlcClockDomain.query.CurrentHLCQuery())
|
||||
const newHLC = sendEvent(currentHLC)
|
||||
const messageBody = messageType === 'join' ? `"${name}" joined the chat` : `"${name}" left the chat`
|
||||
|
||||
// Find user's most recent join/leave message
|
||||
const messageList = get(messageListDomain.query.ListQuery())
|
||||
const userPromptMessages = messageList
|
||||
.filter((msg) => msg.type === ChatRoomMessageType.Prompt && msg.userId === userId)
|
||||
.toSorted((a, b) => b.sendTime - a.sendTime)
|
||||
.filter((msg) => msg.type === MESSAGE_TYPE.SYSTEM_PROMPT && msg.sender.id === id)
|
||||
.toSorted((a, b) => compareHLC(b.hlc, a.hlc))
|
||||
|
||||
const lastMessage = userPromptMessages[0]
|
||||
|
||||
// If the previous message is from the same user, delete it
|
||||
if (lastMessage) {
|
||||
return [
|
||||
hlcClockDomain.command.SendEventCommand(),
|
||||
messageListDomain.command.DeleteItemCommand(lastMessage.id),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
type: MESSAGE_TYPE.SYSTEM_PROMPT,
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: { id, name, avatar },
|
||||
body: messageBody,
|
||||
type: ChatRoomMessageType.Prompt,
|
||||
sendTime: now,
|
||||
receiveTime: now
|
||||
promptType: messageType === 'join' ? PROMPT_TYPE.JOIN : PROMPT_TYPE.LEAVE
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
// Create new message (first message from this user)
|
||||
return messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: messageBody,
|
||||
type: ChatRoomMessageType.Prompt,
|
||||
sendTime: now,
|
||||
receiveTime: now
|
||||
})
|
||||
return [
|
||||
hlcClockDomain.command.SendEventCommand(),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
type: MESSAGE_TYPE.SYSTEM_PROMPT,
|
||||
id: nanoid(),
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: { id, name, avatar },
|
||||
body: messageBody,
|
||||
promptType: messageType === 'join' ? PROMPT_TYPE.JOIN : PROMPT_TYPE.LEAVE
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const { id, name, avatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
user: { peerId: chatRoomExtern.peerId, joinedAt: Date.now(), id, name, avatar }
|
||||
}),
|
||||
HandleJoinLeaveMessageCommand({ userId, username, userAvatar, messageType: 'join' }),
|
||||
HandleJoinLeaveMessageCommand({ id, name, avatar, messageType: PROMPT_TYPE.JOIN }),
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(chatRoomExtern.roomId),
|
||||
SelfJoinRoomEvent(chatRoomExtern.roomId)
|
||||
@@ -171,12 +179,12 @@ const ChatRoomDomain = Remesh.domain({
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const { id, name, avatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
HandleJoinLeaveMessageCommand({ userId, username, userAvatar, messageType: 'leave' }),
|
||||
HandleJoinLeaveMessageCommand({ id, name, avatar, messageType: PROMPT_TYPE.LEAVE }),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
user: { peerId: chatRoomExtern.peerId, joinedAt: Date.now(), id, name, avatar }
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(chatRoomExtern.roomId),
|
||||
@@ -192,25 +200,29 @@ const ChatRoomDomain = Remesh.domain({
|
||||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
name: 'Room.SendTextMessageCommand',
|
||||
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||
impl: ({ get }, message: string | { body: string; mentions: MentionedUser[] }) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const now = Date.now()
|
||||
const currentHLC = get(hlcClockDomain.query.CurrentHLCQuery())
|
||||
const newHLC = sendEvent(currentHLC)
|
||||
|
||||
const textMessage: ChatRoomTextMessage = {
|
||||
...self,
|
||||
const textMessage: TextMessage = {
|
||||
type: MESSAGE_TYPE.TEXT,
|
||||
id: nanoid(),
|
||||
type: ChatRoomSendType.Text,
|
||||
sendTime: Date.now(),
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
avatar: self.avatar
|
||||
},
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
...textMessage,
|
||||
type: ChatRoomMessageType.Normal,
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
mentions: typeof message === 'string' ? [] : message.mentions,
|
||||
reactions: {
|
||||
likes: [],
|
||||
hates: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,25 +240,52 @@ const ChatRoomDomain = Remesh.domain({
|
||||
// Only send to network if there are other peers, but always save to local
|
||||
peerIds.length && chatRoomExtern.sendMessage(textMessage, peerIds)
|
||||
|
||||
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
|
||||
return [
|
||||
hlcClockDomain.command.SendEventCommand(),
|
||||
messageListDomain.command.CreateItemCommand(textMessage),
|
||||
SendTextMessageEvent(textMessage)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'Room.SendLikeMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const SendReactionCommand = domain.command({
|
||||
name: 'Room.SendReactionCommand',
|
||||
impl: ({ get }, payload: { messageId: string; reaction: 'like' | 'hate' }) => {
|
||||
const { messageId, reaction } = payload
|
||||
const self = get(SelfUserQuery())
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
const now = Date.now()
|
||||
const currentHLC = get(hlcClockDomain.query.CurrentHLCQuery())
|
||||
const newHLC = sendEvent(currentHLC)
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as TextMessage
|
||||
|
||||
const likeMessage: ChatRoomLikeMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
sendTime: Date.now(),
|
||||
type: ChatRoomSendType.Like
|
||||
const reactionMessage: ReactionMessage = {
|
||||
type: MESSAGE_TYPE.REACTION,
|
||||
id: nanoid(),
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
avatar: self.avatar
|
||||
},
|
||||
targetId: messageId,
|
||||
reaction: reaction === REACTION_TYPE.LIKE ? REACTION_TYPE.LIKE : REACTION_TYPE.HATE
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
|
||||
const senderInfo = { id: self.id, name: self.name, avatar: self.avatar }
|
||||
const updatedMessage: TextMessage = {
|
||||
...localMessage,
|
||||
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
|
||||
reactions: {
|
||||
likes:
|
||||
reaction === REACTION_TYPE.LIKE
|
||||
? desert(localMessage.reactions.likes, senderInfo, 'id')
|
||||
: localMessage.reactions.likes,
|
||||
hates:
|
||||
reaction === REACTION_TYPE.HATE
|
||||
? desert(localMessage.reactions.hates, senderInfo, 'id')
|
||||
: localMessage.reactions.hates
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,39 +295,13 @@ const ChatRoomDomain = Remesh.domain({
|
||||
const peerIds = get(PeerListQuery())
|
||||
|
||||
// Only send to network if there are other peers, but always save to local
|
||||
peerIds.length && chatRoomExtern.sendMessage(likeMessage, peerIds)
|
||||
peerIds.length && chatRoomExtern.sendMessage(reactionMessage, peerIds)
|
||||
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendHateMessageCommand = domain.command({
|
||||
name: 'Room.SendHateMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const hateMessage: ChatRoomHateMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
sendTime: Date.now(),
|
||||
type: ChatRoomSendType.Hate
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all peerIds from UserList except self.
|
||||
* @see SendTextMessageCommand for detailed explanation.
|
||||
*/
|
||||
const peerIds = get(PeerListQuery())
|
||||
|
||||
// Only send to network if there are other peers, but always save to local
|
||||
peerIds.length && chatRoomExtern.sendMessage(hateMessage, peerIds)
|
||||
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
|
||||
return [
|
||||
hlcClockDomain.command.SendEventCommand(),
|
||||
messageListDomain.command.UpdateItemCommand(updatedMessage),
|
||||
SendReactionMessageEvent(reactionMessage)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -296,19 +309,29 @@ const ChatRoomDomain = Remesh.domain({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
const now = Date.now()
|
||||
const currentHLC = get(hlcClockDomain.query.CurrentHLCQuery())
|
||||
const newHLC = sendEvent(currentHLC)
|
||||
const lastMessageHLC = get(LastMessageHLCQuery())
|
||||
|
||||
const syncUserMessage: ChatRoomSyncUserMessage = {
|
||||
...self,
|
||||
const syncUserMessage: PeerSyncMessage = {
|
||||
type: MESSAGE_TYPE.PEER_SYNC,
|
||||
id: nanoid(),
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
avatar: self.avatar
|
||||
},
|
||||
peerId: chatRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
lastMessageTime,
|
||||
type: ChatRoomSendType.SyncUser
|
||||
joinedAt: self.joinedAt,
|
||||
lastMessageHLC
|
||||
}
|
||||
|
||||
chatRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
return [SendSyncUserMessageEvent(syncUserMessage)]
|
||||
return [hlcClockDomain.command.SendEventCommand(), SendSyncUserMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -333,28 +356,41 @@ const ChatRoomDomain = Remesh.domain({
|
||||
*/
|
||||
const SendSyncHistoryMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncHistoryMessageCommand',
|
||||
impl: ({ get }, { peerId, lastMessageTime }: { peerId: string; lastMessageTime: number }) => {
|
||||
impl: (
|
||||
{ get },
|
||||
{ peerId, lastMessageHLC }: { peerId: string; lastMessageHLC: { timestamp: number; counter: number } }
|
||||
) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const now = Date.now()
|
||||
|
||||
const historyMessages = get(messageListDomain.query.ListQuery()).filter((message) => {
|
||||
return (
|
||||
message.type === ChatRoomMessageType.Normal &&
|
||||
message.sendTime > lastMessageTime &&
|
||||
message.sendTime >= Date.now() - SYNC_HISTORY_MAX_DAYS * 24 * 60 * 60 * 1000
|
||||
message.type === MESSAGE_TYPE.TEXT &&
|
||||
compareHLC(message.hlc, lastMessageHLC) > 0 &&
|
||||
message.hlc.timestamp >= Date.now() - SYNC_HISTORY_MAX_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
})
|
||||
}) as TextMessage[]
|
||||
|
||||
/**
|
||||
* Message chunking to ensure that each message does not exceed WEB_RTC_MAX_MESSAGE_SIZE
|
||||
* If the message itself exceeds the size limit, skip syncing that message directly.
|
||||
*/
|
||||
const pushHistoryMessageList = historyMessages.reduce<ChatRoomSyncHistoryMessage[]>((acc, cur) => {
|
||||
const pushHistoryMessage: ChatRoomSyncHistoryMessage = {
|
||||
...self,
|
||||
const pushHistoryMessageList = historyMessages.reduce<HistorySyncMessage[]>((acc, cur) => {
|
||||
const currentHLC = get(hlcClockDomain.query.CurrentHLCQuery())
|
||||
const newHLC = sendEvent(currentHLC)
|
||||
|
||||
const pushHistoryMessage: HistorySyncMessage = {
|
||||
type: MESSAGE_TYPE.HISTORY_SYNC,
|
||||
id: nanoid(),
|
||||
sendTime: Date.now(),
|
||||
type: ChatRoomSendType.SyncHistory,
|
||||
messages: [cur as NormalMessage]
|
||||
hlc: newHLC,
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
avatar: self.avatar
|
||||
},
|
||||
messages: [cur]
|
||||
}
|
||||
const pushHistoryMessageByteSize = getTextByteSize(JSON.stringify(pushHistoryMessage))
|
||||
|
||||
@@ -362,7 +398,7 @@ const ChatRoomDomain = Remesh.domain({
|
||||
if (acc.length) {
|
||||
const mergedSize = getTextByteSize(JSON.stringify(acc[acc.length - 1])) + pushHistoryMessageByteSize
|
||||
if (mergedSize < WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
acc[acc.length - 1].messages.push(cur as NormalMessage)
|
||||
acc[acc.length - 1].messages.push(cur)
|
||||
} else {
|
||||
acc.push(pushHistoryMessage)
|
||||
}
|
||||
@@ -375,7 +411,7 @@ const ChatRoomDomain = Remesh.domain({
|
||||
|
||||
return pushHistoryMessageList.map((message) => {
|
||||
chatRoomExtern.sendMessage(message, peerId)
|
||||
return SendSyncHistoryMessageEvent(message)
|
||||
return [hlcClockDomain.command.SendEventCommand(), SendSyncHistoryMessageEvent(message)]
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -384,14 +420,14 @@ const ChatRoomDomain = Remesh.domain({
|
||||
name: 'Room.UpdateUserListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; user: Omit<RoomUser, 'peerIds'> & { peerId: string } }) => {
|
||||
const userList = get(UserListState())
|
||||
const existUser = userList.find((user) => user.userId === action.user.userId)
|
||||
const existUser = userList.find((user) => user.id === action.user.id)
|
||||
if (action.type === 'create') {
|
||||
return [
|
||||
UserListState().new(
|
||||
upsert(
|
||||
userList,
|
||||
{ ...action.user, peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId] },
|
||||
'userId'
|
||||
'id'
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -404,7 +440,7 @@ const ChatRoomDomain = Remesh.domain({
|
||||
...action.user,
|
||||
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || []
|
||||
},
|
||||
'userId'
|
||||
'id'
|
||||
).filter((user) => user.peerIds.length)
|
||||
)
|
||||
]
|
||||
@@ -412,24 +448,20 @@ const ChatRoomDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncHistoryMessageEvent = domain.event<ChatRoomSyncHistoryMessage>({
|
||||
const SendSyncHistoryMessageEvent = domain.event<HistorySyncMessage>({
|
||||
name: 'Room.SendSyncHistoryMessageEvent'
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<ChatRoomSyncUserMessage>({
|
||||
const SendSyncUserMessageEvent = domain.event<PeerSyncMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<ChatRoomTextMessage>({
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.SendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<ChatRoomLikeMessage>({
|
||||
name: 'Room.SendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<ChatRoomHateMessage>({
|
||||
name: 'Room.SendHateMessageEvent'
|
||||
const SendReactionMessageEvent = domain.event<ReactionMessage>({
|
||||
name: 'Room.SendReactionMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
@@ -440,32 +472,28 @@ const ChatRoomDomain = Remesh.domain({
|
||||
name: 'Room.LeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<ChatRoomMessage>({
|
||||
const OnMessageEvent = domain.event<NetworkMessage>({
|
||||
name: 'Room.OnMessageEvent'
|
||||
})
|
||||
|
||||
const OnTextMessageEvent = domain.event<ChatRoomTextMessage>({
|
||||
const OnTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'Room.OnTextMessageEvent'
|
||||
})
|
||||
|
||||
const OnSyncUserMessageEvent = domain.event<ChatRoomSyncUserMessage>({
|
||||
const OnSyncUserMessageEvent = domain.event<PeerSyncMessage>({
|
||||
name: 'Room.OnSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const OnSyncHistoryMessageEvent = domain.event<ChatRoomSyncHistoryMessage>({
|
||||
const OnSyncHistoryMessageEvent = domain.event<HistorySyncMessage>({
|
||||
name: 'Room.OnSyncHistoryMessageEvent'
|
||||
})
|
||||
|
||||
const OnSyncMessageEvent = domain.event<ChatRoomSyncHistoryMessage[]>({
|
||||
const OnSyncMessageEvent = domain.event<HistorySyncMessage[]>({
|
||||
name: 'Room.OnSyncMessageEvent'
|
||||
})
|
||||
|
||||
const OnLikeMessageEvent = domain.event<ChatRoomLikeMessage>({
|
||||
name: 'Room.OnLikeMessageEvent'
|
||||
})
|
||||
|
||||
const OnHateMessageEvent = domain.event<ChatRoomHateMessage>({
|
||||
name: 'Room.OnHateMessageEvent'
|
||||
const OnReactionMessageEvent = domain.event<ReactionMessage>({
|
||||
name: 'Room.OnReactionMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
@@ -508,10 +536,10 @@ const ChatRoomDomain = Remesh.domain({
|
||||
domain.effect({
|
||||
name: 'Room.OnMessageEffect',
|
||||
impl: () => {
|
||||
const onMessage$ = fromEventPattern<ChatRoomMessage>(chatRoomExtern.onMessage).pipe(
|
||||
const onMessage$ = fromEventPattern<NetworkMessage>(chatRoomExtern.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// Filter out messages that do not conform to the format
|
||||
if (!checkChatRoomMessage(message)) {
|
||||
if (!validateNetworkMessage(message)) {
|
||||
console.warn('Invalid message format', message)
|
||||
return EMPTY
|
||||
}
|
||||
@@ -521,16 +549,14 @@ const ChatRoomDomain = Remesh.domain({
|
||||
// Emit specific message type events
|
||||
const specificEvent$ = (() => {
|
||||
switch (message.type) {
|
||||
case ChatRoomSendType.Text:
|
||||
case MESSAGE_TYPE.TEXT:
|
||||
return of(OnTextMessageEvent(message))
|
||||
case ChatRoomSendType.SyncUser:
|
||||
case MESSAGE_TYPE.PEER_SYNC:
|
||||
return of(OnSyncUserMessageEvent(message))
|
||||
case ChatRoomSendType.SyncHistory:
|
||||
case MESSAGE_TYPE.HISTORY_SYNC:
|
||||
return of(OnSyncHistoryMessageEvent(message))
|
||||
case ChatRoomSendType.Like:
|
||||
return of(OnLikeMessageEvent(message))
|
||||
case ChatRoomSendType.Hate:
|
||||
return of(OnHateMessageEvent(message))
|
||||
case MESSAGE_TYPE.REACTION:
|
||||
return of(OnReactionMessageEvent(message))
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
@@ -549,13 +575,15 @@ const ChatRoomDomain = Remesh.domain({
|
||||
impl: ({ fromEvent }) => {
|
||||
return fromEvent(OnTextMessageEvent).pipe(
|
||||
map((message) => {
|
||||
return messageListDomain.command.CreateItemCommand({
|
||||
// Update local HLC based on received message
|
||||
const receivedMessage: TextMessage = {
|
||||
...message,
|
||||
type: ChatRoomMessageType.Normal,
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
receivedAt: Date.now()
|
||||
}
|
||||
return [
|
||||
hlcClockDomain.command.ReceiveEventCommand(message.hlc),
|
||||
messageListDomain.command.CreateItemCommand(receivedMessage)
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -569,26 +597,33 @@ const ChatRoomDomain = Remesh.domain({
|
||||
const selfUser = get(SelfUserQuery())
|
||||
|
||||
// If a new user joins after the current user has entered the room, a join log message needs to be created.
|
||||
const existUser = get(UserListQuery()).find((user) => user.userId === message.userId)
|
||||
const isNewJoinUser = !existUser && message.joinTime > selfUser.joinTime
|
||||
const existUser = get(UserListQuery()).find((user) => user.id === message.sender.id)
|
||||
const isNewJoinUser = !existUser && message.joinedAt > selfUser.joinedAt
|
||||
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
const needSyncHistory = lastMessageTime > message.lastMessageTime
|
||||
const lastMessageHLC = get(LastMessageHLCQuery())
|
||||
const needSyncHistory = compareHLC(lastMessageHLC, message.lastMessageHLC) > 0
|
||||
|
||||
const userForList = {
|
||||
...message.sender,
|
||||
peerId: message.peerId,
|
||||
joinedAt: message.joinedAt
|
||||
}
|
||||
|
||||
return of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
hlcClockDomain.command.ReceiveEventCommand(message.hlc),
|
||||
UpdateUserListCommand({ type: 'create', user: userForList }),
|
||||
isNewJoinUser
|
||||
? HandleJoinLeaveMessageCommand({
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar,
|
||||
messageType: 'join'
|
||||
id: message.sender.id,
|
||||
name: message.sender.name,
|
||||
avatar: message.sender.avatar,
|
||||
messageType: PROMPT_TYPE.JOIN
|
||||
})
|
||||
: null,
|
||||
needSyncHistory
|
||||
? SendSyncHistoryMessageCommand({
|
||||
peerId: message.peerId,
|
||||
lastMessageTime: message.lastMessageTime
|
||||
lastMessageHLC: message.lastMessageHLC
|
||||
})
|
||||
: null
|
||||
)
|
||||
@@ -609,7 +644,7 @@ const ChatRoomDomain = Remesh.domain({
|
||||
|
||||
// Deduplicate messages by id, keep the latest one
|
||||
const uniqueMessages = [
|
||||
...allMessages.reduce((map, msg) => map.set(msg.id, msg), new Map<string, NormalMessage>()).values()
|
||||
...allMessages.reduce((map, msg) => map.set(msg.id, msg), new Map<string, TextMessage>()).values()
|
||||
]
|
||||
|
||||
// Filter out messages that haven't changed
|
||||
@@ -622,9 +657,16 @@ const ChatRoomDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
// Update HLC for each received history message
|
||||
const maxHLC = uniqueMessages.reduce((max, msg) => (compareHLC(msg.hlc, max) > 0 ? msg.hlc : max), {
|
||||
timestamp: 0,
|
||||
counter: 0
|
||||
})
|
||||
|
||||
// Return batched upsert commands and single OnSyncMessageEvent for all sync messages
|
||||
return changedMessages.length
|
||||
? of(
|
||||
hlcClockDomain.command.ReceiveEventCommand(maxHLC),
|
||||
...changedMessages.map((message) => messageListDomain.command.UpsertItemCommand(message)),
|
||||
OnSyncMessageEvent(syncMessages)
|
||||
)
|
||||
@@ -635,57 +677,33 @@ const ChatRoomDomain = Remesh.domain({
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnLikeMessageEffect',
|
||||
name: 'Room.OnReactionMessageEffect',
|
||||
impl: ({ get, fromEvent }) => {
|
||||
return fromEvent(OnLikeMessageEvent).pipe(
|
||||
return fromEvent(OnReactionMessageEvent).pipe(
|
||||
mergeMap((message) => {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.targetId))) {
|
||||
return EMPTY
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: desert(
|
||||
_message.likeUsers,
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
const targetMessage = get(messageListDomain.query.ItemQuery(message.targetId)) as TextMessage
|
||||
|
||||
domain.effect({
|
||||
name: 'Room.OnHateMessageEffect',
|
||||
impl: ({ get, fromEvent }) => {
|
||||
return fromEvent(OnHateMessageEvent).pipe(
|
||||
mergeMap((message) => {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
return EMPTY
|
||||
const updatedMessage: TextMessage = {
|
||||
...targetMessage,
|
||||
receivedAt: Date.now(),
|
||||
reactions: {
|
||||
likes:
|
||||
message.reaction === REACTION_TYPE.LIKE
|
||||
? desert(targetMessage.reactions.likes, message.sender, 'id')
|
||||
: targetMessage.reactions.likes,
|
||||
hates:
|
||||
message.reaction === REACTION_TYPE.HATE
|
||||
? desert(targetMessage.reactions.hates, message.sender, 'id')
|
||||
: targetMessage.reactions.hates
|
||||
}
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
receiveTime: Date.now(),
|
||||
hateUsers: desert(
|
||||
_message.hateUsers,
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
})
|
||||
hlcClockDomain.command.ReceiveEventCommand(message.hlc),
|
||||
messageListDomain.command.UpdateItemCommand(updatedMessage)
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -709,10 +727,10 @@ const ChatRoomDomain = Remesh.domain({
|
||||
UpdateUserListCommand({ type: 'delete', user: { ...existUser, peerId } }),
|
||||
existUser.peerIds.length === 1
|
||||
? HandleJoinLeaveMessageCommand({
|
||||
userId: existUser.userId,
|
||||
username: existUser.username,
|
||||
userAvatar: existUser.userAvatar,
|
||||
messageType: 'leave'
|
||||
id: existUser.id,
|
||||
name: existUser.name,
|
||||
avatar: existUser.avatar,
|
||||
messageType: PROMPT_TYPE.LEAVE
|
||||
})
|
||||
: null,
|
||||
OnLeaveRoomEvent(peerId)
|
||||
@@ -744,21 +762,20 @@ const ChatRoomDomain = Remesh.domain({
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
PeerListQuery,
|
||||
JoinIsFinishedQuery
|
||||
JoinIsFinishedQuery,
|
||||
LastMessageHLCQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
SendReactionCommand,
|
||||
SendSyncUserMessageCommand,
|
||||
SendSyncHistoryMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendReactionMessageEvent,
|
||||
SendSyncUserMessageEvent,
|
||||
SendSyncHistoryMessageEvent,
|
||||
JoinRoomEvent,
|
||||
@@ -767,6 +784,7 @@ const ChatRoomDomain = Remesh.domain({
|
||||
SelfLeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnTextMessageEvent,
|
||||
OnReactionMessageEvent,
|
||||
OnSyncMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
|
||||
110
src/domain/HLCClock.ts
Normal file
110
src/domain/HLCClock.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Remesh } from 'remesh'
|
||||
import { createHLC, sendEvent as hlcSendEvent, receiveEvent as hlcReceiveEvent, type HLC } from '@/utils'
|
||||
|
||||
/**
|
||||
* HLCClock Domain
|
||||
*
|
||||
* Manages the local Hybrid Logical Clock state for the application.
|
||||
* This clock is used to:
|
||||
* - Generate timestamps for outgoing messages
|
||||
* - Update local time based on received messages
|
||||
* - Ensure causal ordering across distributed peers
|
||||
*/
|
||||
const HLCClockDomain = Remesh.domain({
|
||||
name: 'HLCClockDomain',
|
||||
impl: (domain) => {
|
||||
/**
|
||||
* Current HLC state
|
||||
*/
|
||||
const HLCState = domain.state<HLC>({
|
||||
name: 'HLCClock.HLCState',
|
||||
default: createHLC()
|
||||
})
|
||||
|
||||
/**
|
||||
* Query current HLC
|
||||
*/
|
||||
const CurrentHLCQuery = domain.query({
|
||||
name: 'HLCClock.CurrentHLCQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(HLCState())
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate new HLC for sending a message
|
||||
* This updates the local clock and returns the new value
|
||||
*/
|
||||
const SendEventCommand = domain.command({
|
||||
name: 'HLCClock.SendEventCommand',
|
||||
impl: ({ get }) => {
|
||||
const currentHLC = get(HLCState())
|
||||
const newHLC = hlcSendEvent(currentHLC)
|
||||
return [HLCState().new(newHLC), SendEventEvent(newHLC)]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update local HLC based on received message
|
||||
* Takes the remote HLC and merges it with local clock
|
||||
*/
|
||||
const ReceiveEventCommand = domain.command({
|
||||
name: 'HLCClock.ReceiveEventCommand',
|
||||
impl: ({ get }, remoteHLC: HLC) => {
|
||||
const currentHLC = get(HLCState())
|
||||
const newHLC = hlcReceiveEvent(currentHLC, remoteHLC)
|
||||
return [HLCState().new(newHLC), ReceiveEventEvent(newHLC)]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset HLC to initial state
|
||||
*/
|
||||
const ResetCommand = domain.command({
|
||||
name: 'HLCClock.ResetCommand',
|
||||
impl: () => {
|
||||
const initialHLC = createHLC()
|
||||
return [HLCState().new(initialHLC), ResetEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Event emitted when HLC is updated by send
|
||||
*/
|
||||
const SendEventEvent = domain.event<HLC>({
|
||||
name: 'HLCClock.SendEventEvent'
|
||||
})
|
||||
|
||||
/**
|
||||
* Event emitted when HLC is updated by receive
|
||||
*/
|
||||
const ReceiveEventEvent = domain.event<HLC>({
|
||||
name: 'HLCClock.ReceiveEventEvent'
|
||||
})
|
||||
|
||||
/**
|
||||
* Event emitted when HLC is reset
|
||||
*/
|
||||
const ResetEvent = domain.event({
|
||||
name: 'HLCClock.ResetEvent'
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
CurrentHLCQuery
|
||||
},
|
||||
command: {
|
||||
SendEventCommand,
|
||||
ReceiveEventCommand,
|
||||
ResetCommand
|
||||
},
|
||||
event: {
|
||||
SendEventEvent,
|
||||
ReceiveEventEvent,
|
||||
ResetEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default HLCClockDomain
|
||||
@@ -4,38 +4,16 @@ import { IndexDBStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
import StatusModule from './modules/Status'
|
||||
import { MESSAGE_LIST_STORAGE_KEY } from '@/constants/config'
|
||||
import type { ChatRoomMessageType } from '@/protocol'
|
||||
import type { LocalMessage } from '@/protocol/Message'
|
||||
|
||||
export interface MessageUser {
|
||||
userId: string
|
||||
username: string
|
||||
userAvatar: string
|
||||
}
|
||||
|
||||
export interface AtUser extends MessageUser {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
export interface NormalMessage extends MessageUser {
|
||||
type: ChatRoomMessageType.Normal
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
type: ChatRoomMessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
}
|
||||
|
||||
export type Message = NormalMessage | PromptMessage
|
||||
// Re-export types
|
||||
export type {
|
||||
LocalMessage as Message,
|
||||
TextMessage,
|
||||
SystemPromptMessage,
|
||||
MentionedUser,
|
||||
MessageUser
|
||||
} from '@/protocol/Message'
|
||||
|
||||
const MessageListDomain = Remesh.domain({
|
||||
name: 'MessageListDomain',
|
||||
@@ -46,7 +24,7 @@ const MessageListDomain = Remesh.domain({
|
||||
key: MESSAGE_LIST_STORAGE_KEY
|
||||
})
|
||||
|
||||
const MessageListModule = ListModule<Message>(domain, {
|
||||
const MessageListModule = ListModule<LocalMessage>(domain, {
|
||||
name: 'MessageListModule',
|
||||
key: (message) => message.id
|
||||
})
|
||||
@@ -70,13 +48,13 @@ const MessageListDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const CreateItemEvent = domain.event<Message>({
|
||||
const CreateItemEvent = domain.event<LocalMessage>({
|
||||
name: 'MessageList.CreateItemEvent'
|
||||
})
|
||||
|
||||
const CreateItemCommand = domain.command({
|
||||
name: 'MessageList.CreateItemCommand',
|
||||
impl: (_, message: Message) => {
|
||||
impl: (_, message: LocalMessage) => {
|
||||
return [
|
||||
MessageListModule.command.AddItemCommand(message),
|
||||
CreateItemEvent(message),
|
||||
@@ -86,13 +64,13 @@ const MessageListDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateItemEvent = domain.event<Message>({
|
||||
const UpdateItemEvent = domain.event<LocalMessage>({
|
||||
name: 'MessageList.UpdateItemEvent'
|
||||
})
|
||||
|
||||
const UpdateItemCommand = domain.command({
|
||||
name: 'MessageList.UpdateItemCommand',
|
||||
impl: (_, message: Message) => {
|
||||
impl: (_, message: LocalMessage) => {
|
||||
return [
|
||||
MessageListModule.command.UpdateItemCommand(message),
|
||||
UpdateItemEvent(message),
|
||||
@@ -120,7 +98,7 @@ const MessageListDomain = Remesh.domain({
|
||||
|
||||
const UpsertItemCommand = domain.command({
|
||||
name: 'MessageList.UpsertItemCommand',
|
||||
impl: (_, message: Message) => {
|
||||
impl: (_, message: LocalMessage) => {
|
||||
return [
|
||||
MessageListModule.command.UpsertItemCommand(message),
|
||||
UpsertItemEvent(message),
|
||||
@@ -130,13 +108,13 @@ const MessageListDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const UpsertItemEvent = domain.event<Message>({
|
||||
const UpsertItemEvent = domain.event<LocalMessage>({
|
||||
name: 'MessageList.UpsertItemEvent'
|
||||
})
|
||||
|
||||
const ResetListCommand = domain.command({
|
||||
name: 'MessageList.ResetListCommand',
|
||||
impl: (_, messages: Message[]) => {
|
||||
impl: (_, messages: LocalMessage[]) => {
|
||||
return [
|
||||
MessageListModule.command.SetListCommand(messages),
|
||||
ResetListEvent(messages),
|
||||
@@ -146,7 +124,7 @@ const MessageListDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const ResetListEvent = domain.event<Message[]>({
|
||||
const ResetListEvent = domain.event<LocalMessage[]>({
|
||||
name: 'MessageList.ResetListEvent'
|
||||
})
|
||||
|
||||
@@ -168,20 +146,20 @@ const MessageListDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStateEvent = domain.event<Message[]>({
|
||||
const SyncToStateEvent = domain.event<LocalMessage[]>({
|
||||
name: 'MessageList.SyncToStateEvent'
|
||||
})
|
||||
|
||||
const SyncToStateCommand = domain.command({
|
||||
name: 'MessageList.SyncToStateCommand',
|
||||
impl: (_, messages: Message[]) => {
|
||||
impl: (_, messages: LocalMessage[]) => {
|
||||
return [MessageListModule.command.SetListCommand(messages), SyncToStateEvent(messages)]
|
||||
}
|
||||
})
|
||||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
|
||||
.get<LocalMessage[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
|
||||
|
||||
return {
|
||||
query: {
|
||||
|
||||
@@ -79,7 +79,7 @@ const NotificationDomain = Remesh.domain({
|
||||
}
|
||||
|
||||
const userInfo = get(userInfoDomain.query.UserInfoQuery())
|
||||
if (message.userId === userInfo?.id) {
|
||||
if (message.sender.id === userInfo?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ const NotificationDomain = Remesh.domain({
|
||||
}
|
||||
|
||||
if (userInfo?.notificationType === 'at') {
|
||||
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
|
||||
const hasAtSelf = message.mentions.find((user) => user.id === userInfo?.id)
|
||||
if (hasAtSelf) {
|
||||
return PushCommand(message)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ const ToastDomain = Remesh.domain({
|
||||
name: 'Toast.OnSyncHistoryEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnSyncMessageEvent).pipe(
|
||||
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
|
||||
map((messages) =>
|
||||
toastModule.command.SuccessCommand(
|
||||
`Synced ${messages.length} history message${messages.length > 1 ? 's' : ''}.`
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return onSyncHistory$
|
||||
|
||||
@@ -8,16 +8,16 @@ import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import getSiteMeta from '@/utils/getSiteMeta'
|
||||
import {
|
||||
WorldRoomSendType,
|
||||
type WorldRoomMessage,
|
||||
type WorldRoomSyncUserMessage,
|
||||
type WorldRoomMessageFromInfo,
|
||||
type WorldRoomPeerSyncMessage,
|
||||
type WorldRoomSiteMeta,
|
||||
checkWorldRoomMessage
|
||||
} from '@/protocol'
|
||||
import { MESSAGE_TYPE } from '@/protocol/Message'
|
||||
|
||||
export type FromInfo = WorldRoomMessageFromInfo
|
||||
export type FromSite = WorldRoomSiteMeta & { peerId: string }
|
||||
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; fromInfos: FromInfo[]; joinTime: number }
|
||||
export type RoomUser = MessageUser & { peerIds: string[]; fromSites: FromSite[]; joinedAt: number }
|
||||
|
||||
const WorldRoomDomain = Remesh.domain({
|
||||
name: 'WorldRoomDomain',
|
||||
@@ -65,17 +65,17 @@ const WorldRoomDomain = Remesh.domain({
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'Room.JoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const { id, name, avatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: {
|
||||
peerId: worldRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteMeta(), peerId: worldRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
fromSite: { ...getSiteMeta(), peerId: worldRoomExtern.peerId },
|
||||
joinedAt: Date.now(),
|
||||
id,
|
||||
name,
|
||||
avatar
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -94,17 +94,17 @@ const WorldRoomDomain = Remesh.domain({
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'Room.LeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const { id, name, avatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: {
|
||||
peerId: worldRoomExtern.peerId,
|
||||
fromInfo: { ...getSiteMeta(), peerId: worldRoomExtern.peerId },
|
||||
joinTime: Date.now(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
fromSite: { ...getSiteMeta(), peerId: worldRoomExtern.peerId },
|
||||
joinedAt: Date.now(),
|
||||
id,
|
||||
name,
|
||||
avatar
|
||||
}
|
||||
}),
|
||||
JoinStatusModule.command.SetInitialCommand(),
|
||||
@@ -125,11 +125,11 @@ const WorldRoomDomain = Remesh.domain({
|
||||
{ get },
|
||||
action: {
|
||||
type: 'create' | 'delete'
|
||||
user: Omit<RoomUser, 'peerIds' | 'fromInfos'> & { peerId: string; fromInfo: FromInfo }
|
||||
user: Omit<RoomUser, 'peerIds' | 'fromSites'> & { peerId: string; fromSite: FromSite }
|
||||
}
|
||||
) => {
|
||||
const userList = get(UserListState())
|
||||
const existUser = userList.find((user) => user.userId === action.user.userId)
|
||||
const existUser = userList.find((user) => user.id === action.user.id)
|
||||
if (action.type === 'create') {
|
||||
return [
|
||||
UserListState().new(
|
||||
@@ -138,9 +138,9 @@ const WorldRoomDomain = Remesh.domain({
|
||||
{
|
||||
...action.user,
|
||||
peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId],
|
||||
fromInfos: upsert(existUser?.fromInfos || [], action.user.fromInfo, 'peerId')
|
||||
fromSites: upsert(existUser?.fromSites || [], action.user.fromSite, 'peerId')
|
||||
},
|
||||
'userId'
|
||||
'id'
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -152,9 +152,9 @@ const WorldRoomDomain = Remesh.domain({
|
||||
{
|
||||
...action.user,
|
||||
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || [],
|
||||
fromInfos: existUser?.fromInfos?.filter((fromInfo) => fromInfo.peerId !== action.user.peerId) || []
|
||||
fromSites: existUser?.fromSites?.filter((fromSite) => fromSite.peerId !== action.user.peerId) || []
|
||||
},
|
||||
'userId'
|
||||
'id'
|
||||
).filter((user) => user.peerIds.length)
|
||||
)
|
||||
]
|
||||
@@ -166,14 +166,22 @@ const WorldRoomDomain = Remesh.domain({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const now = Date.now()
|
||||
|
||||
const syncUserMessage: WorldRoomSyncUserMessage = {
|
||||
...self,
|
||||
const syncUserMessage: WorldRoomPeerSyncMessage = {
|
||||
type: MESSAGE_TYPE.PEER_SYNC,
|
||||
id: nanoid(),
|
||||
hlc: { timestamp: now, counter: 0 },
|
||||
sentAt: now,
|
||||
receivedAt: now,
|
||||
sender: {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
avatar: self.avatar
|
||||
},
|
||||
peerId: worldRoomExtern.peerId,
|
||||
sendTime: Date.now(),
|
||||
fromInfo: { ...getSiteMeta(), peerId: worldRoomExtern.peerId },
|
||||
type: WorldRoomSendType.SyncUser
|
||||
joinedAt: self.joinedAt,
|
||||
siteMeta: getSiteMeta()
|
||||
}
|
||||
|
||||
worldRoomExtern.sendMessage(syncUserMessage, peerId)
|
||||
@@ -181,7 +189,7 @@ const WorldRoomDomain = Remesh.domain({
|
||||
}
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<WorldRoomSyncUserMessage>({
|
||||
const SendSyncUserMessageEvent = domain.event<WorldRoomPeerSyncMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
@@ -249,8 +257,18 @@ const WorldRoomDomain = Remesh.domain({
|
||||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case WorldRoomSendType.SyncUser: {
|
||||
return of(UpdateUserListCommand({ type: 'create', user: message }))
|
||||
case MESSAGE_TYPE.PEER_SYNC: {
|
||||
return of(
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: {
|
||||
...message.sender,
|
||||
peerId: message.peerId,
|
||||
fromSite: { ...message.siteMeta, peerId: message.peerId },
|
||||
joinedAt: message.joinedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -282,7 +300,7 @@ const WorldRoomDomain = Remesh.domain({
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { ...existUser, peerId, fromInfo: { ...getSiteMeta(), peerId } }
|
||||
user: { ...existUser, peerId, fromSite: { ...getSiteMeta(), peerId } }
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
|
||||
@@ -1,94 +1,43 @@
|
||||
import * as v from 'valibot'
|
||||
import { MESSAGE_TYPE, REACTION_TYPE, HLCSchema, MessageMetaSchema, TextMessageSchema } from './Message'
|
||||
|
||||
// ChatRoom MessageType
|
||||
export enum ChatRoomMessageType {
|
||||
Normal = 'normal',
|
||||
Prompt = 'prompt'
|
||||
}
|
||||
|
||||
// ChatRoom SendType
|
||||
export enum ChatRoomSendType {
|
||||
Text = 'Text',
|
||||
Like = 'Like',
|
||||
Hate = 'Hate',
|
||||
SyncUser = 'SyncUser',
|
||||
SyncHistory = 'SyncHistory'
|
||||
}
|
||||
|
||||
// ChatRoom Message Schemas
|
||||
const ChatRoomMessageUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string()
|
||||
}
|
||||
|
||||
const ChatRoomMessageAtUserSchema = {
|
||||
positions: v.array(v.tuple([v.number(), v.number()])),
|
||||
...ChatRoomMessageUserSchema
|
||||
}
|
||||
|
||||
export const ChatRoomNormalMessageSchema = {
|
||||
id: v.string(),
|
||||
type: v.literal(ChatRoomMessageType.Normal),
|
||||
body: v.string(),
|
||||
sendTime: v.number(),
|
||||
receiveTime: v.number(),
|
||||
likeUsers: v.array(v.object(ChatRoomMessageUserSchema)),
|
||||
hateUsers: v.array(v.object(ChatRoomMessageUserSchema)),
|
||||
atUsers: v.array(v.object(ChatRoomMessageAtUserSchema)),
|
||||
...ChatRoomMessageUserSchema
|
||||
}
|
||||
|
||||
// ChatRoom Message Schema
|
||||
// ChatRoom-specific message schemas
|
||||
export const ChatRoomMessageSchema = v.union([
|
||||
// Text Message (reuse from Message.ts)
|
||||
TextMessageSchema,
|
||||
|
||||
// Reaction Message
|
||||
v.object({
|
||||
type: v.literal(ChatRoomSendType.Text),
|
||||
id: v.string(),
|
||||
body: v.string(),
|
||||
sendTime: v.number(),
|
||||
atUsers: v.array(v.object(ChatRoomMessageAtUserSchema)),
|
||||
...ChatRoomMessageUserSchema
|
||||
...MessageMetaSchema.entries,
|
||||
type: v.literal(MESSAGE_TYPE.REACTION),
|
||||
targetId: v.string(),
|
||||
reaction: v.union([v.literal(REACTION_TYPE.LIKE), v.literal(REACTION_TYPE.HATE)])
|
||||
}),
|
||||
|
||||
// Peer Sync Message
|
||||
v.object({
|
||||
type: v.literal(ChatRoomSendType.Like),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
...ChatRoomMessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(ChatRoomSendType.Hate),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
...ChatRoomMessageUserSchema
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal(ChatRoomSendType.SyncUser),
|
||||
id: v.string(),
|
||||
...MessageMetaSchema.entries,
|
||||
type: v.literal(MESSAGE_TYPE.PEER_SYNC),
|
||||
peerId: v.string(),
|
||||
joinTime: v.number(),
|
||||
sendTime: v.number(),
|
||||
lastMessageTime: v.number(),
|
||||
...ChatRoomMessageUserSchema
|
||||
joinedAt: v.number(),
|
||||
lastMessageHLC: HLCSchema
|
||||
}),
|
||||
|
||||
// History Sync Message
|
||||
v.object({
|
||||
type: v.literal(ChatRoomSendType.SyncHistory),
|
||||
id: v.string(),
|
||||
sendTime: v.number(),
|
||||
messages: v.array(v.object(ChatRoomNormalMessageSchema)),
|
||||
...ChatRoomMessageUserSchema
|
||||
...MessageMetaSchema.entries,
|
||||
type: v.literal(MESSAGE_TYPE.HISTORY_SYNC),
|
||||
messages: v.array(TextMessageSchema)
|
||||
})
|
||||
])
|
||||
|
||||
// ChatRoom Types
|
||||
export type ChatRoomMessageUser = v.InferOutput<v.ObjectSchema<typeof ChatRoomMessageUserSchema, undefined>>
|
||||
export type ChatRoomMessageAtUser = v.InferOutput<v.ObjectSchema<typeof ChatRoomMessageAtUserSchema, undefined>>
|
||||
export type ChatRoomNormalMessage = v.InferOutput<v.ObjectSchema<typeof ChatRoomNormalMessageSchema, undefined>>
|
||||
export type ChatRoomTextMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[0]>
|
||||
export type ChatRoomLikeMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[1]>
|
||||
export type ChatRoomHateMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[2]>
|
||||
export type ChatRoomSyncUserMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[3]>
|
||||
export type ChatRoomSyncHistoryMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[4]>
|
||||
export type ChatRoomMessage = v.InferInput<typeof ChatRoomMessageSchema>
|
||||
export type ChatRoomTextMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[0]>
|
||||
export type ChatRoomReactionMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[1]>
|
||||
export type ChatRoomPeerSyncMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[2]>
|
||||
export type ChatRoomHistorySyncMessage = v.InferOutput<(typeof ChatRoomMessageSchema.options)[3]>
|
||||
|
||||
// Check if the message conforms to the format
|
||||
export const checkChatRoomMessage = (message: ChatRoomMessage) => v.safeParse(ChatRoomMessageSchema, message).success
|
||||
export const checkChatRoomMessage = (message: unknown): message is ChatRoomMessage =>
|
||||
v.safeParse(ChatRoomMessageSchema, message).success
|
||||
|
||||
259
src/protocol/Message.ts
Normal file
259
src/protocol/Message.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import * as v from 'valibot'
|
||||
|
||||
// ============ Message Type Constants ============
|
||||
|
||||
/**
|
||||
* Message type constants for type safety and consistency
|
||||
*/
|
||||
export const MESSAGE_TYPE = {
|
||||
TEXT: 'text',
|
||||
REACTION: 'reaction',
|
||||
PEER_SYNC: 'peer-sync',
|
||||
HISTORY_SYNC: 'history-sync',
|
||||
SYSTEM_PROMPT: 'system-prompt'
|
||||
} as const
|
||||
|
||||
export type MessageType = (typeof MESSAGE_TYPE)[keyof typeof MESSAGE_TYPE]
|
||||
|
||||
/**
|
||||
* Reaction type constants
|
||||
*/
|
||||
export const REACTION_TYPE = {
|
||||
LIKE: 'like',
|
||||
HATE: 'hate'
|
||||
} as const
|
||||
|
||||
export type ReactionType = (typeof REACTION_TYPE)[keyof typeof REACTION_TYPE]
|
||||
|
||||
/**
|
||||
* System prompt type constants
|
||||
*/
|
||||
export const PROMPT_TYPE = {
|
||||
JOIN: 'join',
|
||||
LEAVE: 'leave',
|
||||
INFO: 'info'
|
||||
} as const
|
||||
|
||||
export type PromptType = (typeof PROMPT_TYPE)[keyof typeof PROMPT_TYPE]
|
||||
|
||||
// ============ Base Types ============
|
||||
|
||||
/**
|
||||
* Hybrid Logical Clock
|
||||
* Provides causal ordering in distributed systems
|
||||
*/
|
||||
export interface HLC {
|
||||
timestamp: number // Physical time in milliseconds
|
||||
counter: number // Logical counter for same timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* User information
|
||||
* Can represent sender, receiver, or mentioned user
|
||||
*/
|
||||
export interface MessageUser {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mentioned user with position information
|
||||
*/
|
||||
export interface MentionedUser extends MessageUser {
|
||||
positions: [number, number][] // Position ranges in message body
|
||||
}
|
||||
|
||||
/**
|
||||
* Base metadata for all messages
|
||||
*/
|
||||
export interface MessageMetadata {
|
||||
id: string // Unique message identifier
|
||||
hlc: HLC // Hybrid Logical Clock for ordering and sync
|
||||
sentAt: number // Sender's local physical time (for display)
|
||||
receivedAt: number // Receiver's local physical time (for local record)
|
||||
sender: MessageUser // Sender information
|
||||
}
|
||||
|
||||
// ============ Message Types ============
|
||||
|
||||
/**
|
||||
* Text message (used for both network transmission and local storage)
|
||||
*/
|
||||
export interface TextMessage extends MessageMetadata {
|
||||
type: typeof MESSAGE_TYPE.TEXT
|
||||
body: string
|
||||
mentions: MentionedUser[]
|
||||
reactions: {
|
||||
likes: MessageUser[] // Users who liked this message
|
||||
hates: MessageUser[] // Users who disliked this message
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reaction message (network transmission only, not stored)
|
||||
* Used to add/remove like/hate on a text message
|
||||
*/
|
||||
export interface ReactionMessage extends MessageMetadata {
|
||||
type: typeof MESSAGE_TYPE.REACTION
|
||||
targetId: string // Target message ID
|
||||
reaction: ReactionType
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer sync message (network transmission only, not stored)
|
||||
* Exchanged when peers connect to sync user info
|
||||
*/
|
||||
export interface PeerSyncMessage extends MessageMetadata {
|
||||
type: typeof MESSAGE_TYPE.PEER_SYNC
|
||||
peerId: string
|
||||
joinedAt: number // Timestamp when user joined the room
|
||||
lastMessageHLC: HLC // Last message HLC for history sync decision
|
||||
}
|
||||
|
||||
/**
|
||||
* History sync message (network transmission only, not stored)
|
||||
* Used to sync historical messages to newly joined peers
|
||||
*/
|
||||
export interface HistorySyncMessage extends MessageMetadata {
|
||||
type: typeof MESSAGE_TYPE.HISTORY_SYNC
|
||||
messages: TextMessage[] // Historical text messages
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt message (local storage only, not transmitted)
|
||||
* Used for system notifications like user join/leave
|
||||
*/
|
||||
export interface SystemPromptMessage extends MessageMetadata {
|
||||
type: typeof MESSAGE_TYPE.SYSTEM_PROMPT
|
||||
body: string
|
||||
promptType: PromptType
|
||||
}
|
||||
|
||||
/**
|
||||
* Network message union type
|
||||
* Messages that can be transmitted over the network
|
||||
*/
|
||||
export type NetworkMessage = TextMessage | ReactionMessage | PeerSyncMessage | HistorySyncMessage
|
||||
|
||||
/**
|
||||
* Local message union type
|
||||
* Messages that are stored locally
|
||||
*/
|
||||
export type LocalMessage = TextMessage | SystemPromptMessage
|
||||
|
||||
// ============ Valibot Schemas ============
|
||||
|
||||
export const HLCSchema = v.object({
|
||||
timestamp: v.number(),
|
||||
counter: v.number()
|
||||
})
|
||||
|
||||
export const MessageUserSchema = v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
avatar: v.string()
|
||||
})
|
||||
|
||||
export const MentionedUserSchema = v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
avatar: v.string(),
|
||||
positions: v.array(v.tuple([v.number(), v.number()]))
|
||||
})
|
||||
|
||||
export const MessageMetaSchema = v.object({
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema
|
||||
})
|
||||
|
||||
// ============ Network Message Schemas ============
|
||||
|
||||
export const TextMessageSchema = v.object({
|
||||
type: v.literal(MESSAGE_TYPE.TEXT),
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema,
|
||||
body: v.string(),
|
||||
mentions: v.array(MentionedUserSchema),
|
||||
reactions: v.object({
|
||||
likes: v.array(MessageUserSchema),
|
||||
hates: v.array(MessageUserSchema)
|
||||
})
|
||||
})
|
||||
|
||||
export const ReactionMessageSchema = v.object({
|
||||
type: v.literal(MESSAGE_TYPE.REACTION),
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema,
|
||||
targetId: v.string(),
|
||||
reaction: v.union([v.literal(REACTION_TYPE.LIKE), v.literal(REACTION_TYPE.HATE)])
|
||||
})
|
||||
|
||||
export const PeerSyncMessageSchema = v.object({
|
||||
type: v.literal(MESSAGE_TYPE.PEER_SYNC),
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema,
|
||||
peerId: v.string(),
|
||||
joinedAt: v.number(),
|
||||
lastMessageHLC: HLCSchema
|
||||
})
|
||||
|
||||
export const HistorySyncMessageSchema = v.object({
|
||||
type: v.literal(MESSAGE_TYPE.HISTORY_SYNC),
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema,
|
||||
messages: v.array(TextMessageSchema)
|
||||
})
|
||||
|
||||
export const NetworkMessageSchema = v.union([
|
||||
TextMessageSchema,
|
||||
ReactionMessageSchema,
|
||||
PeerSyncMessageSchema,
|
||||
HistorySyncMessageSchema
|
||||
])
|
||||
|
||||
// ============ Stored Message Schemas ============
|
||||
|
||||
export const SystemPromptMessageSchema = v.object({
|
||||
type: v.literal(MESSAGE_TYPE.SYSTEM_PROMPT),
|
||||
id: v.string(),
|
||||
hlc: HLCSchema,
|
||||
sentAt: v.number(),
|
||||
receivedAt: v.number(),
|
||||
sender: MessageUserSchema,
|
||||
body: v.string(),
|
||||
promptType: v.union([v.literal(PROMPT_TYPE.JOIN), v.literal(PROMPT_TYPE.LEAVE), v.literal(PROMPT_TYPE.INFO)])
|
||||
})
|
||||
|
||||
export const LocalMessageSchema = v.union([TextMessageSchema, SystemPromptMessageSchema])
|
||||
|
||||
// ============ Utility Functions ============
|
||||
|
||||
/**
|
||||
* Validate network message format
|
||||
*/
|
||||
export const validateNetworkMessage = (message: unknown): message is NetworkMessage => {
|
||||
return v.safeParse(NetworkMessageSchema, message).success
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate local message format
|
||||
*/
|
||||
export const validateLocalMessage = (message: unknown): message is LocalMessage => {
|
||||
return v.safeParse(LocalMessageSchema, message).success
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import * as v from 'valibot'
|
||||
import { PeerSyncMessageSchema } from './Message'
|
||||
|
||||
// WorldRoom SendType
|
||||
export enum WorldRoomSendType {
|
||||
SyncUser = 'SyncUser'
|
||||
}
|
||||
|
||||
// WorldRoom Message Schemas
|
||||
const WorldRoomMessageUserSchema = {
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
userAvatar: v.string()
|
||||
}
|
||||
|
||||
const WorldRoomMessageFromInfoSchema = {
|
||||
peerId: v.string(),
|
||||
// Site metadata schema
|
||||
const SiteMetaSchema = v.object({
|
||||
host: v.string(),
|
||||
hostname: v.string(),
|
||||
href: v.string(),
|
||||
@@ -21,26 +10,23 @@ const WorldRoomMessageFromInfoSchema = {
|
||||
title: v.string(),
|
||||
icon: v.string(),
|
||||
description: v.string()
|
||||
}
|
||||
})
|
||||
|
||||
// WorldRoom Message Schema
|
||||
// Extends PeerSyncMessageSchema with siteMeta field, but omits lastMessageHLC
|
||||
// WorldRoom only handles user discovery, not message history sync
|
||||
export const WorldRoomMessageSchema = v.union([
|
||||
v.object({
|
||||
type: v.literal(WorldRoomSendType.SyncUser),
|
||||
id: v.string(),
|
||||
peerId: v.string(),
|
||||
joinTime: v.number(),
|
||||
sendTime: v.number(),
|
||||
fromInfo: v.object(WorldRoomMessageFromInfoSchema),
|
||||
...WorldRoomMessageUserSchema
|
||||
...v.omit(PeerSyncMessageSchema, ['lastMessageHLC']).entries,
|
||||
siteMeta: SiteMetaSchema
|
||||
})
|
||||
])
|
||||
|
||||
// WorldRoom Types
|
||||
export type WorldRoomMessageUser = v.InferOutput<v.ObjectSchema<typeof WorldRoomMessageUserSchema, undefined>>
|
||||
export type WorldRoomMessageFromInfo = v.InferOutput<v.ObjectSchema<typeof WorldRoomMessageFromInfoSchema, undefined>>
|
||||
export type WorldRoomSyncUserMessage = v.InferOutput<(typeof WorldRoomMessageSchema.options)[0]>
|
||||
export type WorldRoomSiteMeta = v.InferOutput<typeof SiteMetaSchema>
|
||||
export type WorldRoomPeerSyncMessage = v.InferOutput<(typeof WorldRoomMessageSchema.options)[0]>
|
||||
export type WorldRoomMessage = v.InferInput<typeof WorldRoomMessageSchema>
|
||||
|
||||
// Check if the message conforms to the format
|
||||
export const checkWorldRoomMessage = (message: WorldRoomMessage) => v.safeParse(WorldRoomMessageSchema, message).success
|
||||
export const checkWorldRoomMessage = (message: unknown): message is WorldRoomMessage =>
|
||||
v.safeParse(WorldRoomMessageSchema, message).success
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './Message'
|
||||
export * from './ChatRoom'
|
||||
export * from './WorldRoom'
|
||||
|
||||
@@ -47,8 +47,8 @@ export class Notification implements NotificationExternType {
|
||||
|
||||
const id = await browser.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: message.userAvatar,
|
||||
title: message.username,
|
||||
iconUrl: message.sender.avatar,
|
||||
title: message.sender.name,
|
||||
message: message.body,
|
||||
contextMessage: messageTab?.url
|
||||
})
|
||||
|
||||
109
src/utils/hlc.ts
Normal file
109
src/utils/hlc.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Hybrid Logical Clock (HLC) utilities
|
||||
*
|
||||
* HLC combines physical time with logical counters to provide:
|
||||
* - Causal ordering in distributed systems
|
||||
* - Tolerance to clock skew
|
||||
* - Timestamps close to physical time
|
||||
*
|
||||
* @see https://www.cse.buffalo.edu/tech-reports/2014-04.pdf
|
||||
*/
|
||||
|
||||
export interface HLC {
|
||||
timestamp: number // Physical time in milliseconds
|
||||
counter: number // Logical counter for same timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two HLCs
|
||||
* @returns negative if a < b, 0 if a == b, positive if a > b
|
||||
*/
|
||||
export const compareHLC = (a: HLC, b: HLC): number => {
|
||||
if (a.timestamp !== b.timestamp) {
|
||||
return a.timestamp - b.timestamp
|
||||
}
|
||||
return a.counter - b.counter
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial HLC with epoch time
|
||||
*/
|
||||
export const createHLC = (): HLC => {
|
||||
return { timestamp: 0, counter: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local HLC for a send event
|
||||
*
|
||||
* Rules:
|
||||
* - If physical time advanced: use new time, reset counter
|
||||
* - If physical time unchanged: increment counter
|
||||
*/
|
||||
export const sendEvent = (localHLC: HLC): HLC => {
|
||||
const now = Date.now()
|
||||
|
||||
if (now > localHLC.timestamp) {
|
||||
// Physical clock advanced, reset counter
|
||||
return { timestamp: now, counter: 0 }
|
||||
} else {
|
||||
// Physical clock unchanged, increment counter
|
||||
return { timestamp: localHLC.timestamp, counter: localHLC.counter + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local HLC for a receive event
|
||||
*
|
||||
* Rules:
|
||||
* - Take max of (local time, local HLC, remote HLC)
|
||||
* - If max timestamp appears in HLCs, increment counter
|
||||
* - Otherwise reset counter
|
||||
*/
|
||||
export const receiveEvent = (localHLC: HLC, remoteHLC: HLC): HLC => {
|
||||
const now = Date.now()
|
||||
const maxTimestamp = Math.max(now, localHLC.timestamp, remoteHLC.timestamp)
|
||||
|
||||
if (maxTimestamp === now && now > localHLC.timestamp && now > remoteHLC.timestamp) {
|
||||
// Local physical time is newest
|
||||
return { timestamp: now, counter: 0 }
|
||||
}
|
||||
|
||||
// Find max counter among HLCs with max timestamp
|
||||
let maxCounter = 0
|
||||
if (maxTimestamp === localHLC.timestamp) {
|
||||
maxCounter = Math.max(maxCounter, localHLC.counter)
|
||||
}
|
||||
if (maxTimestamp === remoteHLC.timestamp) {
|
||||
maxCounter = Math.max(maxCounter, remoteHLC.counter)
|
||||
}
|
||||
|
||||
return { timestamp: maxTimestamp, counter: maxCounter + 1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HLC is within a time window
|
||||
*/
|
||||
export const isWithinTimeWindow = (hlc: HLC, windowMs: number): boolean => {
|
||||
return hlc.timestamp >= Date.now() - windowMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Format HLC as readable string for debugging
|
||||
*/
|
||||
export const formatHLC = (hlc: HLC): string => {
|
||||
return `${new Date(hlc.timestamp).toISOString()}:${hlc.counter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HLC is valid (non-negative values)
|
||||
*/
|
||||
export const isValidHLC = (hlc: HLC): boolean => {
|
||||
return hlc.timestamp >= 0 && hlc.counter >= 0 && Number.isInteger(hlc.counter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone HLC (defensive copy)
|
||||
*/
|
||||
export const cloneHLC = (hlc: HLC): HLC => {
|
||||
return { timestamp: hlc.timestamp, counter: hlc.counter }
|
||||
}
|
||||
@@ -18,5 +18,15 @@ export * as JSONR from './jsonr'
|
||||
export { getTextByteSize } from './getTextByteSize'
|
||||
export { default as isEqual } from './isEqual'
|
||||
export { default as setIntervalImmediate } from './setIntervalImmediate'
|
||||
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'
|
||||
export { safeUrl } from './safeUrl'
|
||||
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL, safeUrl } from './url'
|
||||
export {
|
||||
type HLC,
|
||||
compareHLC,
|
||||
createHLC,
|
||||
sendEvent,
|
||||
receiveEvent,
|
||||
isWithinTimeWindow,
|
||||
formatHLC,
|
||||
isValidHLC,
|
||||
cloneHLC
|
||||
} from './hlc'
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Sanitize URL to prevent XSS attacks
|
||||
* @param url - The URL to sanitize
|
||||
* @returns Sanitized URL or empty string if invalid
|
||||
*/
|
||||
export const safeUrl = (url: string): string => {
|
||||
if (!url || typeof url !== 'string' || !URL.canParse(url)) return ''
|
||||
|
||||
// Only allow media data URIs (image/video/audio)
|
||||
if (url.startsWith('data:')) return /^data:(image|video|audio)\//i.test(url) ? url : ''
|
||||
|
||||
// Block dangerous protocols
|
||||
if (/^(javascript|vbscript|file|about):/i.test(url)) return ''
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -34,3 +34,20 @@ export const buildFullURL = (baseURL: string = '', pathURL: string = '', params:
|
||||
const url = cleanURL(isAbsoluteURL(pathURL) ? pathURL : `${baseURL}/${pathURL}`)
|
||||
return assembleURL(url, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize URL to prevent XSS attacks
|
||||
* @param url - The URL to sanitize
|
||||
* @returns Sanitized URL or empty string if invalid
|
||||
*/
|
||||
export const safeUrl = (url: string): string => {
|
||||
if (!url || typeof url !== 'string') return ''
|
||||
|
||||
// Block dangerous protocols
|
||||
if (/^(javascript|vbscript|file|about):/i.test(url)) return ''
|
||||
|
||||
// Only allow media data URIs (image/video/audio)
|
||||
if (url.startsWith('data:') && !/^data:(image|video|audio)\//i.test(url)) return ''
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user