diff --git a/.prettierrc b/.prettierrc index 1bc57c5..806e3c5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,6 @@ "semi": false, "singleQuote": true, "trailingComma": "none", - "printWidth": 160, + "printWidth": 120, "tailwindFunction": ["clsx"] } diff --git a/package.json b/package.json index 06aa5b2..fc0da1c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/content/index.tsx b/src/app/content/index.tsx index 6887edd..59aacc2 100644 --- a/src/app/content/index.tsx +++ b/src/app/content/index.tsx @@ -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 })], diff --git a/src/app/content/views/AppButton/index.tsx b/src/app/content/views/AppButton/index.tsx index 22f7771..9d30548 100644 --- a/src/app/content/views/AppButton/index.tsx +++ b/src/app/content/views/AppButton/index.tsx @@ -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 = ({ 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 = ({ children }) => { ['click'] ) - const handleToggle = (e: MouseEvent) => { + const handleToggleMenu = (e: MouseEvent) => { 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 (
-
- {/* */} -
-
diff --git a/src/app/options/components/ProfileForm.tsx b/src/app/options/components/ProfileForm.tsx index ff40896..8c785d8 100644 --- a/src/app/options/components/ProfileForm.tsx +++ b/src/app/options/components/ProfileForm.tsx @@ -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) => { - send(userInfoDomain.command.SetUserInfoCommand(userInfo)) + send(userInfoDomain.command.UpdateUserInfoCommand(userInfo)) toast.success('Saved successfully!') } @@ -73,7 +77,13 @@ const ProfileForm = () => { render={({ field }) => ( - + @@ -115,7 +125,9 @@ const ProfileForm = () => { - The theme mode of the extension. If you choose the system, will follow the system theme. + + The theme mode of the extension. If you choose the system, will follow the system theme. + )} diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx index 2c85928..c6549cd 100644 --- a/src/components/ui/Popover.tsx +++ b/src/components/ui/Popover.tsx @@ -10,7 +10,7 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ 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 ( { - const changeList$ = fromEvent(ChangeListEvent).pipe(tap(async (messages) => await storage.set(storageKey, messages))) + const changeList$ = fromEvent(ChangeListEvent).pipe( + tap(async (messages) => await storage.set(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, diff --git a/src/domain/UserInfo.ts b/src/domain/UserInfo.ts index 82fcaef..aa26bfc 100644 --- a/src/domain/UserInfo.ts +++ b/src/domain/UserInfo.ts @@ -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(storageKeys.USER_INFO_ID, userInfo?.id ?? null), storage.set(storageKeys.USER_INFO_NAME, userInfo?.name ?? null), storage.set(storageKeys.USER_INFO_AVATAR, userInfo?.avatar ?? null), - storage.set(storageKeys.USER_INFO_CREATE_TIME, userInfo?.createTime ?? null), + storage.set( + storageKeys.USER_INFO_CREATE_TIME, + userInfo?.createTime ?? null + ), storage.set(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(storageKeys.USER_INFO_ID)), + name: from(storage.get(storageKeys.USER_INFO_NAME)), + avatar: from(storage.get(storageKeys.USER_INFO_AVATAR)), + createTime: from(storage.get(storageKeys.USER_INFO_CREATE_TIME)), + themeMode: from(storage.get(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 diff --git a/src/hooks/useClickAway.ts b/src/hooks/useClickAway.ts new file mode 100644 index 0000000..fcf07e0 --- /dev/null +++ b/src/hooks/useClickAway.ts @@ -0,0 +1,45 @@ +import { type RefObject, useEffect, useRef } from 'react' + +export type Events = Array + +const useClickAway = ( + ref: RefObject, + 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 diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a1eaf59..d065121 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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 diff --git a/src/utils/index.ts b/src/utils/index.ts index f6d0181..a9e546e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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' diff --git a/src/utils/isNullish.ts b/src/utils/isNullish.ts index 22e980e..43c3e53 100644 --- a/src/utils/isNullish.ts +++ b/src/utils/isNullish.ts @@ -1,3 +1,3 @@ -const isNullish = (value: any) => value === undefined || value === null +const isNullish = (value: T) => value === undefined || value === null export default isNullish diff --git a/src/utils/storageToObservable.ts b/src/utils/storageToObservable.ts new file mode 100644 index 0000000..a8b80ac --- /dev/null +++ b/src/utils/storageToObservable.ts @@ -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