feat: support offline message sync #45

This commit is contained in:
molvqingtai 2024-11-03 08:01:52 +08:00
parent 96b6cd564c
commit 7c4f65573c
13 changed files with 297 additions and 84 deletions

View file

@ -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.receiveTime}></FormatDate>
</div>
<div>
<div className="pb-2">

View file

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

View file

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

View file

@ -15,16 +15,18 @@ const Main: FC = () => {
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageList = _messageList.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
const messageList = _messageList
.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
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) => {
send(roomDomain.command.SendLikeMessageCommand(messageId))

View file

@ -33,8 +33,6 @@ const mockTextList = [
`![ExampleImage](${ExampleImage})`
]
let printTextList = [...mockTextList]
const generateUserInfo = async (): Promise<UserInfo> => {
return {
id: nanoid(),
@ -52,8 +50,10 @@ 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(),
worldTime: Date.now(),
type: MessageType.Normal,
userId,
username,
@ -87,19 +87,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())
}
}, [])

View file

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

View file

@ -24,7 +24,9 @@ export interface NormalMessage extends MessageUser {
type: MessageType.Normal
id: string
body: string
date: number
sendTime: number
receiveTime: number
worldTime: number
likeUsers: MessageUser[]
hateUsers: MessageUser[]
atUsers: AtUser[]
@ -34,7 +36,9 @@ export interface PromptMessage extends MessageUser {
type: MessageType.Prompt
id: string
body: string
date: number
sendTime: number
receiveTime: number
worldTime: number
}
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({
name: 'MessageList.ClearListEvent'
})
@ -164,14 +200,18 @@ const MessageListDomain = Remesh.domain({
CreateItemCommand,
UpdateItemCommand,
DeleteItemCommand,
ClearListCommand
UpsertItemCommand,
ClearListCommand,
ResetListCommand
},
event: {
ChangeListEvent,
CreateItemEvent,
UpdateItemEvent,
DeleteItemEvent,
UpsertItemEvent,
ClearListEvent,
ResetListEvent,
SyncToStateEvent,
SyncToStorageEvent
}

View file

@ -4,33 +4,51 @@ 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
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 {
type: SendType.Like
sendTime: number
worldTime: number
id: string
}
export interface HateMessage extends MessageUser {
type: SendType.Hate
sendTime: number
worldTime: number
id: string
}
@ -38,10 +56,12 @@ export interface TextMessage extends MessageUser {
type: SendType.Text
id: string
body: string
sendTime: number
worldTime: 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 +70,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 +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 JoinRoomCommand = domain.command({
@ -100,7 +139,9 @@ const RoomDomain = Remesh.domain({
userAvatar,
body: `"${username}" joined the chat`,
type: MessageType.Prompt,
date: Date.now()
sendTime: Date.now(),
receiveTime: Date.now(),
worldTime: Date.now()
}),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId)
@ -121,7 +162,9 @@ const RoomDomain = Remesh.domain({
userAvatar,
body: `"${username}" left the chat`,
type: MessageType.Prompt,
date: Date.now()
sendTime: Date.now(),
receiveTime: Date.now(),
worldTime: Date.now()
}),
UpdateUserListCommand({
type: 'delete',
@ -136,22 +179,22 @@ 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(),
worldTime: 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 +208,14 @@ 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(),
worldTime: Date.now(),
type: SendType.Like
}
const listMessage: NormalMessage = {
@ -187,14 +230,14 @@ 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(),
worldTime: Date.now(),
type: SendType.Hate
}
const listMessage: NormalMessage = {
@ -206,19 +249,92 @@ 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(),
worldTime: 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.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>({
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 +407,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 +428,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 +450,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,7 +483,7 @@ 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,
@ -382,7 +517,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 +528,9 @@ const RoomDomain = Remesh.domain({
id: nanoid(),
body: `"${user.username}" left the chat`,
type: MessageType.Prompt,
date: Date.now()
sendTime: Date.now(),
worldTime: Date.now(),
receiveTime: Date.now()
}),
OnLeaveRoomEvent(peerId)
]
@ -425,6 +562,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 +583,15 @@ const RoomDomain = Remesh.domain({
SendTextMessageCommand,
SendLikeMessageCommand,
SendHateMessageCommand,
SendJoinMessageCommand
SendSyncUserMessageCommand,
SendSyncHistoryMessageCommand
},
event: {
SendTextMessageEvent,
SendLikeMessageEvent,
SendHateMessageEvent,
SendJoinMessageEvent,
SendSyncUserMessageEvent,
SendSyncHistoryMessageEvent,
JoinRoomEvent,
LeaveRoomEvent,
OnMessageEvent,

View file

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

View file

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

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