perf: support unread status
This commit is contained in:
parent
65a320ab35
commit
1f44af873c
9 changed files with 238 additions and 56 deletions
|
@ -8,22 +8,11 @@ import RoomDomain from '@/domain/Room'
|
|||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import Setup from '@/app/content/views/Setup'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
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 DanmakuDomain from '@/domain/Danmaku'
|
||||
import { browser } from 'wxt/browser'
|
||||
|
||||
export default function App() {
|
||||
const send = useRemeshSend()
|
||||
|
@ -32,13 +21,11 @@ export default function App() {
|
|||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
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(() => {
|
||||
if (messageListLoadFinished) {
|
||||
|
@ -51,23 +38,6 @@ export default function App() {
|
|||
}
|
||||
}, [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)
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -76,19 +46,18 @@ export default function App() {
|
|||
danmakuIsEnabled && send(danmakuDomain.command.DestroyCommand())
|
||||
}
|
||||
}, [danmakuIsEnabled])
|
||||
console.log(1)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer open={appOpen}>
|
||||
<AppContainer>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
{notUserInfo && <Setup />}
|
||||
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
|
||||
</AppContainer>
|
||||
<AppButton onClick={handleToggleApp}>
|
||||
<DayLogo className="max-h-full max-w-full"></DayLogo>
|
||||
</AppButton>
|
||||
<AppButton></AppButton>
|
||||
<DanmakuContainer ref={danmakuContainerRef} />
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -7,7 +7,7 @@ import { defineContentScript } from 'wxt/sandbox'
|
|||
import { createShadowRootUi } from 'wxt/client'
|
||||
|
||||
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 { DanmakuImpl } from '@/domain/impls/Danmaku'
|
||||
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||
|
@ -23,7 +23,7 @@ export default defineContentScript({
|
|||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
|
||||
async main(ctx) {
|
||||
const store = Remesh.store({
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl]
|
||||
externs: [LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl, DanmakuImpl]
|
||||
// inspectors: __DEV__ ? [RemeshLogger()] : []
|
||||
})
|
||||
|
||||
|
|
|
@ -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 { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
|
@ -10,17 +10,25 @@ import UserInfoDomain from '@/domain/UserInfo'
|
|||
import useClickAway from '@/hooks/useClickAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
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 {
|
||||
children?: ReactNode
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
|
||||
const AppButton: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||
|
||||
const isDarkMode =
|
||||
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)
|
||||
}
|
||||
|
||||
const handleToggleApp = () => {
|
||||
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
className="z-infinity grid gap-y-3"
|
||||
className="z-10 grid gap-y-3"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
|
@ -90,11 +102,27 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
|
|||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
onClick={handleToggleApp}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { type ReactNode, type FC } from 'react'
|
||||
import useResizable from '@/hooks/useResizable'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
|
||||
export interface AppContainerProps {
|
||||
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({
|
||||
initSize: Math.max(375, window.innerWidth / 6),
|
||||
maxSize: Math.min(750, window.innerWidth / 3),
|
||||
|
@ -17,7 +21,7 @@ const AppContainer: FC<AppContainerProps> = ({ children, open }) => {
|
|||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
{appOpenStatus && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, x: 10 }}
|
||||
animate={{ opacity: 1, y: 0, x: 0 }}
|
||||
|
|
|
@ -11,7 +11,6 @@ const Header: FC = () => {
|
|||
const siteInfo = getSiteInfo()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const peerId = useRemeshQuery(roomDomain.query.PeerIdQuery())
|
||||
const onlineCount = userList.length
|
||||
|
||||
return (
|
||||
|
@ -27,7 +26,6 @@ const Header: FC = () => {
|
|||
<Button className="overflow-hidden" variant="link">
|
||||
<span className="truncate text-lg font-semibold text-slate-600">
|
||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||
{/* {peerId} */}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
|
|
|
@ -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 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
|
||||
* Image is encoded as base64, and the size is increased by about 33%.
|
||||
|
|
144
src/domain/AppStatus.ts
Normal file
144
src/domain/AppStatus.ts
Normal 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
|
|
@ -15,6 +15,30 @@ export interface Storage {
|
|||
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>({
|
||||
default: {
|
||||
name: 'STORAGE',
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import { createStorage } from 'unstorage'
|
||||
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 { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||
import { browser } from 'wxt/browser'
|
||||
import { Storage } from '@/domain/externs/Storage'
|
||||
|
||||
export const localStorage = createStorage({
|
||||
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
|
||||
})
|
||||
|
||||
export const indexDBStorage = createStorage({
|
||||
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
|
||||
})
|
||||
|
@ -14,6 +19,16 @@ export const browserSyncStorage = createStorage({
|
|||
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({
|
||||
name: STORAGE_NAME,
|
||||
get: indexDBStorage.getItem,
|
||||
|
|
Loading…
Reference in a new issue