diff --git a/eslint.config.ts b/eslint.config.ts index 6393e1a..a7eb001 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -24,7 +24,7 @@ export default [ } }, { - ignores: ['**/.output/*', '**/.wxt/*', '**/ui/**', '**/lib/**', '**.million**'] + ignores: ['**/.output/*', '**/.wxt/*', '**/ui/**', '**/magicui/**', '**/lib/**', '**.million**'] }, { rules: { @@ -33,7 +33,8 @@ export default [ '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-unused-expressions': 'off', '@eslint-react/no-array-index-key': 'off', - '@eslint-react/hooks-extra/no-redundant-custom-hook': 'off' + '@eslint-react/hooks-extra/no-redundant-custom-hook': 'off', + '@eslint-react/dom/no-missing-button-type': 'off' } } ] diff --git a/package.json b/package.json index c8dfe79..6c2d594 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "homepage": "https://github.com/molvqingtai/WebChat#readme", "dependencies": { "@hookform/resolvers": "^3.9.0", + "@lottiefiles/dotlottie-react": "^0.9.0", "@perfsee/jsonr": "^1.13.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -64,6 +65,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^11.5.6", "idb-keyval": "^6.2.1", "lucide-react": "^0.445.0", "nanoid": "^5.0.7", @@ -94,8 +96,8 @@ "@eslint-react/eslint-plugin": "^1.14.2", "@eslint/js": "^9.11.1", "@types/eslint": "^9.6.1", - "@types/eslint__js": "^8.42.3", "@types/eslint-plugin-tailwindcss": "^3.17.0", + "@types/eslint__js": "^8.42.3", "@types/node": "^22.6.1", "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aef4a7..55519e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.53.0(react@18.3.1)) + '@lottiefiles/dotlottie-react': + specifier: ^0.9.0 + version: 0.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@perfsee/jsonr': specifier: ^1.13.0 version: 1.13.0 @@ -68,6 +71,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + framer-motion: + specifier: ^11.5.6 + version: 11.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) idb-keyval: specifier: ^6.2.1 version: 6.2.1 @@ -1118,6 +1124,15 @@ packages: '@libp2p/websockets@8.2.0': resolution: {integrity: sha512-UNjqkQ8/emnYswp1ohIIuZCnhI5DlvWF9IaIND2MoTCDavi7yubWfMp8jSWBsAqPnMeLMO8MQ6YlOo4FFC104Q==} + '@lottiefiles/dotlottie-react@0.9.0': + resolution: {integrity: sha512-6vR3XA7YWWvv76TkNPKC6Hc4CU0WXJlVydYmEezgi6RZS+Au3IUsCxZBr/3Zt2JPvtS3mttvP4X7tfeTA+414g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@lottiefiles/dotlottie-web@0.34.0': + resolution: {integrity: sha512-lKayn0IaqFKcnLGxJKKaDlxwKqKBpHzZ7COMEZ0HYhd3cbI9GFJb9YDUgtdpzTH5uL9Eqnr19myWYmEXHPm9pQ==} + '@multiformats/dns@1.0.6': resolution: {integrity: sha512-nt/5UqjMPtyvkG9BQYdJ4GfLK3nMqGpFZOzf4hAmIa0sJh2LlS9YKXZ4FgwBDsaHvzZqR/rUFIywIc7pkHNNuw==} @@ -3229,6 +3244,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@11.5.6: + resolution: {integrity: sha512-JMwUpAxv/DWgul9vPgX0ElKn0G66sUc6O9tOXsYwn3zxwvhxFljSXC0XT2QCzuTYBshwC8nyDAa1SYcV0Ldbhw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -7294,6 +7323,14 @@ snapshots: - bufferutil - utf-8-validate + '@lottiefiles/dotlottie-react@0.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@lottiefiles/dotlottie-web': 0.34.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@lottiefiles/dotlottie-web@0.34.0': {} + '@multiformats/dns@1.0.6': dependencies: '@types/dns-packet': 5.6.5 @@ -9725,6 +9762,13 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@11.5.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + tslib: 2.7.0 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fs-constants@1.0.0: {} fs-extra@11.2.0: diff --git a/src/app/content/App.tsx b/src/app/content/App.tsx index 3f49525..43f02c5 100644 --- a/src/app/content/App.tsx +++ b/src/app/content/App.tsx @@ -16,22 +16,17 @@ export default function App() { const roomDomain = useRemeshDomain(RoomDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain()) const messageListDomain = useRemeshDomain(MessageListDomain()) - const roomFinished = useRemeshQuery(roomDomain.query.IsFinishedQuery()) - const userInfoFinished = useRemeshQuery(userInfoDomain.query.IsFinishedQuery()) + const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery()) + const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery()) + const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery()) - const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) - - const notUserInfo = userInfoFinished && !userInfo + const notUserInfo = userInfoLoadFinished && !userInfoSetFinished useEffect(() => { - if (userInfoFinished) { - if (userInfo) { - !roomFinished && send(roomDomain.command.JoinRoomCommand()) - } else { - send(messageListDomain.command.ClearListCommand()) - } + if (userInfoSetFinished && messageListLoadFinished) { + send(roomDomain.command.JoinRoomCommand()) } - }, [userInfoFinished, userInfo, roomFinished]) + }, [userInfoSetFinished, messageListLoadFinished]) return ( <> diff --git a/src/app/content/components/MessageItem.tsx b/src/app/content/components/MessageItem.tsx index c5ee485..6948087 100644 --- a/src/app/content/components/MessageItem.tsx +++ b/src/app/content/components/MessageItem.tsx @@ -37,7 +37,9 @@ const MessageItem: FC = (props) => {
-
{props.data.username}
+
+ {props.data.username} +
diff --git a/src/app/content/index.tsx b/src/app/content/index.tsx index 48522ed..96b85ab 100644 --- a/src/app/content/index.tsx +++ b/src/app/content/index.tsx @@ -20,13 +20,14 @@ export default defineContentScript({ async main(ctx) { const store = Remesh.store({ externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl], - inspectors: __DEV__ ? [RemeshLogger()] : [] + inspectors: !__DEV__ ? [RemeshLogger()] : [] }) const ui = await createShadowRootUi(ctx, { name: __NAME__, position: 'inline', anchor: 'body', + isolateEvents: ['scroll', 'click'], mode: __DEV__ ? 'open' : 'closed', onMount: (container) => { const app = createElement('
') @@ -34,11 +35,11 @@ export default defineContentScript({ const root = createRoot(app) root.render( - - - - - + // + + + + // ) return root }, diff --git a/src/app/content/views/Header/index.tsx b/src/app/content/views/Header/index.tsx index 62eeed2..58dea61 100644 --- a/src/app/content/views/Header/index.tsx +++ b/src/app/content/views/Header/index.tsx @@ -25,7 +25,7 @@ const Header: FC = () => { + + + + + + + + +
+
@
+ +
+ Start chatting
) diff --git a/src/app/options/components/ProfileForm.tsx b/src/app/options/components/ProfileForm.tsx index fa4ddd5..b6dc4ac 100644 --- a/src/app/options/components/ProfileForm.tsx +++ b/src/app/options/components/ProfileForm.tsx @@ -15,6 +15,7 @@ import { Label } from '@/components/ui/Label' import { RefreshCcwIcon } from 'lucide-react' import { MAX_AVATAR_SIZE } from '@/constants/config' import ToastDomain from '@/domain/Toast' +import BlurFade from '@/components/magicui/blur-fade' const defaultUserInfo: UserInfo = { id: nanoid(), @@ -94,13 +95,15 @@ const ProfileForm = () => { render={({ field }) => ( - + + + diff --git a/src/components/magicui/blur-fade.tsx b/src/components/magicui/blur-fade.tsx new file mode 100644 index 0000000..941f3f4 --- /dev/null +++ b/src/components/magicui/blur-fade.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useRef } from 'react' +import { AnimatePresence, motion, useInView, UseInViewOptions, Variants } from 'framer-motion' + +type MarginType = UseInViewOptions['margin'] + +interface BlurFadeProps { + children: React.ReactNode + className?: string + variant?: { + hidden: { y: number } + visible: { y: number } + } + duration?: number + delay?: number + yOffset?: number + inView?: boolean + inViewMargin?: MarginType + blur?: string +} + +export default function BlurFade({ + children, + className, + variant, + duration = 0.4, + delay = 0, + yOffset = 6, + inView = false, + inViewMargin = '-50px', + blur = '6px' +}: BlurFadeProps) { + const ref = useRef(null) + const inViewResult = useInView(ref, { once: true, margin: inViewMargin }) + const isInView = !inView || inViewResult + const defaultVariants: Variants = { + hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` }, + visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` } + } + const combinedVariants = variant || defaultVariants + return ( + + + {children} + + + ) +} diff --git a/src/components/magicui/pulsating-button.tsx b/src/components/magicui/pulsating-button.tsx new file mode 100644 index 0000000..36410f3 --- /dev/null +++ b/src/components/magicui/pulsating-button.tsx @@ -0,0 +1,37 @@ +'use client' + +import React from 'react' + +import { cn } from '@/utils/index' + +interface PulsatingButtonProps extends React.ButtonHTMLAttributes { + pulseColor?: string + duration?: string +} + +export default function PulsatingButton({ + className, + children, + pulseColor = '#0f172a50', + duration = '1.5s', + ...props +}: PulsatingButtonProps) { + return ( + + ) +} diff --git a/src/components/magicui/word-pull-up.tsx b/src/components/magicui/word-pull-up.tsx new file mode 100644 index 0000000..2747b71 --- /dev/null +++ b/src/components/magicui/word-pull-up.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { motion, Variants } from "framer-motion"; + +import { cn } from "@/utils/index"; + +interface WordPullUpProps { + words: string; + delayMultiple?: number; + wrapperFramerProps?: Variants; + framerProps?: Variants; + className?: string; +} + +export default function WordPullUp({ + words, + wrapperFramerProps = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.2, + }, + }, + }, + framerProps = { + hidden: { y: 20, opacity: 0 }, + show: { y: 0, opacity: 1 }, + }, + className, +}: WordPullUpProps) { + return ( + + {words.split(" ").map((word, i) => ( + + {word === "" ?   : word} + + ))} + + ); +} diff --git a/src/domain/MessageList.ts b/src/domain/MessageList.ts index df7cd65..09a426e 100644 --- a/src/domain/MessageList.ts +++ b/src/domain/MessageList.ts @@ -2,6 +2,7 @@ import { Remesh } from 'remesh' import { ListModule } from 'remesh/modules/list' import { IndexDBStorageExtern } from '@/domain/externs/Storage' import StorageEffect from '@/domain/modules/StorageEffect' +import StatusModule from './modules/Status' export enum MessageType { Normal = 'normal', @@ -48,6 +49,10 @@ const MessageListDomain = Remesh.domain({ key: (message) => message.id }) + const MessageListLoadStatusModule = StatusModule(domain, { + name: 'MessageListLoadStatusModule' + }) + const ListQuery = MessageListModule.query.ItemListQuery const ItemQuery = MessageListModule.query.ItemQuery @@ -140,14 +145,18 @@ const MessageListDomain = Remesh.domain({ storageEffect .set(SyncToStorageEvent) - .get((value) => SyncToStateCommand(value ?? [])) + .get((value) => [ + SyncToStateCommand(value ?? []), + MessageListLoadStatusModule.command.SetFinishedCommand() + ]) .watch((value) => SyncToStateCommand(value ?? [])) return { query: { HasItemQuery, ItemQuery, - ListQuery + ListQuery, + MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery }, command: { CreateItemCommand, diff --git a/src/domain/Room.ts b/src/domain/Room.ts index 6d712d5..6821679 100644 --- a/src/domain/Room.ts +++ b/src/domain/Room.ts @@ -1,10 +1,10 @@ import { Remesh } from 'remesh' -import { map, merge, of, EMPTY, mergeMap } from 'rxjs' +import { map, merge, of, EMPTY, mergeMap, fromEvent, Observable, tap } from 'rxjs' import { NormalMessage, type MessageUser } from './MessageList' import { PeerRoomExtern } from '@/domain/externs/PeerRoom' import MessageListDomain, { MessageType } from '@/domain/MessageList' import UserInfoDomain from '@/domain/UserInfo' -import { callbackToObservable, desert, upsert } from '@/utils' +import { fromEventPattern, desert, upsert } from '@/utils' import { nanoid } from 'nanoid' import StatusModule from '@/domain/modules/Status' @@ -14,11 +14,11 @@ export enum SendType { Like = 'like', Hate = 'hate', Text = 'text', - UserSync = 'userSync' + Join = 'join' } export interface SyncUserMessage extends MessageUser { - type: SendType.UserSync + type: SendType.Join id: string peerId: string joinTime: number @@ -63,10 +63,8 @@ const RoomDomain = Remesh.domain({ } }) - const MessageListQuery = messageListDomain.query.ListQuery - - const RoomStatusModule = StatusModule(domain, { - name: 'Room.RoomStatusModule' + const RoomJoinStatusModule = StatusModule(domain, { + name: 'RoomJoinStatusModule' }) const UserListState = domain.state({ @@ -92,7 +90,16 @@ const RoomDomain = Remesh.domain({ type: 'create', user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } }), - RoomStatusModule.command.SetFinishedCommand(), + messageListDomain.command.CreateItemCommand({ + id: nanoid(), + userId, + username, + userAvatar, + body: `"${username}" joined the chat`, + type: MessageType.Prompt, + date: Date.now() + }), + RoomJoinStatusModule.command.SetFinishedCommand(), JoinRoomEvent(peerRoom.roomId) ] } @@ -100,16 +107,25 @@ const RoomDomain = Remesh.domain({ const LeaveRoomCommand = domain.command({ name: 'RoomLeaveRoomCommand', - impl: ({ get }, roomId: string) => { + impl: ({ get }) => { peerRoom.leaveRoom() const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! return [ + messageListDomain.command.CreateItemCommand({ + id: nanoid(), + userId, + username, + userAvatar, + body: `"${username}" left the chat`, + type: MessageType.Prompt, + date: Date.now() + }), UpdateUserListCommand({ type: 'delete', user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } }), - RoomStatusModule.command.SetInitialCommand(), - LeaveRoomEvent(roomId) + RoomJoinStatusModule.command.SetInitialCommand(), + LeaveRoomEvent(peerRoom.roomId) ] } }) @@ -156,8 +172,6 @@ const RoomDomain = Remesh.domain({ } const listMessage: NormalMessage = { ...localMessage, - type: MessageType.Normal, - date: Date.now(), likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId') } peerRoom.sendMessage(likeMessage) @@ -180,8 +194,6 @@ const RoomDomain = Remesh.domain({ } const listMessage: NormalMessage = { ...localMessage, - type: MessageType.Normal, - date: Date.now(), hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId') } peerRoom.sendMessage(hateMessage) @@ -189,19 +201,19 @@ const RoomDomain = Remesh.domain({ } }) - const SendUserSyncMessageCommand = domain.command({ - name: 'RoomSendUserSyncMessageCommand', + const SendJoinMessageCommand = domain.command({ + name: 'RoomSendJoinMessageCommand', impl: ({ get }, targetPeerId: string) => { const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)! const syncUserMessage: SyncUserMessage = { ...self, id: nanoid(), - type: SendType.UserSync + type: SendType.Join } peerRoom.sendMessage(syncUserMessage, targetPeerId) - return [SendUserSyncMessageEvent(syncUserMessage)] + return [SendJoinMessageEvent(syncUserMessage)] } }) @@ -217,8 +229,8 @@ const RoomDomain = Remesh.domain({ } }) - const SendUserSyncMessageEvent = domain.event({ - name: 'RoomSendUserSyncMessageEvent' + const SendJoinMessageEvent = domain.event({ + name: 'RoomSendJoinMessageEvent' }) const SendTextMessageEvent = domain.event({ @@ -256,13 +268,13 @@ const RoomDomain = Remesh.domain({ domain.effect({ name: 'RoomOnJoinRoomEffect', impl: () => { - const onJoinRoom$ = callbackToObservable(peerRoom.onJoinRoom).pipe( + const onJoinRoom$ = fromEventPattern(peerRoom.onJoinRoom).pipe( mergeMap((peerId) => { - console.log('onJoinRoom', peerId) + // console.log('onJoinRoom', peerId) if (peerRoom.peerId === peerId) { return [OnJoinRoomEvent(peerId)] } else { - return [SendUserSyncMessageCommand(peerId), OnJoinRoomEvent(peerId)] + return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)] } }) ) @@ -273,14 +285,14 @@ const RoomDomain = Remesh.domain({ domain.effect({ name: 'RoomOnMessageEffect', impl: ({ get }) => { - const onMessage$ = callbackToObservable(peerRoom.onMessage).pipe( + const onMessage$ = fromEventPattern(peerRoom.onMessage).pipe( mergeMap((message) => { - console.log('onMessage', message) + // console.log('onMessage', message) const messageEvent$ = of(OnMessageEvent(message)) const commandEvent$ = (() => { switch (message.type) { - case SendType.UserSync: { + case SendType.Join: { const userList = get(UserListQuery()) const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)! // If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId @@ -351,9 +363,9 @@ const RoomDomain = Remesh.domain({ domain.effect({ name: 'RoomOnLeaveRoomEffect', impl: ({ get }) => { - const onLeaveRoom$ = callbackToObservable(peerRoom.onLeaveRoom).pipe( + const onLeaveRoom$ = fromEventPattern(peerRoom.onLeaveRoom).pipe( map((peerId) => { - console.log('onLeaveRoom', peerId) + // console.log('onLeaveRoom', peerId) const user = get(UserListQuery()).find((user) => user.peerId === peerId) if (user) { @@ -377,12 +389,24 @@ const RoomDomain = Remesh.domain({ } }) + // 以后移动到 service worker 中,无需每次刷新页面都发送离开房间的消息 + domain.effect({ + name: 'RoomOnUnloadEffect', + impl: () => { + const beforeUnload$ = fromEvent(window, 'beforeunload').pipe( + map(() => { + return [LeaveRoomCommand()] + }) + ) + return beforeUnload$ + } + }) + return { query: { PeerIdQuery, UserListQuery, - MessageListQuery, - ...RoomStatusModule.query + RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery }, command: { JoinRoomCommand, @@ -390,20 +414,18 @@ const RoomDomain = Remesh.domain({ SendTextMessageCommand, SendLikeMessageCommand, SendHateMessageCommand, - SendUserSyncMessageCommand, - ...RoomStatusModule.command + SendJoinMessageCommand }, event: { SendTextMessageEvent, SendLikeMessageEvent, SendHateMessageEvent, - SendUserSyncMessageEvent, + SendJoinMessageEvent, JoinRoomEvent, LeaveRoomEvent, OnMessageEvent, OnJoinRoomEvent, - OnLeaveRoomEvent, - ...RoomStatusModule.event + OnLeaveRoomEvent } } } diff --git a/src/domain/UserInfo.ts b/src/domain/UserInfo.ts index 503134c..4542f13 100644 --- a/src/domain/UserInfo.ts +++ b/src/domain/UserInfo.ts @@ -27,8 +27,11 @@ const UserInfoDomain = Remesh.domain({ default: null }) - const UserInfoStatusModule = StatusModule(domain, { - name: 'UserInfo.StatusModule' + const UserInfoLoadStatusModule = StatusModule(domain, { + name: 'UserInfoLoadStatusModule' + }) + const UserInfoSetStatusModule = StatusModule(domain, { + name: 'UserInfoSetStatusModule' }) const UserInfoQuery = domain.query({ @@ -46,8 +49,8 @@ const UserInfoDomain = Remesh.domain({ UpdateUserInfoEvent(), SyncToStorageEvent(), userInfo - ? UserInfoStatusModule.command.SetFinishedCommand() - : UserInfoStatusModule.command.SetInitialCommand() + ? UserInfoSetStatusModule.command.SetFinishedCommand() + : UserInfoSetStatusModule.command.SetInitialCommand() ] } }) @@ -73,31 +76,35 @@ const UserInfoDomain = Remesh.domain({ const SyncToStateCommand = domain.command({ name: 'UserInfo.SyncToStateCommand', impl: (_, userInfo: UserInfo | null) => { - return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStateEvent(userInfo)] + return [ + UserInfoState().new(userInfo), + UpdateUserInfoEvent(), + SyncToStateEvent(userInfo), + userInfo && UserInfoSetStatusModule.command.SetFinishedCommand() + ] } }) storageEffect .set(SyncToStorageEvent) .get((value) => { - return [SyncToStateCommand(value), UserInfoStatusModule.command.SetFinishedCommand()] + return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()] }) .watch((value) => [SyncToStateCommand(value)]) return { query: { UserInfoQuery, - ...UserInfoStatusModule.query + UserInfoLoadIsFinishedQuery: UserInfoLoadStatusModule.query.IsFinishedQuery, + UserInfoSetIsFinishedQuery: UserInfoSetStatusModule.query.IsFinishedQuery }, command: { - UpdateUserInfoCommand, - ...UserInfoStatusModule.command + UpdateUserInfoCommand }, event: { SyncToStateEvent, SyncToStorageEvent, - UpdateUserInfoEvent, - ...UserInfoStatusModule.event + UpdateUserInfoEvent } } } diff --git a/src/domain/modules/StorageEffect.ts b/src/domain/modules/StorageEffect.ts index 4661748..3cd4a87 100644 --- a/src/domain/modules/StorageEffect.ts +++ b/src/domain/modules/StorageEffect.ts @@ -45,12 +45,12 @@ export default class StorageEffect { this.domain.effect({ name: 'FormStateToStorageEffect', impl: ({ fromEvent }) => { - const changeUserInfo$ = fromEvent(event).pipe( - tap(async (value) => { - return await this.storage.set(this.key, value) + return fromEvent(event).pipe( + switchMap(async (value) => { + await this.storage.set(this.key, value) + return null }) ) - return merge(changeUserInfo$).pipe(map(() => null)) } }) return this diff --git a/src/utils/callbackToObservable.ts b/src/utils/callbackToObservable.ts index d0a4ca2..64662b0 100644 --- a/src/utils/callbackToObservable.ts +++ b/src/utils/callbackToObservable.ts @@ -2,7 +2,7 @@ import { Observable } from 'rxjs' export type Subscribe = (callback: (event: T) => void) => void -const callbackToObservable = (subscribe: Subscribe, unsubscribe?: () => void) => { +const fromEventPattern = (subscribe: Subscribe, unsubscribe?: () => void) => { return new Observable((subscriber) => { subscribe((event: T) => { subscriber.next(event) @@ -15,4 +15,4 @@ const callbackToObservable = (subscribe: Subscribe, unsubscribe?: () => vo }) } -export default callbackToObservable +export default fromEventPattern diff --git a/src/utils/index.ts b/src/utils/index.ts index a95194a..8f55a3f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,7 +5,7 @@ export { default as getSiteInfo } from './getSiteInfo' export { default as compressImage } from './compressImage' export { default as isNullish } from './isNullish' export { default as checkSystemDarkMode } from './checkSystemDarkMode' -export { default as callbackToObservable } from './callbackToObservable' +export { default as fromEventPattern } from './fromEventPattern' export { default as stringToHex } from './stringToHex' export { default as debounce } from './debounce' export { default as throttle } from './throttle' diff --git a/tailwind.config.ts b/tailwind.config.ts index 6500d2f..e9ff6c6 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -60,6 +60,7 @@ export default { md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' }, + keyframes: { 'accordion-down': { from: { height: '0' }, @@ -68,11 +69,25 @@ export default { 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } + }, + pulse: { + '0%, 100%': { boxShadow: '0 0 0 0 var(--pulse-color)' }, + '50%': { boxShadow: '0 0 0 8px var(--pulse-color)' } + }, + ripple: { + '0%, 100%': { + transform: 'translate(-50%, -50%) scale(1)' + }, + '50%': { + transform: 'translate(-50%, -50%) scale(0.9)' + } } }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out' + 'accordion-up': 'accordion-up 0.2s ease-out', + pulse: 'pulse var(--duration) ease-out infinite', + ripple: 'ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite' } } },