chore: complete message send

This commit is contained in:
molvqingtai 2023-08-03 04:28:15 +08:00
parent 50c7b92e36
commit a9c055b467
11 changed files with 214 additions and 104 deletions

View file

@ -95,6 +95,7 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.263.0", "lucide-react": "^0.263.0",
"nanoid": "^4.0.2",
"peerjs": "^1.4.7", "peerjs": "^1.4.7",
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-nice-avatar": "^1.4.1", "react-nice-avatar": "^1.4.1",

View file

@ -38,6 +38,9 @@ dependencies:
lucide-react: lucide-react:
specifier: ^0.263.0 specifier: ^0.263.0
version: 0.263.0(react@18.2.0) version: 0.263.0(react@18.2.0)
nanoid:
specifier: ^4.0.2
version: 4.0.2
peerjs: peerjs:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
@ -5717,6 +5720,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
/nanoid@4.0.2:
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
engines: {node: ^14 || ^16 || >=18}
hasBin: true
dev: false
/natural-compare-lite@1.4.0: /natural-compare-lite@1.4.0:
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
dev: true dev: true

View file

@ -1,57 +1,52 @@
import { type FC, type ChangeEvent, type KeyboardEvent } from 'react' import { type FC, type ChangeEvent, type KeyboardEvent } from 'react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils' import { cn } from '@/utils'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH } from '@/constants'
export interface MessageInputProps { export interface MessageInputProps {
value?: string
className?: string className?: string
maxLength?: number maxLength?: number
preview?: boolean
onInput?: (value: string) => void
onEnter?: (value: string) => void
} }
const MessageInput: FC<MessageInputProps> = ({ className }) => { const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength = 500, onInput, onEnter, preview }) => {
const send = useRemeshSend() const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.ValueQuery())
const isPreview = useRemeshQuery(messageInputDomain.query.PreviewQuery())
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
send(messageInputDomain.command.InputCommand(e.target.value))
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) { if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault() e.preventDefault()
send(messageInputDomain.command.EnterCommand()) onEnter?.(value)
} }
} }
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onInput?.(e.target.value)
}
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
{isPreview ? ( {preview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{message}</Markdown> <Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : ( ) : (
// Hack: Auto-Growing Textarea // Hack: Auto-Growing Textarea
<div <div
data-value={message} data-value={value}
className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40" className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40"
> >
<Textarea <Textarea
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH} maxLength={maxLength}
className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40" className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
rows={2} rows={2}
value={message} value={value}
placeholder="Type your message here." placeholder="Type your message here."
onInput={handleInput} onInput={handleInput}
/> />
</div> </div>
)} )}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400"> <div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{message?.length ?? 0}/{MESSAGE_MAX_LENGTH} {value?.length ?? 0}/{maxLength}
</div> </div>
</div> </div>
) )

View file

@ -4,22 +4,13 @@ import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import LikeButton from '@/components/LikeButton' import LikeButton from '@/components/LikeButton'
import { type Message } from '@/domain/MessageList'
export interface MessageProps { export interface MessageItemProps {
data: { data: Message
id: string
body: string
username: string
avatar: string
date: number
likeChecked: boolean
hateChecked: boolean
likeCount: number
hateCount: number
}
} }
const Message: FC<MessageProps> = ({ data }) => { const MessageItem: FC<MessageItemProps> = ({ data }) => {
const [formatData, setFormatData] = useState({ const [formatData, setFormatData] = useState({
...data, ...data,
date: format(data.date, 'yyyy/MM/dd HH:mm:ss') date: format(data.date, 'yyyy/MM/dd HH:mm:ss')
@ -76,5 +67,5 @@ const Message: FC<MessageProps> = ({ data }) => {
) )
} }
Message.displayName = 'Message' MessageItem.displayName = 'MessageItem'
export default Message export default MessageItem

View file

@ -0,0 +1,12 @@
import { type ReactElement, type FC } from 'react'
import { type MessageItemProps } from './MessageItem'
export interface MessageListProps {
children?: Array<ReactElement<MessageItemProps>>
}
const MessageList: FC<MessageListProps> = ({ children }) => {
return <div className="grid content-start overflow-y-auto p-4">{children}</div>
}
export default MessageList

View file

@ -4,10 +4,12 @@ import InputModule from './modules/Input'
const MessageInputDomain = Remesh.domain({ const MessageInputDomain = Remesh.domain({
name: 'MessageInputDomain', name: 'MessageInputDomain',
impl: (domain) => { impl: (domain) => {
const inputModule = InputModule(domain, { const MessageInputModule = InputModule(domain, {
name: 'MessageInput' name: 'MessageInputModule'
}) })
const MessageQuery = MessageInputModule.query.ValueQuery
const PreviewState = domain.state({ const PreviewState = domain.state({
name: 'MessageInput.PreviewState', name: 'MessageInput.PreviewState',
default: false default: false
@ -28,7 +30,10 @@ const MessageInputDomain = Remesh.domain({
}) })
const EnterEvent = domain.event({ const EnterEvent = domain.event({
name: 'MessageInput.EnterEvent' name: 'MessageInput.EnterEvent',
impl: ({ get }) => {
return get(MessageInputModule.query.ValueQuery())
}
}) })
const EnterCommand = domain.command({ const EnterCommand = domain.command({
@ -41,23 +46,23 @@ const MessageInputDomain = Remesh.domain({
const ClearCommand = domain.command({ const ClearCommand = domain.command({
name: 'MessageInput.ClearCommand', name: 'MessageInput.ClearCommand',
impl: () => { impl: () => {
return inputModule.command.InputCommand('') return MessageInputModule.command.InputCommand('')
} }
}) })
return { return {
query: { query: {
...inputModule.query, MessageQuery,
PreviewQuery PreviewQuery
}, },
command: { command: {
...inputModule.command, ...MessageInputModule.command,
EnterCommand, EnterCommand,
ClearCommand, ClearCommand,
PreviewCommand PreviewCommand
}, },
event: { event: {
...inputModule.event, ...MessageInputModule.event,
EnterEvent EnterEvent
} }
} }

102
src/domain/MessageList.ts Normal file
View file

@ -0,0 +1,102 @@
import { Remesh } from 'remesh'
import { ListModule } from 'remesh/modules/list'
import { nanoid } from 'nanoid'
export interface Message {
id: string
body: string
username: string
avatar: string
date: number
likeChecked: boolean
hateChecked: boolean
likeCount: number
hateCount: number
}
const MessageListDomain = Remesh.domain({
name: 'MessageListDomain',
impl: (domain) => {
const MessageListModule = ListModule<Message>(domain, {
name: 'MessageListModule',
key: (message) => message.id
})
const ListQuery = MessageListModule.query.ItemListQuery
const ItemQuery = MessageListModule.query.ItemQuery
const ChangeEvent = domain.event({
name: 'MessageList.ChangeEvent',
impl: ({ get }) => {
return get(ListQuery())
}
})
const CreateEvent = domain.event({
name: 'MessageList.CreateEvent'
})
const CreateCommand = domain.command({
name: 'MessageList.CreateCommand',
impl: (_, message: Omit<Message, 'id'>) => {
const id = nanoid()
return [MessageListModule.command.AddItemCommand({ ...message, id }), CreateEvent(), ChangeEvent()]
}
})
const UpdateEvent = domain.event({
name: 'MessageList.UpdateEvent'
})
const UpdateCommand = domain.command({
name: 'MessageList.UpdateCommand',
impl: (_, message: Message) => {
return [MessageListModule.command.UpdateItemCommand(message), UpdateEvent(), ChangeEvent()]
}
})
const DeleteEvent = domain.event({
name: 'MessageList.DeleteEvent'
})
const DeleteCommand = domain.command({
name: 'MessageList.DeleteCommand',
impl: (_, id: string) => {
return [MessageListModule.command.DeleteItemCommand(id), DeleteEvent(), ChangeEvent()]
}
})
const ClearEvent = domain.event({
name: 'MessageList.ClearEvent'
})
const ClearCommand = domain.command({
name: 'MessageList.ClearCommand',
impl: () => {
return [MessageListModule.command.SetListCommand([]), ClearEvent(), ChangeEvent()]
}
})
return {
query: {
ItemQuery,
ListQuery
},
command: {
CreateCommand,
UpdateCommand,
DeleteCommand,
ClearCommand
},
event: {
CreateEvent,
UpdateEvent,
DeleteEvent,
ClearEvent
}
}
}
})
export default MessageListDomain

View file

@ -1,7 +1,7 @@
import { Remesh, type Capitalize, type RemeshDomainContext } from 'remesh' import { Remesh, type DomainConceptName, type RemeshDomainContext } from 'remesh'
export interface InputModuleOptions { export interface InputModuleOptions {
name: Capitalize name: DomainConceptName<'InputModule'>
value?: string value?: string
disabled?: boolean disabled?: boolean
} }
@ -19,27 +19,31 @@ const InputModule = (domain: RemeshDomainContext, options: InputModuleOptions) =
} }
}) })
const InputEvent = domain.event<string>({ const InputEvent = domain.event({
name: `${options.name}.InputEvent` name: `${options.name}.InputEvent`,
impl: ({ get }) => {
return get(ValueState())
}
}) })
const InputCommand = domain.command({ const InputCommand = domain.command({
name: `${options.name}.InputCommand`, name: `${options.name}.InputCommand`,
impl: (_, value: string) => { impl: (_, value: string) => {
InputEvent(value) return [ValueState().new(value), InputEvent()]
return ValueState().new(value)
} }
}) })
const ChangeEvent = domain.event<string>({ const ChangeEvent = domain.event({
name: `${options.name}.ChangeEvent` name: `${options.name}.ChangeEvent`,
impl: ({ get }) => {
return get(ValueState())
}
}) })
const ChangeCommand = domain.command({ const ChangeCommand = domain.command({
name: `${options.name}.ChangeCommand`, name: `${options.name}.ChangeCommand`,
impl: (_, value: string) => { impl: (_, value: string) => {
ChangeEvent(value) return [ValueState().new(value), ChangeEvent()]
return ValueState().new(value)
} }
}) })
@ -55,27 +59,25 @@ const InputModule = (domain: RemeshDomainContext, options: InputModuleOptions) =
} }
}) })
const FocusEvent = domain.event<boolean>({ const FocusEvent = domain.event({
name: `${options.name}.FocusEvent` name: `${options.name}.FocusEvent`
}) })
const BlurEvent = domain.event<boolean>({ const BlurEvent = domain.event({
name: `${options.name}.BlurEvent` name: `${options.name}.BlurEvent`
}) })
const BlurCommand = domain.command({ const BlurCommand = domain.command({
name: `${options.name}.BlurCommand`, name: `${options.name}.BlurCommand`,
impl: () => { impl: () => {
BlurEvent(false) return [FocusState().new(false), BlurEvent()]
return FocusState().new(false)
} }
}) })
const FocusCommand = domain.command({ const FocusCommand = domain.command({
name: `${options.name}.FocusCommand`, name: `${options.name}.FocusCommand`,
impl: () => { impl: () => {
FocusEvent(true) return [FocusState().new(true), FocusEvent()]
return FocusState().new(true)
} }
}) })

View file

@ -1,7 +1,7 @@
import { useMedia } from 'react-use' import { useMedia } from 'react-use'
import { BREAKPOINTS } from '@/constants' import { BREAKPOINTS } from '@/constants'
export function useBreakpoint() { const useBreakpoint = () => {
const isSM = useMedia(`(min-width: ${BREAKPOINTS.sm})`) const isSM = useMedia(`(min-width: ${BREAKPOINTS.sm})`)
const isMD = useMedia(`(min-width: ${BREAKPOINTS.md})`) const isMD = useMedia(`(min-width: ${BREAKPOINTS.md})`)
const isLG = useMedia(`(min-width: ${BREAKPOINTS.lg})`) const isLG = useMedia(`(min-width: ${BREAKPOINTS.lg})`)
@ -15,3 +15,5 @@ export function useBreakpoint() {
is2XL is2XL
} }
} }
export default useBreakpoint

View file

@ -1,22 +1,42 @@
import { type FC } from 'react' import { type FC } from 'react'
import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react' import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshEvent, useRemeshSend } from 'remesh-react' import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import MessageInput from '@/components/MessageInput' import MessageInput from '@/components/MessageInput'
import MessageInputDomain from '@/domain/MessageInput' import MessageInputDomain from '@/domain/MessageInput'
import MessageListDomain from '@/domain/MessageList'
const Footer: FC = () => { const Footer: FC = () => {
const send = useRemeshSend() const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain()) const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const isPreview = useRemeshQuery(messageInputDomain.query.PreviewQuery())
const handleInput = (value: string) => {
send(messageInputDomain.command.InputCommand(value))
}
const handleSend = () => { const handleSend = () => {
send(
messageListDomain.command.CreateCommand({
username: '墨绿青苔',
avatar: 'https://avatars.githubusercontent.com/u/10251037?v=4',
body: message,
date: Date.now(),
likeChecked: false,
likeCount: 0,
hateChecked: false,
hateCount: 0
})
)
send(messageInputDomain.command.ClearCommand()) send(messageInputDomain.command.ClearCommand())
} }
useRemeshEvent(messageInputDomain.event.EnterEvent, handleSend)
return ( return (
<div className="grid gap-y-2 p-4"> <div className="grid gap-y-2 p-4">
<MessageInput></MessageInput> <MessageInput value={message} preview={isPreview} onEnter={handleSend} onInput={handleInput}></MessageInput>
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end"> <div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<SmileIcon size={20} /> <SmileIcon size={20} />

View file

@ -1,48 +1,19 @@
import { type FC } from 'react' import { type FC } from 'react'
import Message from '@/components/Message' import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import MessageList from '@/components/MessageList'
import MessageItem from '@/components/MessageItem'
import MessageListDomain from '@/domain/MessageList'
const Main: FC = () => { const Main: FC = () => {
const messages = [ const messageListDomain = useRemeshDomain(MessageListDomain())
{ const messageList = useRemeshQuery(messageListDomain.query.ListQuery())
id: '1',
body: 'Who are you?',
username: 'molvqingtai',
avatar: 'https://github.com/shadcn.png',
date: Date.now(),
likeChecked: false,
hateChecked: false,
likeCount: 0,
hateCount: 0
},
{
id: '2',
body: `I'm Chinese`,
username: 'Love XJP',
avatar: 'https://github.com/shadcn.png',
date: Date.now(),
likeChecked: false,
hateChecked: false,
likeCount: 0,
hateCount: 0
},
{
id: '3',
body: 'Do you like XJP?',
username: 'molvqingtai',
avatar: 'https://github.com/shadcn.png',
date: Date.now(),
likeChecked: false,
hateChecked: true,
likeCount: 9999,
hateCount: 2
}
]
return ( return (
<div className="grid content-start overflow-y-auto p-4"> <MessageList>
{messages.map((message) => ( {messageList.map((message) => (
<Message key={message.id} data={message} /> <MessageItem key={message.id} data={message}></MessageItem>
))} ))}
</div> </MessageList>
) )
} }