perf: support unread status

This commit is contained in:
molvqingtai 2024-10-02 17:57:28 +08:00
parent 65a320ab35
commit 1f44af873c
9 changed files with 238 additions and 56 deletions

View file

@ -8,22 +8,11 @@ import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup' import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList' import MessageListDomain from '@/domain/MessageList'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { browserSyncStorage, indexDBStorage } from '@/domain/impls/Storage'
import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config'
import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg'
import LogoIcon2 from '@/assets/images/logo-2.svg'
import LogoIcon3 from '@/assets/images/logo-3.svg'
import LogoIcon4 from '@/assets/images/logo-4.svg'
import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import { getDay } from 'date-fns'
import DanmakuContainer from './components/DanmakuContainer' import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku' import DanmakuDomain from '@/domain/Danmaku'
import { browser } from 'wxt/browser'
export default function App() { export default function App() {
const send = useRemeshSend() const send = useRemeshSend()
@ -32,13 +21,11 @@ export default function App() {
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain()) const danmakuDomain = useRemeshDomain(DanmakuDomain())
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery()) const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery()) const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery()) const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery()) const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())] const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
useEffect(() => { useEffect(() => {
if (messageListLoadFinished) { if (messageListLoadFinished) {
@ -51,23 +38,6 @@ export default function App() {
} }
}, [userInfoSetFinished, messageListLoadFinished]) }, [userInfoSetFinished, messageListLoadFinished])
const [appOpen, setAppOpen] = useState(false)
const handleToggleApp = async () => {
const value = !appOpen
setAppOpen(value)
await indexDBStorage.setItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY, value)
}
const getAppOpenStatus = async () => {
const value = await indexDBStorage.getItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY)
setAppOpen(!!value)
}
useEffect(() => {
getAppOpenStatus()
}, [])
const danmakuContainerRef = useRef<HTMLDivElement>(null) const danmakuContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@ -76,19 +46,18 @@ export default function App() {
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand()) danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand())
} }
}, [danmakuIsEnabled]) }, [danmakuIsEnabled])
console.log(1)
return ( return (
<> <>
<AppContainer open={appOpen}> <AppContainer>
<Header /> <Header />
<Main /> <Main />
<Footer /> <Footer />
{notUserInfo && <Setup />} {notUserInfo && <Setup />}
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster> <Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
</AppContainer> </AppContainer>
<AppButton onClick={handleToggleApp}> <AppButton></AppButton>
<DayLogo className="max-h-full max-w-full"></DayLogo>
</AppButton>
<DanmakuContainer ref={danmakuContainerRef} /> <DanmakuContainer ref={danmakuContainerRef} />
</> </>
) )

View file

@ -7,7 +7,7 @@ import { defineContentScript } from 'wxt/sandbox'
import { createShadowRootUi } from 'wxt/client' import { createShadowRootUi } from 'wxt/client'
import App from './App' import App from './App'
import { IndexDBStorageImpl, BrowserSyncStorageImpl, indexDBStorage } from '@/domain/impls/Storage' import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom' import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import { DanmakuImpl } from '@/domain/impls/Danmaku' import { DanmakuImpl } from '@/domain/impls/Danmaku'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2' // import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
@ -23,7 +23,7 @@ export default defineContentScript({
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'], excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
async main(ctx) { async main(ctx) {
const store = Remesh.store({ const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl] externs: [LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl]
// inspectors: __DEV__ ? [RemeshLogger()] : [] // inspectors: __DEV__ ? [RemeshLogger()] : []
}) })

View file

@ -1,4 +1,4 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react' import { type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react' import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
@ -10,17 +10,25 @@ import UserInfoDomain from '@/domain/UserInfo'
import useClickAway from '@/hooks/useClickAway' import useClickAway from '@/hooks/useClickAway'
import { checkSystemDarkMode, cn } from '@/utils' import { checkSystemDarkMode, cn } from '@/utils'
import ToastDomain from '@/domain/Toast' import ToastDomain from '@/domain/Toast'
import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg'
import LogoIcon2 from '@/assets/images/logo-2.svg'
import LogoIcon3 from '@/assets/images/logo-3.svg'
import LogoIcon4 from '@/assets/images/logo-4.svg'
import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import AppStatusDomain from '@/domain/AppStatus'
import { getDay } from 'date-fns'
export interface AppButtonProps { const AppButton: FC = () => {
children?: ReactNode
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
}
const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
const send = useRemeshSend() const send = useRemeshSend()
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const toastDomain = useRemeshDomain(ToastDomain()) const toastDomain = useRemeshDomain(ToastDomain())
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
const isDarkMode = const isDarkMode =
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode() userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
@ -51,12 +59,16 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE) browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
} }
const handleToggleApp = () => {
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
}
return ( return (
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3"> <div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
<AnimatePresence> <AnimatePresence>
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
className="z-infinity grid gap-y-3" className="z-10 grid gap-y-3"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }} exit={{ opacity: 0, y: 12 }}
@ -90,11 +102,27 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
)} )}
</AnimatePresence> </AnimatePresence>
<Button <Button
onClick={onClick} onClick={handleToggleApp}
onContextMenu={handleToggleMenu} onContextMenu={handleToggleMenu}
className="relative z-10 size-11 overflow-hidden rounded-full p-0 text-xs shadow-lg shadow-slate-500/50" className="relative z-20 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50"
> >
{children} <AnimatePresence>
{hasUnreadQuery && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="absolute -right-1 -top-1 flex size-5 items-center justify-center"
>
<span
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
></span>
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
</motion.div>
)}
</AnimatePresence>
<DayLogo className="max-h-full max-w-full"></DayLogo>
</Button> </Button>
</div> </div>
) )

View file

@ -1,13 +1,17 @@
import { type ReactNode, type FC } from 'react' import { type ReactNode, type FC } from 'react'
import useResizable from '@/hooks/useResizable' import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import AppStatusDomain from '@/domain/AppStatus'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
export interface AppContainerProps { export interface AppContainerProps {
children?: ReactNode children?: ReactNode
open?: boolean
} }
const AppContainer: FC<AppContainerProps> = ({ children, open }) => { const AppContainer: FC<AppContainerProps> = ({ children }) => {
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const { size, ref } = useResizable({ const { size, ref } = useResizable({
initSize: Math.max(375, window.innerWidth / 6), initSize: Math.max(375, window.innerWidth / 6),
maxSize: Math.min(750, window.innerWidth / 3), maxSize: Math.min(750, window.innerWidth / 3),
@ -17,7 +21,7 @@ const AppContainer: FC<AppContainerProps> = ({ children, open }) => {
return ( return (
<AnimatePresence> <AnimatePresence>
{open && ( {appOpenStatus && (
<motion.div <motion.div
initial={{ opacity: 0, y: 10, x: 10 }} initial={{ opacity: 0, y: 10, x: 10 }}
animate={{ opacity: 1, y: 0, x: 0 }} animate={{ opacity: 1, y: 0, x: 0 }}

View file

@ -11,7 +11,6 @@ const Header: FC = () => {
const siteInfo = getSiteInfo() const siteInfo = getSiteInfo()
const roomDomain = useRemeshDomain(RoomDomain()) const roomDomain = useRemeshDomain(RoomDomain())
const userList = useRemeshQuery(roomDomain.query.UserListQuery()) const userList = useRemeshQuery(roomDomain.query.UserListQuery())
const peerId = useRemeshQuery(roomDomain.query.PeerIdQuery())
const onlineCount = userList.length const onlineCount = userList.length
return ( return (
@ -27,7 +26,6 @@ const Header: FC = () => {
<Button className="overflow-hidden" variant="link"> <Button className="overflow-hidden" variant="link">
<span className="truncate text-lg font-semibold text-slate-600"> <span className="truncate text-lg font-semibold text-slate-600">
{siteInfo.hostname.replace(/^www\./i, '')} {siteInfo.hostname.replace(/^www\./i, '')}
{/* {peerId} */}
</span> </span>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>

View file

@ -191,7 +191,7 @@ export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
export const APP_OPEN_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_OPEN_STATUS' as const export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_OPEN_STATUS' as const
/** /**
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb * In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* Image is encoded as base64, and the size is increased by about 33%. * Image is encoded as base64, and the size is increased by about 33%.

144
src/domain/AppStatus.ts Normal file
View file

@ -0,0 +1,144 @@
import { Remesh } from 'remesh'
import StatusModule from './modules/Status'
import { LocalStorageExtern } from './externs/Storage'
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
import StorageEffect from './modules/StorageEffect'
import RoomDomain, { SendType } from './Room'
import { map } from 'rxjs'
export interface AppStatus {
open: boolean
unread: number
}
export const defaultStatusState = {
open: false,
unread: 0
}
const AppStatusDomain = Remesh.domain({
name: 'AppStatusDomain',
impl: (domain) => {
const storageEffect = new StorageEffect({
domain,
extern: LocalStorageExtern,
key: APP_STATUS_STORAGE_KEY
})
const roomDomain = domain.getDomain(RoomDomain())
const StatusLoadModule = StatusModule(domain, {
name: 'AppStatus.LoadStatusModule'
})
const StatusLoadIsFinishedQuery = domain.query({
name: 'AppStatus.StatusLoadIsFinishedQuery',
impl: () => {
return StatusLoadModule.query.IsFinishedQuery()
}
})
const StatusState = domain.state<AppStatus>({
name: 'AppStatus.OpenState',
default: defaultStatusState
})
const OpenQuery = domain.query({
name: 'AppStatus.IsOpenQuery',
impl: ({ get }) => {
return get(StatusState()).open
}
})
const UnreadQuery = domain.query({
name: 'AppStatus.UnreadQuery',
impl: ({ get }) => {
return get(StatusState()).unread
}
})
const HasUnreadQuery = domain.query({
name: 'AppStatus.HasUnreadQuery',
impl: ({ get }) => {
return get(StatusState()).unread > 0
}
})
const UpdateOpenCommand = domain.command({
name: 'AppStatus.UpdateOpenCommand',
impl: ({ get }, value: boolean) => {
const status = get(StatusState())
return UpdateStatusCommand({
unread: value ? 0 : status.unread,
open: value
})
}
})
const UpdateUnreadCommand = domain.command({
name: 'AppStatus.UpdateUnreadCommand',
impl: ({ get }, value: number) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
unread: value
})
}
})
const UpdateStatusCommand = domain.command({
name: 'AppStatus.UpdateStatusCommand',
impl: (_, value: AppStatus) => {
return [StatusState().new(value), SyncToStorageEvent()]
}
})
const SyncToStorageEvent = domain.event({
name: 'UserInfo.SyncToStorageEvent',
impl: ({ get }) => {
return get(StatusState())
}
})
storageEffect
.set(SyncToStorageEvent)
.get<AppStatus>((value) => [
UpdateStatusCommand(value ?? defaultStatusState),
StatusLoadModule.command.SetFinishedCommand()
])
.watch<AppStatus>((value) => [UpdateStatusCommand(value ?? defaultStatusState)])
domain.effect({
name: 'OnMessageEffect',
impl: ({ fromEvent, get }) => {
const onMessage$ = fromEvent(roomDomain.event.OnMessageEvent).pipe(
map((message) => {
const status = get(StatusState())
if (!status.open && message.type === SendType.Text) {
return UpdateUnreadCommand(status.unread + 1)
}
return null
})
)
return onMessage$
}
})
return {
query: {
OpenQuery,
UnreadQuery,
HasUnreadQuery,
StatusLoadIsFinishedQuery
},
command: {
UpdateOpenCommand,
UpdateUnreadCommand
},
event: {
SyncToStorageEvent
}
}
}
})
export default AppStatusDomain

View file

@ -15,6 +15,30 @@ export interface Storage {
unwatch: Unwatch unwatch: Unwatch
} }
export const LocalStorageExtern = Remesh.extern<Storage>({
default: {
name: 'STORAGE',
get: async () => {
throw new Error('"get" not implemented.')
},
set: async () => {
throw new Error('"set" not implemented.')
},
remove: async () => {
throw new Error('"remove" not implemented.')
},
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}
})
export const IndexDBStorageExtern = Remesh.extern<Storage>({ export const IndexDBStorageExtern = Remesh.extern<Storage>({
default: { default: {
name: 'STORAGE', name: 'STORAGE',

View file

@ -1,11 +1,16 @@
import { createStorage } from 'unstorage' import { createStorage } from 'unstorage'
import indexedDbDriver from 'unstorage/drivers/indexedb' import indexedDbDriver from 'unstorage/drivers/indexedb'
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage' import localStorageDriver from 'unstorage/drivers/localstorage'
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants/config' import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver' import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { browser } from 'wxt/browser' import { browser } from 'wxt/browser'
import { Storage } from '@/domain/externs/Storage' import { Storage } from '@/domain/externs/Storage'
export const localStorage = createStorage({
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
})
export const indexDBStorage = createStorage({ export const indexDBStorage = createStorage({
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` }) driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
}) })
@ -14,6 +19,16 @@ export const browserSyncStorage = createStorage({
driver: webExtensionDriver({ storageArea: 'sync' }) driver: webExtensionDriver({ storageArea: 'sync' })
}) })
export const LocalStorageImpl = LocalStorageExtern.impl({
name: STORAGE_NAME,
get: localStorage.getItem,
set: localStorage.setItem,
remove: localStorage.removeItem,
clear: localStorage.clear,
watch: localStorage.watch as Storage['watch'],
unwatch: localStorage.unwatch
})
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({ export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
name: STORAGE_NAME, name: STORAGE_NAME,
get: indexDBStorage.getItem, get: indexDBStorage.getItem,