perf: add animation effects and add self join message

This commit is contained in:
molvqingtai 2024-09-26 08:14:55 +08:00
parent 90253effa6
commit 437c234f8a
20 changed files with 367 additions and 103 deletions

View file

@ -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'
}
}
]

View file

@ -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",

View file

@ -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:

View file

@ -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 (
<>

View file

@ -37,7 +37,9 @@ const MessageItem: FC<MessageItemProps> = (props) => {
</Avatar>
<div className="overflow-hidden">
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
<div className="overflow-hidden text-ellipsis text-sm font-medium text-slate-600">{props.data.username}</div>
<div className="overflow-hidden text-ellipsis text-sm font-semibold text-slate-600">
{props.data.username}
</div>
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
</div>
<div>

View file

@ -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('<div id="app"></div>')
@ -34,11 +35,11 @@ export default defineContentScript({
const root = createRoot(app)
root.render(
<React.StrictMode>
// <React.StrictMode>
<RemeshRoot store={store}>
<App />
</RemeshRoot>
</React.StrictMode>
// </React.StrictMode>
)
return root
},

View file

@ -25,7 +25,7 @@ const Header: FC = () => {
<HoverCard>
<HoverCardTrigger asChild>
<Button className="overflow-hidden" variant="link">
<span className="truncate text-lg font-medium text-slate-600">
<span className="truncate text-lg font-semibold text-slate-600">
{siteInfo.hostname.replace(/^www\./i, '')}
{/* {peerId} */}
</span>

View file

@ -6,13 +6,15 @@ import MessageItem from '../../components/MessageItem'
import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList'
const Main: FC = () => {
const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const _messageList = useRemeshQuery(roomDomain.query.MessageListQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageList = _messageList.map((message) => {
if (message.type === MessageType.Normal) {
return {

View file

@ -1,5 +1,4 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
@ -10,11 +9,14 @@ import { FC, useEffect, useState } from 'react'
import { useRemeshDomain, useRemeshSend } from 'remesh-react'
import Timer from '@resreq/Timer'
import ExampleImage from '@/assets/images/example.jpg'
import PulsatingButton from '@/components/magicui/pulsating-button'
import BlurFade from '@/components/magicui/blur-fade'
import WordPullUp from '@/components/magicui/word-pull-up'
const mockTextList = [
`你問我支持不支持,我說我支持`,
`我就明確告訴你,你們啊,我感覺你們新聞界還要學習一個,你們非常熟悉西方的那一套`,
`你們畢竟還 too young`,
`你們畢竟還 too young`,
`明白我的意思吧?`,
`我告訴你們我是身經百戰了,見得多了`,
`西方的那個國家我沒去過?`,
@ -22,12 +24,12 @@ const mockTextList = [
`其實媒體呀,還是要提高自己的知識水平,識得唔識得呀?`,
`你們有一個好,全世界跑到什么地方,你們比其他的西方記者跑得還快`,
`但是呢問來問去的問題呀`,
`too simple sometimes naive`,
`too simple sometimes naive`,
`懂了沒啊,識得唔識得呀?`,
`我很抱歉,我今天是作爲一個長者給你們講`,
`我不是新聞工作者,但是我見得太多了`,
`我有這個必要好告訴你們一點人生的經驗`,
`![too young too simple sometimes naive](${ExampleImage})`
`![ExampleImage](${ExampleImage})`
]
const generateUserInfo = async (): Promise<UserInfo> => {
@ -90,19 +92,27 @@ const Setup: FC = () => {
send(messageListDomain.command.ClearListCommand())
}
}, [])
return (
<div className="absolute inset-0 z-50 flex rounded-xl bg-black/10 shadow-2xl backdrop-blur-sm">
<div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg">
<BlurFade key={userInfo?.avatar} delay={0.1} inView>
<Avatar className="size-24 cursor-pointer border-4 border-white ">
<AvatarImage src={userInfo?.avatar} alt="avatar" />
<AvatarFallback>
<UserIcon size={30} className="text-slate-400" />
</AvatarFallback>
</Avatar>
<div className="text-2xl font-bold text-primary">@{userInfo?.name}</div>
<Button className="rounded-full" size="lg" onClick={handleSetup}>
Start chatting
</Button>
</BlurFade>
<div className="flex">
<div className="text-2xl font-bold text-primary">@</div>
<WordPullUp
className="text-2xl font-bold text-primary"
key={userInfo?.name}
words={`${userInfo?.name || ''}`}
/>
</div>
<PulsatingButton onClick={handleSetup}>Start chatting</PulsatingButton>
</div>
</div>
)

View file

@ -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,6 +95,7 @@ const ProfileForm = () => {
render={({ field }) => (
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center">
<FormControl>
<BlurFade key={form.getValues().avatar} delay={0.1}>
<AvatarSelect
compressSize={MAX_AVATAR_SIZE}
onError={handleError}
@ -101,6 +103,7 @@ const ProfileForm = () => {
className="shadow-lg"
{...field}
></AvatarSelect>
</BlurFade>
</FormControl>
<FormMessage />
</FormItem>

View file

@ -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 (
<AnimatePresence>
<motion.div
ref={ref}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
exit="hidden"
variants={combinedVariants}
transition={{
delay: 0.04 + delay,
duration,
ease: 'easeOut'
}}
className={className}
>
{children}
</motion.div>
</AnimatePresence>
)
}

View file

@ -0,0 +1,37 @@
'use client'
import React from 'react'
import { cn } from '@/utils/index'
interface PulsatingButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
pulseColor?: string
duration?: string
}
export default function PulsatingButton({
className,
children,
pulseColor = '#0f172a50',
duration = '1.5s',
...props
}: PulsatingButtonProps) {
return (
<button
className={cn(
'relative rounded-full text-center cursor-pointer text-sm font-medium flex justify-center items-center text-primary-foreground bg-primary py-2 h-10 px-8 hover:bg-primary/90',
className
)}
style={
{
'--pulse-color': pulseColor,
'--duration': duration
} as React.CSSProperties
}
{...props}
>
<div className="relative z-10">{children}</div>
<div className="absolute left-1/2 top-1/2 size-full -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-full bg-inherit" />
</button>
)
}

View file

@ -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 (
<motion.h1
variants={wrapperFramerProps}
initial="hidden"
animate="show"
className={cn(
"font-display text-center text-4xl font-bold leading-[5rem] tracking-[-0.02em] drop-shadow-sm",
className,
)}
>
{words.split(" ").map((word, i) => (
<motion.span
key={i}
variants={framerProps}
style={{ display: "inline-block", paddingRight: "8px" }}
>
{word === "" ? <span>&nbsp;</span> : word}
</motion.span>
))}
</motion.h1>
);
}

View file

@ -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<Message[]>((value) => SyncToStateCommand(value ?? []))
.get<Message[]>((value) => [
SyncToStateCommand(value ?? []),
MessageListLoadStatusModule.command.SetFinishedCommand()
])
.watch<Message[]>((value) => SyncToStateCommand(value ?? []))
return {
query: {
HasItemQuery,
ItemQuery,
ListQuery
ListQuery,
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery
},
command: {
CreateItemCommand,

View file

@ -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<RoomUser[]>({
@ -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<RoomMessage>(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<RoomMessage>(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<RoomMessage>(syncUserMessage, targetPeerId)
return [SendUserSyncMessageEvent(syncUserMessage)]
return [SendJoinMessageEvent(syncUserMessage)]
}
})
@ -217,8 +229,8 @@ const RoomDomain = Remesh.domain({
}
})
const SendUserSyncMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendUserSyncMessageEvent'
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendJoinMessageEvent'
})
const SendTextMessageEvent = domain.event<TextMessage>({
@ -256,13 +268,13 @@ const RoomDomain = Remesh.domain({
domain.effect({
name: 'RoomOnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = callbackToObservable<string>(peerRoom.onJoinRoom).pipe(
const onJoinRoom$ = fromEventPattern<string>(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<RoomMessage>(peerRoom.onMessage).pipe(
const onMessage$ = fromEventPattern<RoomMessage>(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<string>(peerRoom.onLeaveRoom).pipe(
const onLeaveRoom$ = fromEventPattern<string>(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
}
}
}

View file

@ -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<UserInfo>((value) => {
return [SyncToStateCommand(value), UserInfoStatusModule.command.SetFinishedCommand()]
return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
})
.watch<UserInfo>((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
}
}
}

View file

@ -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

View file

@ -2,7 +2,7 @@ import { Observable } from 'rxjs'
export type Subscribe<T> = (callback: (event: T) => void) => void
const callbackToObservable = <T>(subscribe: Subscribe<T>, unsubscribe?: () => void) => {
const fromEventPattern = <T>(subscribe: Subscribe<T>, unsubscribe?: () => void) => {
return new Observable<T>((subscriber) => {
subscribe((event: T) => {
subscriber.next(event)
@ -15,4 +15,4 @@ const callbackToObservable = <T>(subscribe: Subscribe<T>, unsubscribe?: () => vo
})
}
export default callbackToObservable
export default fromEventPattern

View file

@ -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'

View file

@ -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'
}
}
},