perf: add animation effects and add self join message
This commit is contained in:
parent
90253effa6
commit
437c234f8a
20 changed files with 367 additions and 103 deletions
|
@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
<RemeshRoot store={store}>
|
||||
<App />
|
||||
</RemeshRoot>
|
||||
</React.StrictMode>
|
||||
// <React.StrictMode>
|
||||
<RemeshRoot store={store}>
|
||||
<App />
|
||||
</RemeshRoot>
|
||||
// </React.StrictMode>
|
||||
)
|
||||
return root
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
<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 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>
|
||||
</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>
|
||||
)
|
||||
|
|
|
@ -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 }) => (
|
||||
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center">
|
||||
<FormControl>
|
||||
<AvatarSelect
|
||||
compressSize={MAX_AVATAR_SIZE}
|
||||
onError={handleError}
|
||||
onWarning={handleWarning}
|
||||
className="shadow-lg"
|
||||
{...field}
|
||||
></AvatarSelect>
|
||||
<BlurFade key={form.getValues().avatar} delay={0.1}>
|
||||
<AvatarSelect
|
||||
compressSize={MAX_AVATAR_SIZE}
|
||||
onError={handleError}
|
||||
onWarning={handleWarning}
|
||||
className="shadow-lg"
|
||||
{...field}
|
||||
></AvatarSelect>
|
||||
</BlurFade>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
61
src/components/magicui/blur-fade.tsx
Normal file
61
src/components/magicui/blur-fade.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
src/components/magicui/pulsating-button.tsx
Normal file
37
src/components/magicui/pulsating-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
53
src/components/magicui/word-pull-up.tsx
Normal file
53
src/components/magicui/word-pull-up.tsx
Normal 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> </span> : word}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.h1>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue