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: { rules: {
@ -33,7 +33,8 @@ export default [
'@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'off',
'@eslint-react/no-array-index-key': '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", "homepage": "https://github.com/molvqingtai/WebChat#readme",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@lottiefiles/dotlottie-react": "^0.9.0",
"@perfsee/jsonr": "^1.13.0", "@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
@ -64,6 +65,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^11.5.6",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.445.0", "lucide-react": "^0.445.0",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
@ -94,8 +96,8 @@
"@eslint-react/eslint-plugin": "^1.14.2", "@eslint-react/eslint-plugin": "^1.14.2",
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.6.1", "@types/node": "^22.6.1",
"@types/react": "^18.3.9", "@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

View file

@ -11,6 +11,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.9.0 specifier: ^3.9.0
version: 3.9.0(react-hook-form@7.53.0(react@18.3.1)) 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': '@perfsee/jsonr':
specifier: ^1.13.0 specifier: ^1.13.0
version: 1.13.0 version: 1.13.0
@ -68,6 +71,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 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: idb-keyval:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1 version: 6.2.1
@ -1118,6 +1124,15 @@ packages:
'@libp2p/websockets@8.2.0': '@libp2p/websockets@8.2.0':
resolution: {integrity: sha512-UNjqkQ8/emnYswp1ohIIuZCnhI5DlvWF9IaIND2MoTCDavi7yubWfMp8jSWBsAqPnMeLMO8MQ6YlOo4FFC104Q==} 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': '@multiformats/dns@1.0.6':
resolution: {integrity: sha512-nt/5UqjMPtyvkG9BQYdJ4GfLK3nMqGpFZOzf4hAmIa0sJh2LlS9YKXZ4FgwBDsaHvzZqR/rUFIywIc7pkHNNuw==} resolution: {integrity: sha512-nt/5UqjMPtyvkG9BQYdJ4GfLK3nMqGpFZOzf4hAmIa0sJh2LlS9YKXZ4FgwBDsaHvzZqR/rUFIywIc7pkHNNuw==}
@ -3229,6 +3244,20 @@ packages:
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} 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: fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@ -7294,6 +7323,14 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - 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': '@multiformats/dns@1.0.6':
dependencies: dependencies:
'@types/dns-packet': 5.6.5 '@types/dns-packet': 5.6.5
@ -9725,6 +9762,13 @@ snapshots:
fraction.js@4.3.7: {} 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-constants@1.0.0: {}
fs-extra@11.2.0: fs-extra@11.2.0:

View file

@ -16,22 +16,17 @@ export default function App() {
const roomDomain = useRemeshDomain(RoomDomain()) const roomDomain = useRemeshDomain(RoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain()) const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain()) const messageListDomain = useRemeshDomain(MessageListDomain())
const roomFinished = useRemeshQuery(roomDomain.query.IsFinishedQuery()) const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const userInfoFinished = useRemeshQuery(userInfoDomain.query.IsFinishedQuery()) const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery()) const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
const notUserInfo = userInfoFinished && !userInfo
useEffect(() => { useEffect(() => {
if (userInfoFinished) { if (userInfoSetFinished && messageListLoadFinished) {
if (userInfo) { send(roomDomain.command.JoinRoomCommand())
!roomFinished && send(roomDomain.command.JoinRoomCommand())
} else {
send(messageListDomain.command.ClearListCommand())
}
} }
}, [userInfoFinished, userInfo, roomFinished]) }, [userInfoSetFinished, messageListLoadFinished])
return ( return (
<> <>

View file

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

View file

@ -20,13 +20,14 @@ export default defineContentScript({
async main(ctx) { async main(ctx) {
const store = Remesh.store({ const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl], externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl],
inspectors: __DEV__ ? [RemeshLogger()] : [] inspectors: !__DEV__ ? [RemeshLogger()] : []
}) })
const ui = await createShadowRootUi(ctx, { const ui = await createShadowRootUi(ctx, {
name: __NAME__, name: __NAME__,
position: 'inline', position: 'inline',
anchor: 'body', anchor: 'body',
isolateEvents: ['scroll', 'click'],
mode: __DEV__ ? 'open' : 'closed', mode: __DEV__ ? 'open' : 'closed',
onMount: (container) => { onMount: (container) => {
const app = createElement('<div id="app"></div>') const app = createElement('<div id="app"></div>')
@ -34,11 +35,11 @@ export default defineContentScript({
const root = createRoot(app) const root = createRoot(app)
root.render( root.render(
<React.StrictMode> // <React.StrictMode>
<RemeshRoot store={store}> <RemeshRoot store={store}>
<App /> <App />
</RemeshRoot> </RemeshRoot>
</React.StrictMode> // </React.StrictMode>
) )
return root return root
}, },

View file

@ -25,7 +25,7 @@ const Header: FC = () => {
<HoverCard> <HoverCard>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<Button className="overflow-hidden" variant="link"> <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, '')} {siteInfo.hostname.replace(/^www\./i, '')}
{/* {peerId} */} {/* {peerId} */}
</span> </span>

View file

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

View file

@ -1,5 +1,4 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { MAX_AVATAR_SIZE } from '@/constants/config' import { MAX_AVATAR_SIZE } from '@/constants/config'
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList' import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo' import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
@ -10,11 +9,14 @@ import { FC, useEffect, useState } from 'react'
import { useRemeshDomain, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshSend } from 'remesh-react'
import Timer from '@resreq/Timer' import Timer from '@resreq/Timer'
import ExampleImage from '@/assets/images/example.jpg' 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 = [ 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> => { const generateUserInfo = async (): Promise<UserInfo> => {
@ -90,19 +92,27 @@ const Setup: FC = () => {
send(messageListDomain.command.ClearListCommand()) send(messageListDomain.command.ClearListCommand())
} }
}, []) }, [])
return ( return (
<div className="absolute inset-0 z-50 flex rounded-xl bg-black/10 shadow-2xl backdrop-blur-sm"> <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"> <div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg">
<Avatar className="size-24 cursor-pointer border-4 border-white "> <BlurFade key={userInfo?.avatar} delay={0.1} inView>
<AvatarImage src={userInfo?.avatar} alt="avatar" /> <Avatar className="size-24 cursor-pointer border-4 border-white ">
<AvatarFallback> <AvatarImage src={userInfo?.avatar} alt="avatar" />
<UserIcon size={30} className="text-slate-400" /> <AvatarFallback>
</AvatarFallback> <UserIcon size={30} className="text-slate-400" />
</Avatar> </AvatarFallback>
<div className="text-2xl font-bold text-primary">@{userInfo?.name}</div> </Avatar>
<Button className="rounded-full" size="lg" onClick={handleSetup}> </BlurFade>
Start chatting <div className="flex">
</Button> <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>
</div> </div>
) )

View file

@ -15,6 +15,7 @@ import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react' import { RefreshCcwIcon } from 'lucide-react'
import { MAX_AVATAR_SIZE } from '@/constants/config' import { MAX_AVATAR_SIZE } from '@/constants/config'
import ToastDomain from '@/domain/Toast' import ToastDomain from '@/domain/Toast'
import BlurFade from '@/components/magicui/blur-fade'
const defaultUserInfo: UserInfo = { const defaultUserInfo: UserInfo = {
id: nanoid(), id: nanoid(),
@ -94,13 +95,15 @@ const ProfileForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center"> <FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center">
<FormControl> <FormControl>
<AvatarSelect <BlurFade key={form.getValues().avatar} delay={0.1}>
compressSize={MAX_AVATAR_SIZE} <AvatarSelect
onError={handleError} compressSize={MAX_AVATAR_SIZE}
onWarning={handleWarning} onError={handleError}
className="shadow-lg" onWarning={handleWarning}
{...field} className="shadow-lg"
></AvatarSelect> {...field}
></AvatarSelect>
</BlurFade>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </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 { ListModule } from 'remesh/modules/list'
import { IndexDBStorageExtern } from '@/domain/externs/Storage' import { IndexDBStorageExtern } from '@/domain/externs/Storage'
import StorageEffect from '@/domain/modules/StorageEffect' import StorageEffect from '@/domain/modules/StorageEffect'
import StatusModule from './modules/Status'
export enum MessageType { export enum MessageType {
Normal = 'normal', Normal = 'normal',
@ -48,6 +49,10 @@ const MessageListDomain = Remesh.domain({
key: (message) => message.id key: (message) => message.id
}) })
const MessageListLoadStatusModule = StatusModule(domain, {
name: 'MessageListLoadStatusModule'
})
const ListQuery = MessageListModule.query.ItemListQuery const ListQuery = MessageListModule.query.ItemListQuery
const ItemQuery = MessageListModule.query.ItemQuery const ItemQuery = MessageListModule.query.ItemQuery
@ -140,14 +145,18 @@ const MessageListDomain = Remesh.domain({
storageEffect storageEffect
.set(SyncToStorageEvent) .set(SyncToStorageEvent)
.get<Message[]>((value) => SyncToStateCommand(value ?? [])) .get<Message[]>((value) => [
SyncToStateCommand(value ?? []),
MessageListLoadStatusModule.command.SetFinishedCommand()
])
.watch<Message[]>((value) => SyncToStateCommand(value ?? [])) .watch<Message[]>((value) => SyncToStateCommand(value ?? []))
return { return {
query: { query: {
HasItemQuery, HasItemQuery,
ItemQuery, ItemQuery,
ListQuery ListQuery,
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery
}, },
command: { command: {
CreateItemCommand, CreateItemCommand,

View file

@ -1,10 +1,10 @@
import { Remesh } from 'remesh' 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 { NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom' import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList' import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo' import UserInfoDomain from '@/domain/UserInfo'
import { callbackToObservable, desert, upsert } from '@/utils' import { fromEventPattern, desert, upsert } from '@/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status' import StatusModule from '@/domain/modules/Status'
@ -14,11 +14,11 @@ export enum SendType {
Like = 'like', Like = 'like',
Hate = 'hate', Hate = 'hate',
Text = 'text', Text = 'text',
UserSync = 'userSync' Join = 'join'
} }
export interface SyncUserMessage extends MessageUser { export interface SyncUserMessage extends MessageUser {
type: SendType.UserSync type: SendType.Join
id: string id: string
peerId: string peerId: string
joinTime: number joinTime: number
@ -63,10 +63,8 @@ const RoomDomain = Remesh.domain({
} }
}) })
const MessageListQuery = messageListDomain.query.ListQuery const RoomJoinStatusModule = StatusModule(domain, {
name: 'RoomJoinStatusModule'
const RoomStatusModule = StatusModule(domain, {
name: 'Room.RoomStatusModule'
}) })
const UserListState = domain.state<RoomUser[]>({ const UserListState = domain.state<RoomUser[]>({
@ -92,7 +90,16 @@ const RoomDomain = Remesh.domain({
type: 'create', type: 'create',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } 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) JoinRoomEvent(peerRoom.roomId)
] ]
} }
@ -100,16 +107,25 @@ const RoomDomain = Remesh.domain({
const LeaveRoomCommand = domain.command({ const LeaveRoomCommand = domain.command({
name: 'RoomLeaveRoomCommand', name: 'RoomLeaveRoomCommand',
impl: ({ get }, roomId: string) => { impl: ({ get }) => {
peerRoom.leaveRoom() peerRoom.leaveRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [ return [
messageListDomain.command.CreateItemCommand({
id: nanoid(),
userId,
username,
userAvatar,
body: `"${username}" left the chat`,
type: MessageType.Prompt,
date: Date.now()
}),
UpdateUserListCommand({ UpdateUserListCommand({
type: 'delete', type: 'delete',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}), }),
RoomStatusModule.command.SetInitialCommand(), RoomJoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(roomId) LeaveRoomEvent(peerRoom.roomId)
] ]
} }
}) })
@ -156,8 +172,6 @@ const RoomDomain = Remesh.domain({
} }
const listMessage: NormalMessage = { const listMessage: NormalMessage = {
...localMessage, ...localMessage,
type: MessageType.Normal,
date: Date.now(),
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId') likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
} }
peerRoom.sendMessage<RoomMessage>(likeMessage) peerRoom.sendMessage<RoomMessage>(likeMessage)
@ -180,8 +194,6 @@ const RoomDomain = Remesh.domain({
} }
const listMessage: NormalMessage = { const listMessage: NormalMessage = {
...localMessage, ...localMessage,
type: MessageType.Normal,
date: Date.now(),
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId') hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
} }
peerRoom.sendMessage<RoomMessage>(hateMessage) peerRoom.sendMessage<RoomMessage>(hateMessage)
@ -189,19 +201,19 @@ const RoomDomain = Remesh.domain({
} }
}) })
const SendUserSyncMessageCommand = domain.command({ const SendJoinMessageCommand = domain.command({
name: 'RoomSendUserSyncMessageCommand', name: 'RoomSendJoinMessageCommand',
impl: ({ get }, targetPeerId: string) => { impl: ({ get }, targetPeerId: string) => {
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)! const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
const syncUserMessage: SyncUserMessage = { const syncUserMessage: SyncUserMessage = {
...self, ...self,
id: nanoid(), id: nanoid(),
type: SendType.UserSync type: SendType.Join
} }
peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId) peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId)
return [SendUserSyncMessageEvent(syncUserMessage)] return [SendJoinMessageEvent(syncUserMessage)]
} }
}) })
@ -217,8 +229,8 @@ const RoomDomain = Remesh.domain({
} }
}) })
const SendUserSyncMessageEvent = domain.event<SyncUserMessage>({ const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendUserSyncMessageEvent' name: 'RoomSendJoinMessageEvent'
}) })
const SendTextMessageEvent = domain.event<TextMessage>({ const SendTextMessageEvent = domain.event<TextMessage>({
@ -256,13 +268,13 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'RoomOnJoinRoomEffect', name: 'RoomOnJoinRoomEffect',
impl: () => { impl: () => {
const onJoinRoom$ = callbackToObservable<string>(peerRoom.onJoinRoom).pipe( const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
mergeMap((peerId) => { mergeMap((peerId) => {
console.log('onJoinRoom', peerId) // console.log('onJoinRoom', peerId)
if (peerRoom.peerId === peerId) { if (peerRoom.peerId === peerId) {
return [OnJoinRoomEvent(peerId)] return [OnJoinRoomEvent(peerId)]
} else { } else {
return [SendUserSyncMessageCommand(peerId), OnJoinRoomEvent(peerId)] return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)]
} }
}) })
) )
@ -273,14 +285,14 @@ const RoomDomain = Remesh.domain({
domain.effect({ domain.effect({
name: 'RoomOnMessageEffect', name: 'RoomOnMessageEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onMessage$ = callbackToObservable<RoomMessage>(peerRoom.onMessage).pipe( const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
mergeMap((message) => { mergeMap((message) => {
console.log('onMessage', message) // console.log('onMessage', message)
const messageEvent$ = of(OnMessageEvent(message)) const messageEvent$ = of(OnMessageEvent(message))
const commandEvent$ = (() => { const commandEvent$ = (() => {
switch (message.type) { switch (message.type) {
case SendType.UserSync: { case SendType.Join: {
const userList = get(UserListQuery()) const userList = get(UserListQuery())
const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)! 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 // 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({ domain.effect({
name: 'RoomOnLeaveRoomEffect', name: 'RoomOnLeaveRoomEffect',
impl: ({ get }) => { impl: ({ get }) => {
const onLeaveRoom$ = callbackToObservable<string>(peerRoom.onLeaveRoom).pipe( const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => { map((peerId) => {
console.log('onLeaveRoom', peerId) // console.log('onLeaveRoom', peerId)
const user = get(UserListQuery()).find((user) => user.peerId === peerId) const user = get(UserListQuery()).find((user) => user.peerId === peerId)
if (user) { 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 { return {
query: { query: {
PeerIdQuery, PeerIdQuery,
UserListQuery, UserListQuery,
MessageListQuery, RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery
...RoomStatusModule.query
}, },
command: { command: {
JoinRoomCommand, JoinRoomCommand,
@ -390,20 +414,18 @@ const RoomDomain = Remesh.domain({
SendTextMessageCommand, SendTextMessageCommand,
SendLikeMessageCommand, SendLikeMessageCommand,
SendHateMessageCommand, SendHateMessageCommand,
SendUserSyncMessageCommand, SendJoinMessageCommand
...RoomStatusModule.command
}, },
event: { event: {
SendTextMessageEvent, SendTextMessageEvent,
SendLikeMessageEvent, SendLikeMessageEvent,
SendHateMessageEvent, SendHateMessageEvent,
SendUserSyncMessageEvent, SendJoinMessageEvent,
JoinRoomEvent, JoinRoomEvent,
LeaveRoomEvent, LeaveRoomEvent,
OnMessageEvent, OnMessageEvent,
OnJoinRoomEvent, OnJoinRoomEvent,
OnLeaveRoomEvent, OnLeaveRoomEvent
...RoomStatusModule.event
} }
} }
} }

View file

@ -27,8 +27,11 @@ const UserInfoDomain = Remesh.domain({
default: null default: null
}) })
const UserInfoStatusModule = StatusModule(domain, { const UserInfoLoadStatusModule = StatusModule(domain, {
name: 'UserInfo.StatusModule' name: 'UserInfoLoadStatusModule'
})
const UserInfoSetStatusModule = StatusModule(domain, {
name: 'UserInfoSetStatusModule'
}) })
const UserInfoQuery = domain.query({ const UserInfoQuery = domain.query({
@ -46,8 +49,8 @@ const UserInfoDomain = Remesh.domain({
UpdateUserInfoEvent(), UpdateUserInfoEvent(),
SyncToStorageEvent(), SyncToStorageEvent(),
userInfo userInfo
? UserInfoStatusModule.command.SetFinishedCommand() ? UserInfoSetStatusModule.command.SetFinishedCommand()
: UserInfoStatusModule.command.SetInitialCommand() : UserInfoSetStatusModule.command.SetInitialCommand()
] ]
} }
}) })
@ -73,31 +76,35 @@ const UserInfoDomain = Remesh.domain({
const SyncToStateCommand = domain.command({ const SyncToStateCommand = domain.command({
name: 'UserInfo.SyncToStateCommand', name: 'UserInfo.SyncToStateCommand',
impl: (_, userInfo: UserInfo | null) => { impl: (_, userInfo: UserInfo | null) => {
return [UserInfoState().new(userInfo), UpdateUserInfoEvent(), SyncToStateEvent(userInfo)] return [
UserInfoState().new(userInfo),
UpdateUserInfoEvent(),
SyncToStateEvent(userInfo),
userInfo && UserInfoSetStatusModule.command.SetFinishedCommand()
]
} }
}) })
storageEffect storageEffect
.set(SyncToStorageEvent) .set(SyncToStorageEvent)
.get<UserInfo>((value) => { .get<UserInfo>((value) => {
return [SyncToStateCommand(value), UserInfoStatusModule.command.SetFinishedCommand()] return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
}) })
.watch<UserInfo>((value) => [SyncToStateCommand(value)]) .watch<UserInfo>((value) => [SyncToStateCommand(value)])
return { return {
query: { query: {
UserInfoQuery, UserInfoQuery,
...UserInfoStatusModule.query UserInfoLoadIsFinishedQuery: UserInfoLoadStatusModule.query.IsFinishedQuery,
UserInfoSetIsFinishedQuery: UserInfoSetStatusModule.query.IsFinishedQuery
}, },
command: { command: {
UpdateUserInfoCommand, UpdateUserInfoCommand
...UserInfoStatusModule.command
}, },
event: { event: {
SyncToStateEvent, SyncToStateEvent,
SyncToStorageEvent, SyncToStorageEvent,
UpdateUserInfoEvent, UpdateUserInfoEvent
...UserInfoStatusModule.event
} }
} }
} }

View file

@ -45,12 +45,12 @@ export default class StorageEffect {
this.domain.effect({ this.domain.effect({
name: 'FormStateToStorageEffect', name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => { impl: ({ fromEvent }) => {
const changeUserInfo$ = fromEvent(event).pipe( return fromEvent(event).pipe(
tap(async (value) => { switchMap(async (value) => {
return await this.storage.set(this.key, value) await this.storage.set(this.key, value)
return null
}) })
) )
return merge(changeUserInfo$).pipe(map(() => null))
} }
}) })
return this return this

View file

@ -2,7 +2,7 @@ import { Observable } from 'rxjs'
export type Subscribe<T> = (callback: (event: T) => void) => void 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) => { return new Observable<T>((subscriber) => {
subscribe((event: T) => { subscribe((event: T) => {
subscriber.next(event) 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 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 callbackToObservable } from './callbackToObservable' export { default as fromEventPattern } from './fromEventPattern'
export { default as stringToHex } from './stringToHex' export { default as stringToHex } from './stringToHex'
export { default as debounce } from './debounce' export { default as debounce } from './debounce'
export { default as throttle } from './throttle' export { default as throttle } from './throttle'

View file

@ -60,6 +60,7 @@ export default {
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)' sm: 'calc(var(--radius) - 4px)'
}, },
keyframes: { keyframes: {
'accordion-down': { 'accordion-down': {
from: { height: '0' }, from: { height: '0' },
@ -68,11 +69,25 @@ export default {
'accordion-up': { 'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' }, from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' } 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: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', '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'
} }
} }
}, },