Merge branch 'develop'
This commit is contained in:
commit
75b52d4003
15 changed files with 649 additions and 363 deletions
22
package.json
22
package.json
|
@ -45,13 +45,13 @@
|
|||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.2",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.3",
|
||||
"@perfsee/jsonr": "^1.13.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.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-popover": "^1.1.2",
|
||||
"@radix-ui/react-portal": "^1.1.2",
|
||||
|
@ -70,7 +70,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.10",
|
||||
"framer-motion": "^11.11.11",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"nanoid": "^5.0.8",
|
||||
|
@ -86,7 +86,7 @@
|
|||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.5.0",
|
||||
"sonner": "^1.6.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.26.1",
|
||||
|
@ -97,27 +97,27 @@
|
|||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@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/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.8.2",
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.12.1",
|
||||
"@typescript-eslint/parser": "^8.12.2",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.11.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.3.3",
|
||||
"jiti": "^2.4.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.47",
|
||||
|
@ -128,8 +128,8 @@
|
|||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.12.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"typescript-eslint": "^8.12.2",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.19.13"
|
||||
},
|
||||
|
|
624
pnpm-lock.yaml
624
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -58,7 +58,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
<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.date}></FormatDate>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
|
|
|
@ -56,13 +56,13 @@ export default defineContentScript({
|
|||
container.append(app)
|
||||
const root = createRoot(app)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
// <React.StrictMode>
|
||||
<RemeshRoot store={store}>
|
||||
<RemeshScope domains={[NotificationDomain()]}>
|
||||
<App />
|
||||
</RemeshScope>
|
||||
</RemeshRoot>
|
||||
</React.StrictMode>
|
||||
// </React.StrictMode>
|
||||
)
|
||||
return root
|
||||
},
|
||||
|
|
|
@ -5,7 +5,7 @@ import MessageInput from '../../components/MessageInput'
|
|||
import EmojiButton from '../../components/EmojiButton'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
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 useCursorPosition from '@/hooks/useCursorPosition'
|
||||
import useShareRef from '@/hooks/useShareRef'
|
||||
|
@ -15,7 +15,7 @@ import useTriggerAway from '@/hooks/useTriggerAway'
|
|||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
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 { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
|
@ -136,6 +136,13 @@ const Footer: FC = () => {
|
|||
})
|
||||
.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(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
|
|
@ -15,7 +15,8 @@ const Main: FC = () => {
|
|||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
|
||||
const messageList = _messageList.map((message) => {
|
||||
const messageList = _messageList
|
||||
.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
|
@ -25,6 +26,7 @@ const Main: FC = () => {
|
|||
}
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
|
|
|
@ -33,8 +33,6 @@ const mockTextList = [
|
|||
`![ExampleImage](${ExampleImage})`
|
||||
]
|
||||
|
||||
let printTextList = [...mockTextList]
|
||||
|
||||
const generateUserInfo = async (): Promise<UserInfo> => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
|
@ -52,8 +50,9 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
const { name: username, avatar: userAvatar, id: userId } = userInfo
|
||||
return {
|
||||
id: nanoid(),
|
||||
body: printTextList.shift()!,
|
||||
date: Date.now(),
|
||||
body: mockTextList.shift()!,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
type: MessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
|
@ -87,19 +86,16 @@ const Setup: FC = () => {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
printTextList.length === 0 && (printTextList = [...mockTextList])
|
||||
const timer = new Timer(
|
||||
async () => {
|
||||
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()
|
||||
return () => {
|
||||
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
|
||||
*/
|
||||
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,8 @@ export interface NormalMessage extends MessageUser {
|
|||
type: MessageType.Normal
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
|
@ -34,7 +35,8 @@ export interface PromptMessage extends MessageUser {
|
|||
type: MessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
}
|
||||
|
||||
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({
|
||||
name: 'MessageList.ClearListEvent'
|
||||
})
|
||||
|
@ -164,14 +198,18 @@ const MessageListDomain = Remesh.domain({
|
|||
CreateItemCommand,
|
||||
UpdateItemCommand,
|
||||
DeleteItemCommand,
|
||||
ClearListCommand
|
||||
UpsertItemCommand,
|
||||
ClearListCommand,
|
||||
ResetListCommand
|
||||
},
|
||||
event: {
|
||||
ChangeListEvent,
|
||||
CreateItemEvent,
|
||||
UpdateItemEvent,
|
||||
DeleteItemEvent,
|
||||
UpsertItemEvent,
|
||||
ClearListEvent,
|
||||
ResetListEvent,
|
||||
SyncToStateEvent,
|
||||
SyncToStorageEvent
|
||||
}
|
||||
|
|
|
@ -4,33 +4,47 @@ import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
|||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, upsert } from '@/utils'
|
||||
import { desert, getTextByteSize, upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 enum SendType {
|
||||
Like = 'like',
|
||||
Hate = 'hate',
|
||||
Text = 'text',
|
||||
Join = 'join'
|
||||
Like = 'Like',
|
||||
Hate = 'Hate',
|
||||
Text = 'Text',
|
||||
SyncUser = 'SyncUser',
|
||||
SyncHistory = 'SyncHistory'
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.Join
|
||||
type: SendType.SyncUser
|
||||
id: string
|
||||
peerId: string
|
||||
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 {
|
||||
type: SendType.Like
|
||||
sendTime: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HateMessage extends MessageUser {
|
||||
type: SendType.Hate
|
||||
sendTime: number
|
||||
id: string
|
||||
}
|
||||
|
||||
|
@ -38,10 +52,11 @@ export interface TextMessage extends MessageUser {
|
|||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
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 }
|
||||
|
||||
|
@ -50,6 +65,7 @@ const RoomDomain = Remesh.domain({
|
|||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
const peerRoom = domain.getExtern(PeerRoomExtern)
|
||||
|
||||
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 JoinRoomCommand = domain.command({
|
||||
|
@ -100,7 +134,8 @@ const RoomDomain = Remesh.domain({
|
|||
userAvatar,
|
||||
body: `"${username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
}),
|
||||
JoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(peerRoom.roomId)
|
||||
|
@ -121,7 +156,8 @@ const RoomDomain = Remesh.domain({
|
|||
userAvatar,
|
||||
body: `"${username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
}),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
|
@ -136,22 +172,21 @@ const RoomDomain = Remesh.domain({
|
|||
const SendTextMessageCommand = domain.command({
|
||||
name: 'Room.SendTextMessageCommand',
|
||||
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 = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
sendTime: Date.now(),
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
...textMessage,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
|
@ -165,14 +200,13 @@ const RoomDomain = Remesh.domain({
|
|||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'Room.SendLikeMessageCommand',
|
||||
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 likeMessage: LikeMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
sendTime: Date.now(),
|
||||
type: SendType.Like
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
|
@ -187,14 +221,13 @@ const RoomDomain = Remesh.domain({
|
|||
const SendHateMessageCommand = domain.command({
|
||||
name: 'Room.SendHateMessageCommand',
|
||||
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 hateMessage: HateMessage = {
|
||||
...self,
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
sendTime: Date.now(),
|
||||
type: SendType.Hate
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
|
@ -206,19 +239,90 @@ const RoomDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const SendJoinMessageCommand = domain.command({
|
||||
name: 'Room.SendJoinMessageCommand',
|
||||
impl: ({ get }, targetPeerId: string) => {
|
||||
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
|
||||
const SendSyncUserMessageCommand = domain.command({
|
||||
name: 'Room.SendSyncUserMessageCommand',
|
||||
impl: ({ get }, peerId: string) => {
|
||||
const self = get(SelfUserQuery())
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
type: SendType.Join
|
||||
sendTime: Date.now(),
|
||||
lastMessageTime,
|
||||
type: SendType.SyncUser
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(syncUserMessage, targetPeerId)
|
||||
return [SendJoinMessageEvent(syncUserMessage)]
|
||||
peerRoom.sendMessage(syncUserMessage, peerId)
|
||||
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>({
|
||||
name: 'Room.SendJoinMessageEvent'
|
||||
const SendSyncHistoryMessageEvent = domain.event<SyncHistoryMessage>({
|
||||
name: 'Room.SendSyncHistoryMessageEvent'
|
||||
})
|
||||
|
||||
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'Room.SendSyncUserMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
|
@ -287,7 +395,7 @@ const RoomDomain = Remesh.domain({
|
|||
if (peerRoom.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
@ -308,16 +416,19 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
const messageCommand$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.Join: {
|
||||
case SendType.SyncUser: {
|
||||
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
|
||||
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
|
||||
// Use joinTime to determine if it's a new user
|
||||
const isNewJoinEvent = selfUser.joinTime < message.joinTime
|
||||
|
||||
return isSelfJoinEvent
|
||||
const lastMessageTime = get(LastMessageTimeQuery())
|
||||
const needSyncHistory = lastMessageTime > message.lastMessageTime
|
||||
|
||||
return isRepeatJoin
|
||||
? EMPTY
|
||||
: of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
|
@ -327,17 +438,29 @@ const RoomDomain = Remesh.domain({
|
|||
id: nanoid(),
|
||||
body: `"${message.username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
receiveTime: Date.now()
|
||||
})
|
||||
: null,
|
||||
needSyncHistory
|
||||
? SendSyncHistoryMessageCommand({
|
||||
peerId: message.peerId,
|
||||
lastMessageTime: message.lastMessageTime
|
||||
})
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
case SendType.SyncHistory: {
|
||||
toast.success('Syncing history messages.')
|
||||
return of(...message.messages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
|
||||
}
|
||||
|
||||
case SendType.Text:
|
||||
return of(
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
|
@ -348,10 +471,11 @@ const RoomDomain = Remesh.domain({
|
|||
return EMPTY
|
||||
}
|
||||
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(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
receiveTime: Date.now(),
|
||||
[type]: desert(
|
||||
_message[type],
|
||||
{
|
||||
|
@ -382,7 +506,7 @@ const RoomDomain = Remesh.domain({
|
|||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
console.log('onLeaveRoom', peerId, get(SelfUserQuery()).peerId)
|
||||
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
|
||||
|
||||
if (user) {
|
||||
|
@ -393,7 +517,8 @@ const RoomDomain = Remesh.domain({
|
|||
id: nanoid(),
|
||||
body: `"${user.username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now()
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
|
@ -425,6 +550,8 @@ const RoomDomain = Remesh.domain({
|
|||
impl: ({ get }) => {
|
||||
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
|
||||
map(() => {
|
||||
console.log('beforeunload')
|
||||
|
||||
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
|
||||
})
|
||||
)
|
||||
|
@ -444,13 +571,15 @@ const RoomDomain = Remesh.domain({
|
|||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
SendJoinMessageCommand
|
||||
SendSyncUserMessageCommand,
|
||||
SendSyncHistoryMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendJoinMessageEvent,
|
||||
SendSyncUserMessageEvent,
|
||||
SendSyncHistoryMessageEvent,
|
||||
JoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
|
|
|
@ -5,6 +5,8 @@ import { stringToHex } from '@/utils'
|
|||
import { nanoid } from 'nanoid'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '../Room'
|
||||
import { JSONR } from '@/utils'
|
||||
|
||||
export interface Config {
|
||||
peerId?: string
|
||||
roomId: string
|
||||
|
@ -50,11 +52,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -65,11 +67,11 @@ class PeerRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
}
|
||||
})
|
||||
} 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
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
|||
|
||||
import { Storage } from '@/domain/externs/Storage'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import { JSONR } from '@/utils'
|
||||
|
||||
export const localStorage = createStorage({
|
||||
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
|
||||
|
@ -22,8 +23,8 @@ export const browserSyncStorage = createStorage({
|
|||
|
||||
export const LocalStorageImpl = LocalStorageExtern.impl({
|
||||
name: STORAGE_NAME,
|
||||
get: localStorage.getItem,
|
||||
set: localStorage.setItem,
|
||||
get: async (key) => JSONR.parse(await localStorage.getItem(key)),
|
||||
set: (key, value) => localStorage.setItem(key, JSONR.stringify(value)!),
|
||||
remove: localStorage.removeItem,
|
||||
clear: localStorage.clear,
|
||||
watch: async (callback) => {
|
||||
|
@ -45,8 +46,8 @@ export const LocalStorageImpl = LocalStorageExtern.impl({
|
|||
|
||||
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
|
||||
name: STORAGE_NAME,
|
||||
get: indexDBStorage.getItem,
|
||||
set: indexDBStorage.setItem,
|
||||
get: async (key) => JSONR.parse(await indexDBStorage.getItem(key)),
|
||||
set: (key, value) => indexDBStorage.setItem(key, JSONR.stringify(value)),
|
||||
remove: indexDBStorage.removeItem,
|
||||
clear: indexDBStorage.clear,
|
||||
watch: indexDBStorage.watch as Storage['watch'],
|
||||
|
@ -55,8 +56,8 @@ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
|
|||
|
||||
export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
|
||||
name: STORAGE_NAME,
|
||||
get: browserSyncStorage.getItem,
|
||||
set: browserSyncStorage.setItem,
|
||||
get: async (key) => JSONR.parse(await browserSyncStorage.getItem(key)),
|
||||
set: (key, value) => browserSyncStorage.setItem(key, JSONR.stringify(value)),
|
||||
remove: browserSyncStorage.removeItem,
|
||||
clear: browserSyncStorage.clear,
|
||||
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 getRootNode } from './getRootNode'
|
||||
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