feat: support offline message sync #45
This commit is contained in:
parent
96b6cd564c
commit
7c4f65573c
13 changed files with 297 additions and 84 deletions
|
@ -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.receiveTime}></FormatDate>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.worldTime - b.worldTime)
|
||||||
|
|
||||||
const handleLikeChange = (messageId: string) => {
|
const handleLikeChange = (messageId: string) => {
|
||||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||||
|
|
|
@ -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,10 @@ 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(),
|
||||||
|
worldTime: Date.now(),
|
||||||
type: MessageType.Normal,
|
type: MessageType.Normal,
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
|
@ -87,19 +87,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())
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -24,7 +24,9 @@ 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
|
||||||
|
worldTime: number
|
||||||
likeUsers: MessageUser[]
|
likeUsers: MessageUser[]
|
||||||
hateUsers: MessageUser[]
|
hateUsers: MessageUser[]
|
||||||
atUsers: AtUser[]
|
atUsers: AtUser[]
|
||||||
|
@ -34,7 +36,9 @@ 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
|
||||||
|
worldTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Message = NormalMessage | PromptMessage
|
export type Message = NormalMessage | PromptMessage
|
||||||
|
@ -120,6 +124,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 +200,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
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,33 +4,51 @@ 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
|
||||||
|
worldTime: number
|
||||||
|
lastMessageTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncHistoryMessage extends MessageUser {
|
||||||
|
type: SendType.SyncHistory
|
||||||
|
sendTime: number
|
||||||
|
worldTime: number
|
||||||
|
id: string
|
||||||
|
messages: NormalMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LikeMessage extends MessageUser {
|
export interface LikeMessage extends MessageUser {
|
||||||
type: SendType.Like
|
type: SendType.Like
|
||||||
|
sendTime: number
|
||||||
|
worldTime: number
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HateMessage extends MessageUser {
|
export interface HateMessage extends MessageUser {
|
||||||
type: SendType.Hate
|
type: SendType.Hate
|
||||||
|
sendTime: number
|
||||||
|
worldTime: number
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,10 +56,12 @@ export interface TextMessage extends MessageUser {
|
||||||
type: SendType.Text
|
type: SendType.Text
|
||||||
id: string
|
id: string
|
||||||
body: string
|
body: string
|
||||||
|
sendTime: number
|
||||||
|
worldTime: 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 +70,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 +101,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.worldTime - a.worldTime)[0]?.worldTime ?? 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 +139,9 @@ 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(),
|
||||||
|
worldTime: Date.now()
|
||||||
}),
|
}),
|
||||||
JoinStatusModule.command.SetFinishedCommand(),
|
JoinStatusModule.command.SetFinishedCommand(),
|
||||||
JoinRoomEvent(peerRoom.roomId)
|
JoinRoomEvent(peerRoom.roomId)
|
||||||
|
@ -121,7 +162,9 @@ 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(),
|
||||||
|
worldTime: Date.now()
|
||||||
}),
|
}),
|
||||||
UpdateUserListCommand({
|
UpdateUserListCommand({
|
||||||
type: 'delete',
|
type: 'delete',
|
||||||
|
@ -136,22 +179,22 @@ 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(),
|
||||||
|
worldTime: 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 +208,14 @@ 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,
|
worldTime: Date.now(),
|
||||||
userAvatar,
|
|
||||||
type: SendType.Like
|
type: SendType.Like
|
||||||
}
|
}
|
||||||
const listMessage: NormalMessage = {
|
const listMessage: NormalMessage = {
|
||||||
|
@ -187,14 +230,14 @@ 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,
|
worldTime: Date.now(),
|
||||||
userAvatar,
|
|
||||||
type: SendType.Hate
|
type: SendType.Hate
|
||||||
}
|
}
|
||||||
const listMessage: NormalMessage = {
|
const listMessage: NormalMessage = {
|
||||||
|
@ -206,19 +249,92 @@ 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(),
|
||||||
|
worldTime: 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.worldTime > lastMessageTime &&
|
||||||
|
message.worldTime - 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(),
|
||||||
|
worldTime: 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 +350,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 +407,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 +428,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 +450,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,7 +483,7 @@ 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,
|
||||||
|
@ -382,7 +517,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 +528,9 @@ 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(),
|
||||||
|
worldTime: Date.now(),
|
||||||
|
receiveTime: Date.now()
|
||||||
}),
|
}),
|
||||||
OnLeaveRoomEvent(peerId)
|
OnLeaveRoomEvent(peerId)
|
||||||
]
|
]
|
||||||
|
@ -425,6 +562,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 +583,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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
3
src/utils/getTextByteSize.ts
Normal file
3
src/utils/getTextByteSize.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const getTextByteSize = (text: string) => {
|
||||||
|
return new TextEncoder().encode(text).length
|
||||||
|
}
|
|
@ -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
10
src/utils/jsonr.ts
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue