chore: watch userInfo storage
This commit is contained in:
parent
511338850e
commit
968480605c
13 changed files with 172 additions and 36 deletions
|
@ -2,6 +2,6 @@
|
|||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 160,
|
||||
"printWidth": 120,
|
||||
"tailwindFunction": ["clsx"]
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:signaling": "y-webrtc-signaling",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
|
|
|
@ -18,7 +18,7 @@ export default defineContentScript({
|
|||
async main(ctx) {
|
||||
const doc = new Y.Doc()
|
||||
// eslint-disable-next-line no-new
|
||||
new WebrtcProvider(__NAME__, doc)
|
||||
new WebrtcProvider(__NAME__, doc, { signaling: ['ws://localhost:4444'] })
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, RemeshYjsExtern.impl({ doc })],
|
||||
|
|
|
@ -1,20 +1,28 @@
|
|||
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useClickAway } from 'react-use'
|
||||
|
||||
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 { EVENTS } from '@/constants'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useClickAway from '@/hooks/useClickAway'
|
||||
|
||||
export interface AppButtonProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const AppButton: FC<AppButtonProps> = ({ children }) => {
|
||||
const send = useRemeshSend()
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
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)
|
||||
|
||||
|
@ -32,42 +40,46 @@ const AppButton: FC<AppButtonProps> = ({ children }) => {
|
|||
['click']
|
||||
)
|
||||
|
||||
const handleToggle = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const handleSwitchTheme = () => {
|
||||
if (userInfo) {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
browser.runtime.sendMessage(EVENTS.OPEN_OPTIONS_PAGE)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 && ''}>
|
||||
{/* <Button
|
||||
{/* <div className="grid gap-y-3" inert={!open && ''}> */}
|
||||
<div className="pointer-events-none grid gap-y-3">
|
||||
<Button
|
||||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
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} />
|
||||
</Button> */}
|
||||
<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} />
|
||||
{/* <SunIcon size={20} /> */}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenOptionsPage}
|
||||
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"
|
||||
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} />
|
||||
</Button>
|
||||
</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}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { type Output, object, string, minBytes, maxBytes, toTrimmed, union, literal, notLength, number } from 'valibot'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
@ -32,7 +31,11 @@ const formSchema = object({
|
|||
createTime: number(),
|
||||
// Pure numeric strings will be converted to number
|
||||
// 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.')]),
|
||||
themeMode: union([literal('system'), literal('light'), literal('dark')], 'Please select extension theme mode.')
|
||||
})
|
||||
|
@ -47,12 +50,13 @@ const ProfileForm = () => {
|
|||
defaultValues: userInfo ?? defaultUserInfo
|
||||
})
|
||||
|
||||
// Update defaultValues
|
||||
useEffect(() => {
|
||||
userInfo && form.reset(userInfo)
|
||||
}, [userInfo, form])
|
||||
|
||||
const handleSubmit = (userInfo: Output<typeof formSchema>) => {
|
||||
send(userInfoDomain.command.SetUserInfoCommand(userInfo))
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
|
||||
toast.success('Saved successfully!')
|
||||
}
|
||||
|
||||
|
@ -73,7 +77,13 @@ const ProfileForm = () => {
|
|||
render={({ field }) => (
|
||||
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center">
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -115,7 +125,9 @@ const ProfileForm = () => {
|
|||
</div>
|
||||
</RadioGroup>
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
|
@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef<
|
|||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ 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 (
|
||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
||||
<PopoverPrimitive.Content
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Remesh } from 'remesh'
|
|||
import { ListModule } from 'remesh/modules/list'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { from, map, tap, merge } from 'rxjs'
|
||||
import { RemeshYjs } from 'remesh-yjs'
|
||||
import { IndexDBStorageExtern } from './externs/Storage'
|
||||
|
||||
const MessageListDomain = Remesh.domain({
|
||||
|
@ -92,11 +93,24 @@ const MessageListDomain = Remesh.domain({
|
|||
domain.effect({
|
||||
name: 'FormStateToStorageEffect',
|
||||
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))
|
||||
}
|
||||
})
|
||||
|
||||
RemeshYjs(domain, {
|
||||
key: 'MessageList',
|
||||
dataType: 'array',
|
||||
onSend: ({ get }): Message[] => {
|
||||
return get(ListQuery())
|
||||
},
|
||||
onReceive: (_, messages: Message[]) => {
|
||||
return InitListCommand(messages)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
ItemQuery,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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 { isNullish } from '@/utils'
|
||||
import { isNullish, storageToObservable } from '@/utils'
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
name: 'UserInfoDomain',
|
||||
|
@ -27,8 +27,8 @@ const UserInfoDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const SetUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.SetUserInfoCommand',
|
||||
const UpdateUserInfoCommand = domain.command({
|
||||
name: 'UserInfo.UpdateUserInfoCommand',
|
||||
impl: (_, userInfo: UserInfo | null) => {
|
||||
return [UserInfoState().new(userInfo), UpdateUserInfoEvent()]
|
||||
}
|
||||
|
@ -59,9 +59,9 @@ const UserInfoDomain = Remesh.domain({
|
|||
!isNullish(userInfo.createTime) &&
|
||||
!isNullish(userInfo.themeMode)
|
||||
) {
|
||||
return SetUserInfoCommand(userInfo as UserInfo)
|
||||
return UpdateUserInfoCommand(userInfo as UserInfo)
|
||||
} 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['name'] | null>(storageKeys.USER_INFO_NAME, userInfo?.name ?? 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)
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
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 {
|
||||
query: {
|
||||
UserInfoQuery
|
||||
},
|
||||
command: {
|
||||
SetUserInfoCommand
|
||||
UpdateUserInfoCommand
|
||||
},
|
||||
event: {
|
||||
UpdateUserInfoEvent
|
||||
|
|
45
src/hooks/useClickAway.ts
Normal file
45
src/hooks/useClickAway.ts
Normal 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
|
5
src/types/global.d.ts
vendored
5
src/types/global.d.ts
vendored
|
@ -1,4 +1,5 @@
|
|||
declare interface Message {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
declare type Message = {
|
||||
id: string
|
||||
userId: string
|
||||
body: string
|
||||
|
@ -20,3 +21,5 @@ declare interface UserInfo {
|
|||
createTime: number
|
||||
themeMode: 'system' | 'light' | 'dark'
|
||||
}
|
||||
|
||||
declare type SafeAny = any
|
||||
|
|
|
@ -7,3 +7,4 @@ export { default as chunk } from './chunk'
|
|||
export { default as compressImage } from './compressImage'
|
||||
export { default as isNullish } from './isNullish'
|
||||
export { default as checkSystemDarkMode } from './checkSystemDarkMode'
|
||||
export { default as storageToObservable } from './storageToObservable'
|
||||
|
|
|
@ -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
|
||||
|
|
15
src/utils/storageToObservable.ts
Normal file
15
src/utils/storageToObservable.ts
Normal 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
|
Loading…
Reference in a new issue