Merge branch 'develop'

This commit is contained in:
molvqingtai 2024-11-03 08:59:11 +08:00
commit 75b52d4003
15 changed files with 649 additions and 363 deletions

View file

@ -45,13 +45,13 @@
"homepage": "https://github.com/molvqingtai/WebChat", "homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@lottiefiles/dotlottie-react": "^0.9.2", "@lottiefiles/dotlottie-react": "^0.9.3",
"@perfsee/jsonr": "^1.13.0", "@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.2", "@radix-ui/react-portal": "^1.1.2",
@ -70,7 +70,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"danmu": "^0.14.0", "danmu": "^0.14.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^11.11.10", "framer-motion": "^11.11.11",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"nanoid": "^5.0.8", "nanoid": "^5.0.8",
@ -86,7 +86,7 @@
"remesh-logger": "^4.1.0", "remesh-logger": "^4.1.0",
"remesh-react": "^4.1.2", "remesh-react": "^4.1.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sonner": "^1.5.0", "sonner": "^1.6.1",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"trystero": "^0.20.0", "trystero": "^0.20.0",
"type-fest": "^4.26.1", "type-fest": "^4.26.1",
@ -97,27 +97,27 @@
"@commitlint/cli": "^19.5.0", "@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.15.2", "@eslint-react/eslint-plugin": "^1.15.2",
"@eslint/js": "^9.13.0", "@eslint/js": "^9.14.0",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3", "@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3", "@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.8.2", "@types/node": "^22.8.6",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.12.1", "@typescript-eslint/parser": "^8.12.2",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.13.0", "eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.5", "eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.11.0", "globals": "^15.11.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"jiti": "^2.3.3", "jiti": "^2.4.0",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.47", "postcss": "^8.4.47",
@ -128,8 +128,8 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.12.1", "typescript-eslint": "^8.12.2",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.3.0",
"webext-bridge": "^6.0.1", "webext-bridge": "^6.0.1",
"wxt": "^0.19.13" "wxt": "^0.19.13"
}, },

File diff suppressed because it is too large Load diff

View file

@ -58,7 +58,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none"> <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> <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.date}></FormatDate> <FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
</div> </div>
<div> <div>
<div className="pb-2"> <div className="pb-2">

View file

@ -56,13 +56,13 @@ export default defineContentScript({
container.append(app) container.append(app)
const root = createRoot(app) const root = createRoot(app)
root.render( root.render(
<React.StrictMode> // <React.StrictMode>
<RemeshRoot store={store}> <RemeshRoot store={store}>
<RemeshScope domains={[NotificationDomain()]}> <RemeshScope domains={[NotificationDomain()]}>
<App /> <App />
</RemeshScope> </RemeshScope>
</RemeshRoot> </RemeshRoot>
</React.StrictMode> // </React.StrictMode>
) )
return root return root
}, },

View file

@ -5,7 +5,7 @@ import MessageInput from '../../components/MessageInput'
import EmojiButton from '../../components/EmojiButton' import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput' import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH } from '@/constants/config' import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import RoomDomain from '@/domain/Room' import RoomDomain from '@/domain/Room'
import useCursorPosition from '@/hooks/useCursorPosition' import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef' import useShareRef from '@/hooks/useShareRef'
@ -15,7 +15,7 @@ import useTriggerAway from '@/hooks/useTriggerAway'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import { blobToBase64, cn, compressImage, getRootNode, getTextSimilarity } from '@/utils' import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar' import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { AvatarImage } from '@radix-ui/react-avatar' import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast' import ToastDomain from '@/domain/Toast'
@ -136,6 +136,13 @@ const Footer: FC = () => {
}) })
.filter(Boolean) .filter(Boolean)
const newMessage = { body: transformedMessage, atUsers }
const byteSize = getTextByteSize(JSON.stringify(newMessage))
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
}
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers })) send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand()) send(messageInputDomain.command.ClearCommand())
} }

View file

@ -15,16 +15,18 @@ const Main: FC = () => {
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery()) const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageList = _messageList.map((message) => { const messageList = _messageList
if (message.type === MessageType.Normal) { .map((message) => {
return { if (message.type === MessageType.Normal) {
...message, return {
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id), ...message,
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id) like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}
} }
} return message
return message })
}) .toSorted((a, b) => a.sendTime - b.sendTime)
const handleLikeChange = (messageId: string) => { const handleLikeChange = (messageId: string) => {
send(roomDomain.command.SendLikeMessageCommand(messageId)) send(roomDomain.command.SendLikeMessageCommand(messageId))

View file

@ -33,8 +33,6 @@ const mockTextList = [
`![ExampleImage](${ExampleImage})` `![ExampleImage](${ExampleImage})`
] ]
let printTextList = [...mockTextList]
const generateUserInfo = async (): Promise<UserInfo> => { const generateUserInfo = async (): Promise<UserInfo> => {
return { return {
id: nanoid(), id: nanoid(),
@ -52,8 +50,9 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
const { name: username, avatar: userAvatar, id: userId } = userInfo const { name: username, avatar: userAvatar, id: userId } = userInfo
return { return {
id: nanoid(), id: nanoid(),
body: printTextList.shift()!, body: mockTextList.shift()!,
date: Date.now(), sendTime: Date.now(),
receiveTime: Date.now(),
type: MessageType.Normal, type: MessageType.Normal,
userId, userId,
username, username,
@ -87,19 +86,16 @@ const Setup: FC = () => {
} }
useEffect(() => { useEffect(() => {
printTextList.length === 0 && (printTextList = [...mockTextList])
const timer = new Timer( const timer = new Timer(
async () => { async () => {
await createMessage(await refreshUserInfo()) await createMessage(await refreshUserInfo())
}, },
{ delay: 2000, immediate: true, limit: printTextList.length } { delay: 2000, immediate: true, limit: mockTextList.length }
) )
timer.on('stop', () => {
printTextList.length === 0 && send(messageListDomain.command.ClearListCommand())
})
timer.start() timer.start()
return () => { return () => {
timer.stop() timer.stop()
send(messageListDomain.command.ClearListCommand())
} }
}, []) }, [])

View file

@ -199,3 +199,11 @@ export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
* 8kb * (1 - 0.33) = 5488 bytes * 8kb * (1 - 0.33) = 5488 bytes
*/ */
export const MAX_AVATAR_SIZE = 5120 as const export const MAX_AVATAR_SIZE = 5120 as const
export const SYNC_HISTORY_MAX_DAYS = 30 as const
/**
* https://lgrahl.de/articles/demystifying-webrtc-dc-size-limit.html
* 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

View file

@ -24,7 +24,8 @@ export interface NormalMessage extends MessageUser {
type: MessageType.Normal type: MessageType.Normal
id: string id: string
body: string body: string
date: number sendTime: number
receiveTime: number
likeUsers: MessageUser[] likeUsers: MessageUser[]
hateUsers: MessageUser[] hateUsers: MessageUser[]
atUsers: AtUser[] atUsers: AtUser[]
@ -34,7 +35,8 @@ export interface PromptMessage extends MessageUser {
type: MessageType.Prompt type: MessageType.Prompt
id: string id: string
body: string body: string
date: number sendTime: number
receiveTime: number
} }
export type Message = NormalMessage | PromptMessage export type Message = NormalMessage | PromptMessage
@ -120,6 +122,38 @@ const MessageListDomain = Remesh.domain({
} }
}) })
const UpsertItemCommand = domain.command({
name: 'MessageList.UpsertItemCommand',
impl: (_, message: Message) => {
return [
MessageListModule.command.UpsertItemCommand(message),
UpsertItemEvent(message),
ChangeListEvent(),
SyncToStorageEvent()
]
}
})
const UpsertItemEvent = domain.event<Message>({
name: 'MessageList.UpsertItemEvent'
})
const ResetListCommand = domain.command({
name: 'MessageList.ResetListCommand',
impl: (_, messages: Message[]) => {
return [
MessageListModule.command.SetListCommand(messages),
ResetListEvent(messages),
ChangeListEvent(),
SyncToStorageEvent()
]
}
})
const ResetListEvent = domain.event<Message[]>({
name: 'MessageList.ResetListEvent'
})
const ClearListEvent = domain.event({ const ClearListEvent = domain.event({
name: 'MessageList.ClearListEvent' name: 'MessageList.ClearListEvent'
}) })
@ -164,14 +198,18 @@ const MessageListDomain = Remesh.domain({
CreateItemCommand, CreateItemCommand,
UpdateItemCommand, UpdateItemCommand,
DeleteItemCommand, DeleteItemCommand,
ClearListCommand UpsertItemCommand,
ClearListCommand,
ResetListCommand
}, },
event: { event: {
ChangeListEvent, ChangeListEvent,
CreateItemEvent, CreateItemEvent,
UpdateItemEvent, UpdateItemEvent,
DeleteItemEvent, DeleteItemEvent,
UpsertItemEvent,
ClearListEvent, ClearListEvent,
ResetListEvent,
SyncToStateEvent, SyncToStateEvent,
SyncToStorageEvent SyncToStorageEvent
} }

View file

@ -4,33 +4,47 @@ import { AtUser, NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom' import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList' import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import { desert, upsert } from '@/utils' import { desert, getTextByteSize, upsert } from '@/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status' import StatusModule from '@/domain/modules/Status'
import { ToastExtern } from './externs/Toast'
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
export { MessageType } export { MessageType }
export enum SendType { export enum SendType {
Like = 'like', Like = 'Like',
Hate = 'hate', Hate = 'Hate',
Text = 'text', Text = 'Text',
Join = 'join' SyncUser = 'SyncUser',
SyncHistory = 'SyncHistory'
} }
export interface SyncUserMessage extends MessageUser { export interface SyncUserMessage extends MessageUser {
type: SendType.Join type: SendType.SyncUser
id: string id: string
peerId: string peerId: string
joinTime: number joinTime: number
sendTime: number
lastMessageTime: number
}
export interface SyncHistoryMessage extends MessageUser {
type: SendType.SyncHistory
sendTime: number
id: string
messages: NormalMessage[]
} }
export interface LikeMessage extends MessageUser { export interface LikeMessage extends MessageUser {
type: SendType.Like type: SendType.Like
sendTime: number
id: string id: string
} }
export interface HateMessage extends MessageUser { export interface HateMessage extends MessageUser {
type: SendType.Hate type: SendType.Hate
sendTime: number
id: string id: string
} }
@ -38,10 +52,11 @@ export interface TextMessage extends MessageUser {
type: SendType.Text type: SendType.Text
id: string id: string
body: string body: string
sendTime: number
atUsers: AtUser[] atUsers: AtUser[]
} }
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage export type RoomMessage = SyncUserMessage | SyncHistoryMessage | LikeMessage | HateMessage | TextMessage
export type RoomUser = MessageUser & { peerId: string; joinTime: number } export type RoomUser = MessageUser & { peerId: string; joinTime: number }
@ -50,6 +65,7 @@ const RoomDomain = Remesh.domain({
impl: (domain) => { impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain()) const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain()) const userInfoDomain = domain.getDomain(UserInfoDomain())
const toast = domain.getExtern(ToastExtern)
const peerRoom = domain.getExtern(PeerRoomExtern) const peerRoom = domain.getExtern(PeerRoomExtern)
const PeerIdState = domain.state<string>({ const PeerIdState = domain.state<string>({
@ -80,6 +96,24 @@ const RoomDomain = Remesh.domain({
} }
}) })
const SelfUserQuery = domain.query({
name: 'Room.SelfUserQuery',
impl: ({ get }) => {
return get(UserListQuery()).find((user) => user.peerId === get(PeerIdQuery()))!
}
})
const LastMessageTimeQuery = domain.query({
name: 'Room.LastMessageTimeQuery',
impl: ({ get }) => {
return (
get(messageListDomain.query.ListQuery())
.filter((message) => message.type === MessageType.Normal)
.toSorted((a, b) => b.sendTime - a.sendTime)[0]?.sendTime ?? new Date(1970, 1, 1).getTime()
)
}
})
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({ const JoinRoomCommand = domain.command({
@ -100,7 +134,8 @@ const RoomDomain = Remesh.domain({
userAvatar, userAvatar,
body: `"${username}" joined the chat`, body: `"${username}" joined the chat`,
type: MessageType.Prompt, type: MessageType.Prompt,
date: Date.now() sendTime: Date.now(),
receiveTime: Date.now()
}), }),
JoinStatusModule.command.SetFinishedCommand(), JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId) JoinRoomEvent(peerRoom.roomId)
@ -121,7 +156,8 @@ const RoomDomain = Remesh.domain({
userAvatar, userAvatar,
body: `"${username}" left the chat`, body: `"${username}" left the chat`,
type: MessageType.Prompt, type: MessageType.Prompt,
date: Date.now() sendTime: Date.now(),
receiveTime: Date.now()
}), }),
UpdateUserListCommand({ UpdateUserListCommand({
type: 'delete', type: 'delete',
@ -136,22 +172,21 @@ const RoomDomain = Remesh.domain({
const SendTextMessageCommand = domain.command({ const SendTextMessageCommand = domain.command({
name: 'Room.SendTextMessageCommand', name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => { impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const self = get(SelfUserQuery())
const textMessage: TextMessage = { const textMessage: TextMessage = {
...self,
id: nanoid(), id: nanoid(),
type: SendType.Text, type: SendType.Text,
sendTime: Date.now(),
body: typeof message === 'string' ? message : message.body, body: typeof message === 'string' ? message : message.body,
userId,
username,
userAvatar,
atUsers: typeof message === 'string' ? [] : message.atUsers atUsers: typeof message === 'string' ? [] : message.atUsers
} }
const listMessage: NormalMessage = { const listMessage: NormalMessage = {
...textMessage, ...textMessage,
type: MessageType.Normal, type: MessageType.Normal,
date: Date.now(), receiveTime: Date.now(),
likeUsers: [], likeUsers: [],
hateUsers: [], hateUsers: [],
atUsers: typeof message === 'string' ? [] : message.atUsers atUsers: typeof message === 'string' ? [] : message.atUsers
@ -165,14 +200,13 @@ const RoomDomain = Remesh.domain({
const SendLikeMessageCommand = domain.command({ const SendLikeMessageCommand = domain.command({
name: 'Room.SendLikeMessageCommand', name: 'Room.SendLikeMessageCommand',
impl: ({ get }, messageId: string) => { impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const self = get(SelfUserQuery())
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const likeMessage: LikeMessage = { const likeMessage: LikeMessage = {
...self,
id: messageId, id: messageId,
userId, sendTime: Date.now(),
username,
userAvatar,
type: SendType.Like type: SendType.Like
} }
const listMessage: NormalMessage = { const listMessage: NormalMessage = {
@ -187,14 +221,13 @@ const RoomDomain = Remesh.domain({
const SendHateMessageCommand = domain.command({ const SendHateMessageCommand = domain.command({
name: 'Room.SendHateMessageCommand', name: 'Room.SendHateMessageCommand',
impl: ({ get }, messageId: string) => { impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const self = get(SelfUserQuery())
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const hateMessage: HateMessage = { const hateMessage: HateMessage = {
...self,
id: messageId, id: messageId,
userId, sendTime: Date.now(),
username,
userAvatar,
type: SendType.Hate type: SendType.Hate
} }
const listMessage: NormalMessage = { const listMessage: NormalMessage = {
@ -206,19 +239,90 @@ const RoomDomain = Remesh.domain({
} }
}) })
const SendJoinMessageCommand = domain.command({ const SendSyncUserMessageCommand = domain.command({
name: 'Room.SendJoinMessageCommand', name: 'Room.SendSyncUserMessageCommand',
impl: ({ get }, targetPeerId: string) => { impl: ({ get }, peerId: string) => {
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)! const self = get(SelfUserQuery())
const lastMessageTime = get(LastMessageTimeQuery())
const syncUserMessage: SyncUserMessage = { const syncUserMessage: SyncUserMessage = {
...self, ...self,
id: nanoid(), id: nanoid(),
type: SendType.Join sendTime: Date.now(),
lastMessageTime,
type: SendType.SyncUser
} }
peerRoom.sendMessage(syncUserMessage, targetPeerId) peerRoom.sendMessage(syncUserMessage, peerId)
return [SendJoinMessageEvent(syncUserMessage)] return [SendSyncUserMessageEvent(syncUserMessage)]
}
})
/**
* The maximum sync message is the historical records within 30 days, using the last message as the basis for judgment.
* The number of synced messages may not be all messages within 30 days; if new messages are generated before syncing, they will not be synced.
* Users A, B, C, D, and E: A and B are online, while C, D, and E are offline.
* 1. A and B chat, generating two messages: messageA and messageB.
* 2. A and B go offline.
* 3. C and D come online, generating two messages: messageC and messageD.
* 4. A and B come online, and C and D will push two messages, messageC and messageD, to A and B. However, A and B will not push messageA and messageB to C and D because C and D's latest message timestamps are earlier than A and B's.
* 5. E comes online, and A, B, C, and D will all push messages messageA, messageB, messageC, and messageD to E.
*
* Final results:
* A and B see 4 messages: messageC, messageD, messageA, and messageB.
* C and D see 2 messages: messageA and messageB.
* E sees 4 messages: messageA, messageB, messageC, and messageD.
*
* As shown above, C and D did not sync messages that were earlier than their own.
* On one hand, if we want to fully sync 30 days of messages, we must diff the timestamps of messages within 30 days and then insert them. The current implementation only does incremental additions, and messages will accumulate over time.
* For now, let's keep it this way and see if it's necessary to fully sync the data within 30 days later.
*/
const SendSyncHistoryMessageCommand = domain.command({
name: 'Room.SendSyncHistoryMessageCommand',
impl: ({ get }, { peerId, lastMessageTime }: { peerId: string; lastMessageTime: number }) => {
const self = get(SelfUserQuery())
console.log('SendSyncHistoryMessageCommand', peerId, peerRoom.peerId)
const historyMessages = get(messageListDomain.query.ListQuery()).filter(
(message) =>
message.type === MessageType.Normal &&
message.sendTime > lastMessageTime &&
message.sendTime - Date.now() <= SYNC_HISTORY_MAX_DAYS * 24 * 60 * 60 * 1000
)
/**
* 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<SyncHistoryMessage[]>((acc, cur) => {
const pushHistoryMessage: SyncHistoryMessage = {
...self,
id: nanoid(),
sendTime: Date.now(),
type: SendType.SyncHistory,
messages: [cur as NormalMessage]
}
const pushHistoryMessageByteSize = getTextByteSize(JSON.stringify(pushHistoryMessage))
if (pushHistoryMessageByteSize < WEB_RTC_MAX_MESSAGE_SIZE) {
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)
} else {
acc.push(pushHistoryMessage)
}
} else {
acc.push(pushHistoryMessage)
}
}
return acc
}, [])
return pushHistoryMessageList.map((message) => {
peerRoom.sendMessage(message, peerId)
return SendSyncHistoryMessageEvent(message)
})
} }
}) })
@ -234,8 +338,12 @@ const RoomDomain = Remesh.domain({
} }
}) })
const SendJoinMessageEvent = domain.event<SyncUserMessage>({ const SendSyncHistoryMessageEvent = domain.event<SyncHistoryMessage>({
name: 'Room.SendJoinMessageEvent' name: 'Room.SendSyncHistoryMessageEvent'
})
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
name: 'Room.SendSyncUserMessageEvent'
}) })
const SendTextMessageEvent = domain.event<TextMessage>({ const SendTextMessageEvent = domain.event<TextMessage>({
@ -287,7 +395,7 @@ const RoomDomain = Remesh.domain({
if (peerRoom.peerId === peerId) { if (peerRoom.peerId === peerId) {
return [OnJoinRoomEvent(peerId)] return [OnJoinRoomEvent(peerId)]
} else { } else {
return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)] return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
} }
}) })
) )
@ -308,16 +416,19 @@ const RoomDomain = Remesh.domain({
const messageCommand$ = (() => { const messageCommand$ = (() => {
switch (message.type) { switch (message.type) {
case SendType.Join: { case SendType.SyncUser: {
const userList = get(UserListQuery()) const userList = get(UserListQuery())
const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)! const selfUser = get(SelfUserQuery())
// If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId // If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId
const isSelfJoinEvent = !!userList.find((user) => user.userId === message.userId) const isRepeatJoin = userList.some((user) => user.userId === message.userId)
// When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event // When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event
// Use joinTime to determine if it's a new user // Use joinTime to determine if it's a new user
const isNewJoinEvent = selfUser.joinTime < message.joinTime const isNewJoinEvent = selfUser.joinTime < message.joinTime
return isSelfJoinEvent const lastMessageTime = get(LastMessageTimeQuery())
const needSyncHistory = lastMessageTime > message.lastMessageTime
return isRepeatJoin
? EMPTY ? EMPTY
: of( : of(
UpdateUserListCommand({ type: 'create', user: message }), UpdateUserListCommand({ type: 'create', user: message }),
@ -327,17 +438,29 @@ const RoomDomain = Remesh.domain({
id: nanoid(), id: nanoid(),
body: `"${message.username}" joined the chat`, body: `"${message.username}" joined the chat`,
type: MessageType.Prompt, type: MessageType.Prompt,
date: Date.now() receiveTime: Date.now()
})
: null,
needSyncHistory
? SendSyncHistoryMessageCommand({
peerId: message.peerId,
lastMessageTime: message.lastMessageTime
}) })
: null : null
) )
} }
case SendType.SyncHistory: {
toast.success('Syncing history messages.')
return of(...message.messages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
}
case SendType.Text: case SendType.Text:
return of( return of(
messageListDomain.command.CreateItemCommand({ messageListDomain.command.CreateItemCommand({
...message, ...message,
type: MessageType.Normal, type: MessageType.Normal,
date: Date.now(), receiveTime: Date.now(),
likeUsers: [], likeUsers: [],
hateUsers: [] hateUsers: []
}) })
@ -348,10 +471,11 @@ const RoomDomain = Remesh.domain({
return EMPTY return EMPTY
} }
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
const type = message.type === 'like' ? 'likeUsers' : 'hateUsers' const type = message.type === 'Like' ? 'likeUsers' : 'hateUsers'
return of( return of(
messageListDomain.command.UpdateItemCommand({ messageListDomain.command.UpdateItemCommand({
..._message, ..._message,
receiveTime: Date.now(),
[type]: desert( [type]: desert(
_message[type], _message[type],
{ {
@ -382,7 +506,7 @@ const RoomDomain = Remesh.domain({
impl: ({ get }) => { impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe( const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => { map((peerId) => {
// console.log('onLeaveRoom', peerId) console.log('onLeaveRoom', peerId, get(SelfUserQuery()).peerId)
const user = get(UserListQuery()).find((user) => user.peerId === peerId) const user = get(UserListQuery()).find((user) => user.peerId === peerId)
if (user) { if (user) {
@ -393,7 +517,8 @@ const RoomDomain = Remesh.domain({
id: nanoid(), id: nanoid(),
body: `"${user.username}" left the chat`, body: `"${user.username}" left the chat`,
type: MessageType.Prompt, type: MessageType.Prompt,
date: Date.now() sendTime: Date.now(),
receiveTime: Date.now()
}), }),
OnLeaveRoomEvent(peerId) OnLeaveRoomEvent(peerId)
] ]
@ -425,6 +550,8 @@ const RoomDomain = Remesh.domain({
impl: ({ get }) => { impl: ({ get }) => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe( const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
map(() => { map(() => {
console.log('beforeunload')
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
}) })
) )
@ -444,13 +571,15 @@ const RoomDomain = Remesh.domain({
SendTextMessageCommand, SendTextMessageCommand,
SendLikeMessageCommand, SendLikeMessageCommand,
SendHateMessageCommand, SendHateMessageCommand,
SendJoinMessageCommand SendSyncUserMessageCommand,
SendSyncHistoryMessageCommand
}, },
event: { event: {
SendTextMessageEvent, SendTextMessageEvent,
SendLikeMessageEvent, SendLikeMessageEvent,
SendHateMessageEvent, SendHateMessageEvent,
SendJoinMessageEvent, SendSyncUserMessageEvent,
SendSyncHistoryMessageEvent,
JoinRoomEvent, JoinRoomEvent,
LeaveRoomEvent, LeaveRoomEvent,
OnMessageEvent, OnMessageEvent,

View file

@ -5,6 +5,8 @@ import { stringToHex } from '@/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import EventHub from '@resreq/event-hub' import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room' import { RoomMessage } from '../Room'
import { JSONR } from '@/utils'
export interface Config { export interface Config {
peerId?: string peerId?: string
roomId: string roomId: string
@ -50,11 +52,11 @@ class PeerRoom extends EventHub {
if (!this.room) { if (!this.room) {
this.emit('error', new Error('Room not joined')) this.emit('error', new Error('Room not joined'))
} else { } else {
this.room.send(JSON.stringify(message), id) this.room.send(JSONR.stringify(message)!, id)
} }
}) })
} else { } else {
this.room.send(JSON.stringify(message), id) this.room.send(JSONR.stringify(message)!, id)
} }
return this return this
} }
@ -65,11 +67,11 @@ class PeerRoom extends EventHub {
if (!this.room) { if (!this.room) {
this.emit('error', new Error('Room not joined')) this.emit('error', new Error('Room not joined'))
} else { } else {
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage)) this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
} }
}) })
} else { } else {
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage)) this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
} }
return this return this
} }

View file

@ -7,6 +7,7 @@ import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { Storage } from '@/domain/externs/Storage' import { Storage } from '@/domain/externs/Storage'
import { EVENT } from '@/constants/event' import { EVENT } from '@/constants/event'
import { JSONR } from '@/utils'
export const localStorage = createStorage({ export const localStorage = createStorage({
driver: localStorageDriver({ base: `${STORAGE_NAME}:` }) driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
@ -22,8 +23,8 @@ export const browserSyncStorage = createStorage({
export const LocalStorageImpl = LocalStorageExtern.impl({ export const LocalStorageImpl = LocalStorageExtern.impl({
name: STORAGE_NAME, name: STORAGE_NAME,
get: localStorage.getItem, get: async (key) => JSONR.parse(await localStorage.getItem(key)),
set: localStorage.setItem, set: (key, value) => localStorage.setItem(key, JSONR.stringify(value)!),
remove: localStorage.removeItem, remove: localStorage.removeItem,
clear: localStorage.clear, clear: localStorage.clear,
watch: async (callback) => { watch: async (callback) => {
@ -45,8 +46,8 @@ export const LocalStorageImpl = LocalStorageExtern.impl({
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
name: STORAGE_NAME, name: STORAGE_NAME,
get: indexDBStorage.getItem, get: async (key) => JSONR.parse(await indexDBStorage.getItem(key)),
set: indexDBStorage.setItem, set: (key, value) => indexDBStorage.setItem(key, JSONR.stringify(value)),
remove: indexDBStorage.removeItem, remove: indexDBStorage.removeItem,
clear: indexDBStorage.clear, clear: indexDBStorage.clear,
watch: indexDBStorage.watch as Storage['watch'], watch: indexDBStorage.watch as Storage['watch'],
@ -55,8 +56,8 @@ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
name: STORAGE_NAME, name: STORAGE_NAME,
get: browserSyncStorage.getItem, get: async (key) => JSONR.parse(await browserSyncStorage.getItem(key)),
set: browserSyncStorage.setItem, set: (key, value) => browserSyncStorage.setItem(key, JSONR.stringify(value)),
remove: browserSyncStorage.removeItem, remove: browserSyncStorage.removeItem,
clear: browserSyncStorage.clear, clear: browserSyncStorage.clear,
watch: browserSyncStorage.watch as Storage['watch'], watch: browserSyncStorage.watch as Storage['watch'],

View file

@ -0,0 +1,3 @@
export const getTextByteSize = (text: string) => {
return new TextEncoder().encode(text).length
}

View file

@ -15,3 +15,5 @@ export { default as getCursorPosition } from './getCursorPosition'
export { default as getTextSimilarity } from './getTextSimilarity' export { default as getTextSimilarity } from './getTextSimilarity'
export { default as getRootNode } from './getRootNode' export { default as getRootNode } from './getRootNode'
export { default as blobToBase64 } from './blobToBase64' export { default as blobToBase64 } from './blobToBase64'
export * as JSONR from './jsonr'
export { getTextByteSize } from './getTextByteSize'

10
src/utils/jsonr.ts Normal file
View file

@ -0,0 +1,10 @@
import JSONR from '@perfsee/jsonr'
import { isNullish } from '@/utils'
export const parse = <T = any>(value: string | number | boolean | null): T | null => {
return !isNullish(value) ? JSONR.parse(value!.toString()) : null
}
export const stringify = (value: any): string | null => {
return !isNullish(value) ? JSONR.stringify(value) : null
}