chore: watch userInfo storage

This commit is contained in:
molvqingtai 2023-12-05 03:56:34 +08:00
parent 511338850e
commit 968480605c
13 changed files with 172 additions and 36 deletions

View file

@ -2,6 +2,6 @@
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 160, "printWidth": 120,
"tailwindFunction": ["clsx"] "tailwindFunction": ["clsx"]
} }

View file

@ -6,6 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
"dev:signaling": "y-webrtc-signaling",
"dev:firefox": "wxt -b firefox", "dev:firefox": "wxt -b firefox",
"build": "wxt build", "build": "wxt build",
"build:firefox": "wxt build -b firefox", "build:firefox": "wxt build -b firefox",

View file

@ -18,7 +18,7 @@ export default defineContentScript({
async main(ctx) { async main(ctx) {
const doc = new Y.Doc() const doc = new Y.Doc()
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new WebrtcProvider(__NAME__, doc) new WebrtcProvider(__NAME__, doc, { signaling: ['ws://localhost:4444'] })
const store = Remesh.store({ const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, RemeshYjsExtern.impl({ doc })], externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, RemeshYjsExtern.impl({ doc })],

View file

@ -1,20 +1,28 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react' import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react' import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { useClickAway } from 'react-use'
import { browser } from 'wxt/browser' import { browser } from 'wxt/browser'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EVENTS } from '@/constants' import { EVENTS } from '@/constants'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import useClickAway from '@/hooks/useClickAway'
export interface AppButtonProps { export interface AppButtonProps {
children?: ReactNode children?: ReactNode
} }
const AppButton: FC<AppButtonProps> = ({ children }) => { const AppButton: FC<AppButtonProps> = ({ children }) => {
const send = useRemeshSend()
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
console.log(userInfo)
const isDarkMode =
userInfo?.themeMode === 'dark'
? true
: userInfo?.themeMode === 'light'
? false
: window.matchMedia('(prefers-color-scheme: dark)').matches
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -32,42 +40,46 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
['click'] ['click']
) )
const handleToggle = (e: MouseEvent<HTMLButtonElement>) => { const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault() e.preventDefault()
setOpen(!open) setOpen(!open)
} }
const handleSwitchTheme = () => {
if (userInfo) {
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
} else {
// TODO
}
}
const handleOpenOptionsPage = () => { const handleOpenOptionsPage = () => {
browser.runtime.sendMessage(EVENTS.OPEN_OPTIONS_PAGE) browser.runtime.sendMessage(EVENTS.OPEN_OPTIONS_PAGE)
} }
return ( return (
<div ref={menuRef} className="fixed bottom-5 right-5 z-top grid select-none justify-center gap-y-3"> <div ref={menuRef} className="fixed bottom-5 right-5 z-top grid select-none justify-center gap-y-3">
<div className="grid gap-y-3" inert={!open && ''}> {/* <div className="grid gap-y-3" inert={!open && ''}> */}
{/* <Button <div className="pointer-events-none grid gap-y-3">
<Button
onClick={handleSwitchTheme}
variant="outline" variant="outline"
data-state={open ? 'open' : 'closed'} data-state={open ? 'open' : 'closed'}
className="h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom" className="pointer-events-auto h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom"
> >
<MoonIcon size={20} /> <MoonIcon size={20} />
</Button> */} {/* <SunIcon size={20} /> */}
<Button
variant="outline"
data-state={open ? 'open' : 'closed'}
className="h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom"
>
<SunIcon size={20} />
</Button> </Button>
<Button <Button
onClick={handleOpenOptionsPage} onClick={handleOpenOptionsPage}
variant="outline" variant="outline"
data-state={open ? 'open' : 'closed'} data-state={open ? 'open' : 'closed'}
className="h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom" className="pointer-events-auto h-10 w-10 rounded-full p-0 shadow fill-mode-forwards data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom"
> >
<SettingsIcon size={20} /> <SettingsIcon size={20} />
</Button> </Button>
</div> </div>
<Button onContextMenu={handleToggle} className="relative z-10 h-10 w-10 rounded-full p-0 text-xs shadow"> <Button onContextMenu={handleToggleMenu} className="relative z-10 h-10 w-10 rounded-full p-0 text-xs shadow">
{children} {children}
</Button> </Button>
</div> </div>

View file

@ -1,7 +1,6 @@
import { type Output, object, string, minBytes, maxBytes, toTrimmed, union, literal, notLength, number } from 'valibot' import { type Output, object, string, minBytes, maxBytes, toTrimmed, union, literal, notLength, number } from 'valibot'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot' import { valibotResolver } from '@hookform/resolvers/valibot'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
@ -32,7 +31,11 @@ const formSchema = object({
createTime: number(), createTime: number(),
// Pure numeric strings will be converted to number // Pure numeric strings will be converted to number
// Issues: https://github.com/unjs/unstorage/issues/277 // Issues: https://github.com/unjs/unstorage/issues/277
name: string([toTrimmed(), minBytes(1, 'Please enter your username.'), maxBytes(20, 'Your username cannot exceed 20 bytes.')]), name: string([
toTrimmed(),
minBytes(1, 'Please enter your username.'),
maxBytes(20, 'Your username cannot exceed 20 bytes.')
]),
avatar: string([notLength(0, 'Please select your avatar.'), maxBytes(8 * 1024, 'Your avatar cannot exceed 8kb.')]), avatar: string([notLength(0, 'Please select your avatar.'), maxBytes(8 * 1024, 'Your avatar cannot exceed 8kb.')]),
themeMode: union([literal('system'), literal('light'), literal('dark')], 'Please select extension theme mode.') themeMode: union([literal('system'), literal('light'), literal('dark')], 'Please select extension theme mode.')
}) })
@ -47,12 +50,13 @@ const ProfileForm = () => {
defaultValues: userInfo ?? defaultUserInfo defaultValues: userInfo ?? defaultUserInfo
}) })
// Update defaultValues
useEffect(() => { useEffect(() => {
userInfo && form.reset(userInfo) userInfo && form.reset(userInfo)
}, [userInfo, form]) }, [userInfo, form])
const handleSubmit = (userInfo: Output<typeof formSchema>) => { const handleSubmit = (userInfo: Output<typeof formSchema>) => {
send(userInfoDomain.command.SetUserInfoCommand(userInfo)) send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
toast.success('Saved successfully!') toast.success('Saved successfully!')
} }
@ -73,7 +77,13 @@ const ProfileForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center"> <FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center">
<FormControl> <FormControl>
<AvatarSelect compressSize={COMPRESS_SIZE} onError={handleError} onWarning={handleWarning} className="shadow-lg" {...field}></AvatarSelect> <AvatarSelect
compressSize={COMPRESS_SIZE}
onError={handleError}
onWarning={handleWarning}
className="shadow-lg"
{...field}
></AvatarSelect>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -115,7 +125,9 @@ const ProfileForm = () => {
</div> </div>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
<FormDescription>The theme mode of the extension. If you choose the system, will follow the system theme.</FormDescription> <FormDescription>
The theme mode of the extension. If you choose the system, will follow the system theme.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => { >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as any as HTMLElement const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
return ( return (
<PopoverPrimitive.Portal container={shadowRoot}> <PopoverPrimitive.Portal container={shadowRoot}>
<PopoverPrimitive.Content <PopoverPrimitive.Content

View file

@ -2,6 +2,7 @@ import { Remesh } from 'remesh'
import { ListModule } from 'remesh/modules/list' import { ListModule } from 'remesh/modules/list'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { from, map, tap, merge } from 'rxjs' import { from, map, tap, merge } from 'rxjs'
import { RemeshYjs } from 'remesh-yjs'
import { IndexDBStorageExtern } from './externs/Storage' import { IndexDBStorageExtern } from './externs/Storage'
const MessageListDomain = Remesh.domain({ const MessageListDomain = Remesh.domain({
@ -92,11 +93,24 @@ const MessageListDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'FormStateToStorageEffect', name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => { impl: ({ fromEvent }) => {
const changeList$ = fromEvent(ChangeListEvent).pipe(tap(async (messages) => await storage.set<Message[]>(storageKey, messages))) const changeList$ = fromEvent(ChangeListEvent).pipe(
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
)
return merge(changeList$).pipe(map(() => null)) return merge(changeList$).pipe(map(() => null))
} }
}) })
RemeshYjs(domain, {
key: 'MessageList',
dataType: 'array',
onSend: ({ get }): Message[] => {
return get(ListQuery())
},
onReceive: (_, messages: Message[]) => {
return InitListCommand(messages)
}
})
return { return {
query: { query: {
ItemQuery, ItemQuery,

View file

@ -1,7 +1,7 @@
import { Remesh } from 'remesh' import { Remesh } from 'remesh'
import { forkJoin, from, map, merge, tap } from 'rxjs' import { forkJoin, from, map, merge, switchMap, tap } from 'rxjs'
import { BrowserSyncStorageExtern } from './externs/Storage' import { BrowserSyncStorageExtern } from './externs/Storage'
import { isNullish } from '@/utils' import { isNullish, storageToObservable } from '@/utils'
const UserInfoDomain = Remesh.domain({ const UserInfoDomain = Remesh.domain({
name: 'UserInfoDomain', name: 'UserInfoDomain',
@ -27,8 +27,8 @@ const UserInfoDomain = Remesh.domain({
} }
}) })
const SetUserInfoCommand = domain.command({ const UpdateUserInfoCommand = domain.command({
name: 'UserInfo.SetUserInfoCommand', name: 'UserInfo.UpdateUserInfoCommand',
impl: (_, userInfo: UserInfo | null) => { impl: (_, userInfo: UserInfo | null) => {
return [UserInfoState().new(userInfo), UpdateUserInfoEvent()] return [UserInfoState().new(userInfo), UpdateUserInfoEvent()]
} }
@ -59,9 +59,9 @@ const UserInfoDomain = Remesh.domain({
!isNullish(userInfo.createTime) && !isNullish(userInfo.createTime) &&
!isNullish(userInfo.themeMode) !isNullish(userInfo.themeMode)
) { ) {
return SetUserInfoCommand(userInfo as UserInfo) return UpdateUserInfoCommand(userInfo as UserInfo)
} else { } else {
return SetUserInfoCommand(null) return UpdateUserInfoCommand(null)
} }
}) })
) )
@ -77,22 +77,55 @@ const UserInfoDomain = Remesh.domain({
storage.set<UserInfo['id'] | null>(storageKeys.USER_INFO_ID, userInfo?.id ?? null), storage.set<UserInfo['id'] | null>(storageKeys.USER_INFO_ID, userInfo?.id ?? null),
storage.set<UserInfo['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? null), storage.set<UserInfo['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? null),
storage.set<UserInfo['avatar'] | null>(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null), storage.set<UserInfo['avatar'] | null>(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null),
storage.set<UserInfo['createTime'] | null>(storageKeys.USER_INFO_CREATE_TIME, userInfo?.createTime ?? null), storage.set<UserInfo['createTime'] | null>(
storageKeys.USER_INFO_CREATE_TIME,
userInfo?.createTime ?? null
),
storage.set<UserInfo['themeMode'] | null>(storageKeys.USER_INFO_THEME_MODE, userInfo?.themeMode ?? null) storage.set<UserInfo['themeMode'] | null>(storageKeys.USER_INFO_THEME_MODE, userInfo?.themeMode ?? null)
]) ])
}) })
) )
return merge(changeUserInfo$).pipe(map(() => null)) return merge(changeUserInfo$).pipe(map(() => null))
} }
}) })
domain.effect({
name: 'WatchStorageEffect',
impl: () => {
return storageToObservable(storage).pipe(
switchMap(() => {
return forkJoin({
id: from(storage.get<UserInfo['id']>(storageKeys.USER_INFO_ID)),
name: from(storage.get<UserInfo['name']>(storageKeys.USER_INFO_NAME)),
avatar: from(storage.get<UserInfo['avatar']>(storageKeys.USER_INFO_AVATAR)),
createTime: from(storage.get<UserInfo['createTime']>(storageKeys.USER_INFO_CREATE_TIME)),
themeMode: from(storage.get<UserInfo['themeMode']>(storageKeys.USER_INFO_THEME_MODE))
}).pipe(
map((userInfo) => {
if (
!isNullish(userInfo.id) &&
!isNullish(userInfo.name) &&
!isNullish(userInfo.avatar) &&
!isNullish(userInfo.createTime) &&
!isNullish(userInfo.themeMode)
) {
return UpdateUserInfoCommand(userInfo as UserInfo)
} else {
return UpdateUserInfoCommand(null)
}
})
)
})
)
}
})
return { return {
query: { query: {
UserInfoQuery UserInfoQuery
}, },
command: { command: {
SetUserInfoCommand UpdateUserInfoCommand
}, },
event: { event: {
UpdateUserInfoEvent UpdateUserInfoEvent

45
src/hooks/useClickAway.ts Normal file
View file

@ -0,0 +1,45 @@
import { type RefObject, useEffect, useRef } from 'react'
export type Events = Array<keyof GlobalEventHandlersEventMap>
const useClickAway = <E extends Event = Event>(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: E) => void,
events: Events = ['mousedown', 'touchstart']
) => {
const savedCallback = useRef(onClickAway)
useEffect(() => {
savedCallback.current = onClickAway
}, [onClickAway])
useEffect(() => {
const { current: el } = ref
if (!el) return
const rootNode = el.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
/**
* When events are captured outside the component, events that occur in shadow DOM will target the host element
* so additional event listeners need to be added for shadowDom
*
* document shadowDom target
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const handler = (event: SafeAny) => {
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
}
for (const eventName of events) {
document.addEventListener(eventName, handler)
isInShadow && rootNode.addEventListener(eventName, handler)
}
return () => {
for (const eventName of events) {
document.removeEventListener(eventName, handler)
isInShadow && rootNode.removeEventListener(eventName, handler)
}
}
}, [events, ref])
}
export default useClickAway

View file

@ -1,4 +1,5 @@
declare interface Message { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
declare type Message = {
id: string id: string
userId: string userId: string
body: string body: string
@ -20,3 +21,5 @@ declare interface UserInfo {
createTime: number createTime: number
themeMode: 'system' | 'light' | 'dark' themeMode: 'system' | 'light' | 'dark'
} }
declare type SafeAny = any

View file

@ -7,3 +7,4 @@ export { default as chunk } from './chunk'
export { default as compressImage } from './compressImage' export { default as compressImage } from './compressImage'
export { default as isNullish } from './isNullish' export { default as isNullish } from './isNullish'
export { default as checkSystemDarkMode } from './checkSystemDarkMode' export { default as checkSystemDarkMode } from './checkSystemDarkMode'
export { default as storageToObservable } from './storageToObservable'

View file

@ -1,3 +1,3 @@
const isNullish = (value: any) => value === undefined || value === null const isNullish = <T = any>(value: T) => value === undefined || value === null
export default isNullish export default isNullish

View file

@ -0,0 +1,15 @@
import { Observable } from 'rxjs'
import { type Storage } from '@/domain/externs/Storage'
const storageToObservable = (storage: Storage) => {
return new Observable((subscriber) => {
storage.watch((event) => {
subscriber.next(event)
})
return () => {
storage.unwatch()
}
})
}
export default storageToObservable