feat: implement join and leave prompts
This commit is contained in:
parent
8a18871b90
commit
ec62b1155e
20 changed files with 373 additions and 139 deletions
|
@ -1,19 +1,22 @@
|
|||
import { type FC } from 'react'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { type Message } from '@/domain/MessageList'
|
||||
import { type NormalMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
export interface MessageItemProps {
|
||||
data: Message
|
||||
data: NormalMessage
|
||||
index?: number
|
||||
like: boolean
|
||||
hate: boolean
|
||||
onLikeChange?: (checked: boolean) => void
|
||||
onHateChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MessageItem: FC<MessageItemProps> = (props) => {
|
||||
|
@ -24,7 +27,10 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
props.onHateChange?.(checked)
|
||||
}
|
||||
return (
|
||||
<div data-index={props.index} className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4">
|
||||
<div
|
||||
data-index={props.index}
|
||||
className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={props.data.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
|
||||
|
@ -65,4 +71,5 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
}
|
||||
|
||||
MessageItem.displayName = 'MessageItem'
|
||||
|
||||
export default MessageItem
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { FC, useRef, type ReactElement } from 'react'
|
||||
|
||||
import { type MessageItemProps } from './MessageItem'
|
||||
import { type PromptItemProps } from './PromptItem'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
export interface MessageListProps {
|
||||
children?: Array<ReactElement<MessageItemProps>>
|
||||
children?: Array<ReactElement<MessageItemProps | PromptItemProps>>
|
||||
}
|
||||
const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||
const scrollParentRef = useRef<HTMLDivElement>(null)
|
||||
|
@ -16,7 +17,7 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
|||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
data={children}
|
||||
customScrollParent={scrollParentRef.current!}
|
||||
itemContent={(_: any, item: ReactElement<MessageItemProps>) => item}
|
||||
itemContent={(_: any, item: ReactElement<MessageItemProps | PromptItemProps>) => item}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
|
|
29
src/app/content/components/PromptItem.tsx
Normal file
29
src/app/content/components/PromptItem.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { PromptMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: PromptMessage
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
||||
return (
|
||||
<div className={cn('flex justify-center py-1', className)}>
|
||||
<Badge variant="secondary" className="gap-x-2 rounded-full font-medium text-slate-400">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={data.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{data.body}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PromptItem.displayName = 'PromptItem'
|
||||
|
||||
export default PromptItem
|
|
@ -11,14 +11,15 @@ import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Stora
|
|||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import { createElement } from '@/utils'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
|
||||
export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
matches: ['*://*.example.com/*', '*://*.v2ex.com/*'],
|
||||
async main(ctx) {
|
||||
const store = Remesh.store({
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl],
|
||||
inspectors: __DEV__ ? [RemeshLogger()] : []
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl],
|
||||
inspectors: !__DEV__ ? [RemeshLogger()] : []
|
||||
})
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
|
@ -32,11 +33,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
|
||||
},
|
||||
|
|
|
@ -32,7 +32,7 @@ const Footer: FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-4 before:h-4 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
|
|
|
@ -6,11 +6,13 @@ import { Button } from '@/components/ui/Button'
|
|||
import { getSiteInfo } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import { selfId } from 'trystero'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const peerList = useRemeshQuery(roomDomain.query.PeerListQuery())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
console.log('userList', [...userList], userList.length)
|
||||
|
||||
return (
|
||||
<div className="z-10 grid h-12 grid-flow-col items-center justify-between gap-x-4 rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
||||
|
@ -24,7 +26,8 @@ const Header: FC = () => {
|
|||
<HoverCardTrigger asChild>
|
||||
<Button className="overflow-hidden" variant="link">
|
||||
<span className="truncate text-lg font-medium text-slate-600">
|
||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||
{/* {siteInfo.hostname.replace(/^www\./i, '')} */}
|
||||
{selfId}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
|
@ -45,7 +48,7 @@ const Header: FC = () => {
|
|||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<div className="text-sm text-slate-500">Online {peerList.length}</div>
|
||||
<div className="text-sm text-slate-500">Online {userList.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@ import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
|||
|
||||
import MessageList from '../../components/MessageList'
|
||||
import MessageItem from '../../components/MessageItem'
|
||||
import PromptItem from '../../components/PromptItem'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import RoomDomain, { MessageType } from '@/domain/Room'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
|
@ -12,11 +13,16 @@ const Main: FC = () => {
|
|||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const _messageList = useRemeshQuery(roomDomain.query.MessageListQuery())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const messageList = _messageList.map((message) => ({
|
||||
const messageList = _messageList.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
|
@ -28,7 +34,8 @@ const Main: FC = () => {
|
|||
|
||||
return (
|
||||
<MessageList>
|
||||
{messageList.map((message, index) => (
|
||||
{messageList.map((message, index) =>
|
||||
message.type === MessageType.Normal ? (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
|
@ -38,7 +45,14 @@ const Main: FC = () => {
|
|||
onLikeChange={() => handleLikeChange(message.id)}
|
||||
onHateChange={() => handleHateChange(message.id)}
|
||||
></MessageItem>
|
||||
))}
|
||||
) : (
|
||||
<PromptItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||
></PromptItem>
|
||||
)
|
||||
)}
|
||||
</MessageList>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import MessageListDomain, { Message } from '@/domain/MessageList'
|
||||
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
|
||||
import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
|
@ -46,6 +46,7 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
id: nanoid(),
|
||||
body: mockTextList.shift()!,
|
||||
date: Date.now(),
|
||||
type: MessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import { type ChangeEvent } from 'react'
|
||||
import { ImagePlusIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { cn, compressImage } from '@/utils'
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import * as v from 'valibot'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
import { toast } from 'sonner'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect } from 'react'
|
||||
|
@ -15,6 +14,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
|||
import { Label } from '@/components/ui/Label'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
|
||||
const defaultUserInfo: UserInfo = {
|
||||
id: nanoid(),
|
||||
|
@ -52,6 +52,8 @@ const formSchema = v.object({
|
|||
|
||||
const ProfileForm = () => {
|
||||
const send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
|
||||
|
@ -67,15 +69,15 @@ const ProfileForm = () => {
|
|||
|
||||
const handleSubmit = (userInfo: UserInfo) => {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
|
||||
toast.success('Saved successfully!')
|
||||
send(toastDomain.command.SuccessCommand('Saved successfully!'))
|
||||
}
|
||||
|
||||
const handleWarning = (error: Error) => {
|
||||
toast.warning(error.message)
|
||||
send(toastDomain.command.WarningCommand(error.message))
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
toast.error(error.message)
|
||||
send(toastDomain.command.ErrorCommand(error.message))
|
||||
}
|
||||
|
||||
const handleRefreshAvatar = async () => {
|
||||
|
|
|
@ -6,9 +6,10 @@ import { RemeshLogger } from 'remesh-logger'
|
|||
import App from './App'
|
||||
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [BrowserSyncStorageImpl],
|
||||
externs: [BrowserSyncStorageImpl, ToastImpl],
|
||||
inspectors: [RemeshLogger()]
|
||||
})
|
||||
|
||||
|
|
|
@ -3,13 +3,19 @@ import { ListModule } from 'remesh/modules/list'
|
|||
import { IndexDBStorageExtern } from '@/domain/externs/Storage'
|
||||
import StorageEffect from '@/domain/modules/StorageEffect'
|
||||
|
||||
export enum MessageType {
|
||||
Normal = 'normal',
|
||||
Prompt = 'prompt'
|
||||
}
|
||||
|
||||
export interface MessageUser {
|
||||
userId: string
|
||||
username: string
|
||||
userAvatar: string
|
||||
}
|
||||
|
||||
export interface Message extends MessageUser {
|
||||
export interface NormalMessage extends MessageUser {
|
||||
type: MessageType.Normal
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
|
@ -17,6 +23,15 @@ export interface Message extends MessageUser {
|
|||
hateUsers: MessageUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
type: MessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
date: number
|
||||
}
|
||||
|
||||
export type Message = NormalMessage | PromptMessage
|
||||
|
||||
export const STORAGE_KEY = `MESSAGE_LIST`
|
||||
|
||||
const MessageListDomain = Remesh.domain({
|
||||
|
|
|
@ -1,36 +1,48 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, switchMap, tap, defer, of, EMPTY, mergeMap } from 'rxjs'
|
||||
import { type MessageUser } from './MessageList'
|
||||
import { map, merge, switchMap, tap, of, EMPTY, mergeMap } from 'rxjs'
|
||||
import { NormalMessage, type MessageUser } from './MessageList'
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { callbackToObservable, desert } from '@/utils'
|
||||
import { callbackToObservable, desert, upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from './modules/Status'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
|
||||
export enum MessageType {
|
||||
export { MessageType }
|
||||
|
||||
export enum SendType {
|
||||
Like = 'like',
|
||||
Hate = 'hate',
|
||||
Text = 'text'
|
||||
Text = 'text',
|
||||
UserSync = 'userSync'
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.UserSync
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
}
|
||||
|
||||
export interface LikeMessage extends MessageUser {
|
||||
type: MessageType.Like
|
||||
type: SendType.Like
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HateMessage extends MessageUser {
|
||||
type: MessageType.Hate
|
||||
type: SendType.Hate
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TextMessage extends MessageUser {
|
||||
type: MessageType.Text
|
||||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type RoomMessage = LikeMessage | HateMessage | TextMessage
|
||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerId: string; joinTime: number }
|
||||
|
||||
const RoomDomain = Remesh.domain({
|
||||
name: 'RoomDomain',
|
||||
|
@ -41,35 +53,51 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
const MessageListQuery = messageListDomain.query.ListQuery
|
||||
|
||||
const RoomStatusState = StatusModule(domain, {
|
||||
const RoomStatusModule = StatusModule(domain, {
|
||||
name: 'Room.RoomStatusModule'
|
||||
})
|
||||
|
||||
const PeerListState = domain.state<string[]>({
|
||||
name: 'Room.PeerListState',
|
||||
default: [peerRoom.selfId]
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'RoomUserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const PeerListQuery = domain.query({
|
||||
name: 'Room.PeerListQuery',
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerListState())
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'RoomJoinRoomCommand',
|
||||
impl: (_, roomId: string) => {
|
||||
impl: ({ get }, roomId: string) => {
|
||||
peerRoom.joinRoom(roomId)
|
||||
return [JoinRoomEvent(roomId), RoomStatusState.command.SetFinishedCommand()]
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
JoinRoomEvent(roomId),
|
||||
RoomStatusModule.command.SetFinishedCommand(),
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId: peerRoom.selfId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'RoomLeaveRoomCommand',
|
||||
impl: (_, roomId: string) => {
|
||||
impl: ({ get }, roomId: string) => {
|
||||
peerRoom.leaveRoom()
|
||||
return [LeaveRoomEvent(roomId), RoomStatusState.command.SetInitialCommand()]
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
LeaveRoomEvent(roomId),
|
||||
RoomStatusModule.command.SetInitialCommand(),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { peerId: peerRoom.selfId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -78,31 +106,29 @@ const RoomDomain = Remesh.domain({
|
|||
impl: ({ get }, message: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const id = nanoid()
|
||||
const date = Date.now()
|
||||
return [
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id,
|
||||
type: MessageType.Normal,
|
||||
body: message,
|
||||
date: Date.now(),
|
||||
date,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
}),
|
||||
SendTextMessageEvent({ id, body: message, userId, username, userAvatar, type: MessageType.Text })
|
||||
SendTextMessageEvent({ id, body: message, userId, username, userAvatar, type: SendType.Text })
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'RoomSendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'RoomSendLikeMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const _message = get(messageListDomain.query.ItemQuery(messageId))
|
||||
const _message = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
return [
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
|
@ -116,20 +142,22 @@ const RoomDomain = Remesh.domain({
|
|||
'userId'
|
||||
)
|
||||
}),
|
||||
SendLikeMessageEvent({ id: messageId, userId, username, userAvatar, type: MessageType.Like })
|
||||
SendLikeMessageEvent({
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.Like
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'RoomSendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageCommand = domain.command({
|
||||
name: 'RoomSendHateMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const _message = get(messageListDomain.query.ItemQuery(messageId))
|
||||
const _message = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
return [
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
|
@ -144,12 +172,44 @@ const RoomDomain = Remesh.domain({
|
|||
'userId'
|
||||
)
|
||||
}),
|
||||
SendHateMessageEvent({ id: messageId, userId, username, userAvatar, type: MessageType.Hate })
|
||||
SendHateMessageEvent({ id: messageId, userId, username, userAvatar, type: SendType.Hate })
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<RoomMessage>({
|
||||
const SendUserSyncMessageCommand = domain.command({
|
||||
name: 'RoomSendUserSyncMessageCommand',
|
||||
impl: ({ get }, targetPeerId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const joinTime = get(UserListQuery()).find((u) => u.peerId === peerRoom.selfId)?.joinTime || Date.now()
|
||||
return [
|
||||
SendUserSyncMessageEvent({
|
||||
id: nanoid(),
|
||||
peerId: peerRoom.selfId,
|
||||
targetPeerId,
|
||||
userId,
|
||||
joinTime,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.UserSync
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendUserSyncMessageEvent = domain.event<SyncUserMessage & { targetPeerId: string }>({
|
||||
name: 'RoomSendUserSyncMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'RoomSendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<LikeMessage>({
|
||||
name: 'RoomSendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<HateMessage>({
|
||||
name: 'RoomSendHateMessageEvent'
|
||||
})
|
||||
|
||||
|
@ -173,17 +233,15 @@ const RoomDomain = Remesh.domain({
|
|||
name: 'RoomOnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const UpdatePeerListCommand = domain.command({
|
||||
name: 'RoomUpdatePeerListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; peerId: string }) => {
|
||||
const peerList = get(PeerListState())
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'RoomUpdateUserListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
|
||||
const userList = get(UserListState())
|
||||
if (action.type === 'create') {
|
||||
return [PeerListState().new([...new Set(peerList).add(action.peerId)])]
|
||||
return [UserListState().new(upsert(userList, action.user, 'peerId'))]
|
||||
} else {
|
||||
return [UserListState().new(userList.filter(({ peerId }) => peerId !== action.user.peerId))]
|
||||
}
|
||||
if (action.type === 'delete') {
|
||||
return [PeerListState().new(peerList.filter((peerId) => peerId == action.peerId))]
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -204,7 +262,7 @@ const RoomDomain = Remesh.domain({
|
|||
impl: ({ fromEvent }) => {
|
||||
const likeMessage$ = fromEvent(SendLikeMessageEvent).pipe(
|
||||
tap(async (message) => {
|
||||
peerRoom.sendMessage<RoomMessage>(message)
|
||||
return peerRoom.sendMessage<RoomMessage>(message)
|
||||
})
|
||||
)
|
||||
return merge(likeMessage$).pipe(map(() => null))
|
||||
|
@ -223,6 +281,20 @@ const RoomDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomSendUserSyncMessageEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const userSyncMessage$ = fromEvent(SendUserSyncMessageEvent).pipe(
|
||||
tap(async (message) => {
|
||||
console.log('sendMessage', message)
|
||||
|
||||
peerRoom.sendMessage<RoomMessage>(message, message.targetPeerId)
|
||||
})
|
||||
)
|
||||
return merge(userSyncMessage$).pipe(map(() => null))
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomOnMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
|
@ -235,27 +307,44 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
const commandEvent$ = (() => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
case SendType.UserSync: {
|
||||
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.selfId)!
|
||||
if (self.joinTime > message.joinTime) {
|
||||
return EMPTY
|
||||
}
|
||||
return of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
id: nanoid(),
|
||||
body: `"${message.username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
})
|
||||
)
|
||||
}
|
||||
case SendType.Text:
|
||||
return of(
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
)
|
||||
case 'like':
|
||||
case 'hate': {
|
||||
case SendType.Like:
|
||||
case SendType.Hate: {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
return EMPTY
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id))
|
||||
const users = message.type === 'like' ? 'likeUsers' : 'hateUsers'
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
const type = message.type === 'like' ? 'likeUsers' : 'hateUsers'
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
[users]: desert(
|
||||
_message[users],
|
||||
[type]: desert(
|
||||
_message[type],
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
|
@ -280,12 +369,20 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
domain.effect({
|
||||
name: 'RoomOnJoinRoomEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onJoinRoom$ = fromEvent(JoinRoomEvent).pipe(
|
||||
switchMap(() => callbackToObservable<string>(peerRoom.onJoinRoom.bind(peerRoom))),
|
||||
map((peerId) => {
|
||||
mergeMap((peerId) => {
|
||||
console.log('onJoinRoom', peerId)
|
||||
return [UpdatePeerListCommand({ type: 'create', peerId }), OnJoinRoomEvent(peerId)]
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
SendUserSyncMessageCommand(peerId),
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
OnJoinRoomEvent(peerId)
|
||||
]
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
|
@ -294,12 +391,27 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
domain.effect({
|
||||
name: 'RoomOnLeaveRoomEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onLeaveRoom$ = fromEvent(JoinRoomEvent).pipe(
|
||||
switchMap(() => callbackToObservable<string>(peerRoom.onLeaveRoom.bind(peerRoom))),
|
||||
map((peerId) => {
|
||||
console.log('onLeaveRoom', peerId)
|
||||
return [UpdatePeerListCommand({ type: 'delete', peerId }), OnLeaveRoomEvent(peerId)]
|
||||
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
|
||||
if (user) {
|
||||
return [
|
||||
UpdateUserListCommand({ type: 'delete', user }),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...user,
|
||||
id: nanoid(),
|
||||
body: `"${user.username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
|
@ -308,20 +420,9 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
return {
|
||||
query: {
|
||||
PeerListQuery,
|
||||
UserListQuery,
|
||||
MessageListQuery,
|
||||
...RoomStatusState.query
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
JoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
...RoomStatusState.event
|
||||
...RoomStatusModule.query
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
|
@ -329,7 +430,20 @@ const RoomDomain = Remesh.domain({
|
|||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
...RoomStatusState.command
|
||||
SendUserSyncMessageCommand,
|
||||
...RoomStatusModule.command
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendUserSyncMessageEvent,
|
||||
JoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent,
|
||||
...RoomStatusModule.event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
11
src/domain/Toast.ts
Normal file
11
src/domain/Toast.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import ToastModule from './modules/Toast'
|
||||
|
||||
const ToastDomain = Remesh.domain({
|
||||
name: 'ToastDomain',
|
||||
impl: (domain) => {
|
||||
return ToastModule(domain)
|
||||
}
|
||||
})
|
||||
|
||||
export default ToastDomain
|
|
@ -6,12 +6,11 @@ export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
|
|||
export interface PeerRoom {
|
||||
readonly selfId: string
|
||||
joinRoom: (roomId: string) => Promise<any>
|
||||
sendMessage: <T extends PeerMessage>(message: T) => Promise<any>
|
||||
sendMessage: <T extends PeerMessage>(message: T, id?: string) => Promise<any>
|
||||
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => Promisable<void>
|
||||
leaveRoom: () => Promisable<void>
|
||||
onJoinRoom: (callback: (id: string) => void) => Promisable<void>
|
||||
onLeaveRoom: (callback: (id: string) => void) => Promisable<void>
|
||||
getRoomPeers: () => string[]
|
||||
}
|
||||
|
||||
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
|
||||
|
@ -34,9 +33,6 @@ export const PeerRoomExtern = Remesh.extern<PeerRoom>({
|
|||
},
|
||||
onLeaveRoom: () => {
|
||||
throw new Error('"onLeaveRoom" not implemented.')
|
||||
},
|
||||
getRoomPeers: () => {
|
||||
throw new Error('"getRoomPeers" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
25
src/domain/externs/Toast.ts
Normal file
25
src/domain/externs/Toast.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Remesh } from 'remesh'
|
||||
|
||||
export interface Toast {
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
info: (message: string) => void
|
||||
warning: (message: string) => void
|
||||
}
|
||||
|
||||
export const ToastExtern = Remesh.extern<Toast>({
|
||||
default: {
|
||||
success: () => {
|
||||
throw new Error('"success" not implemented.')
|
||||
},
|
||||
error: () => {
|
||||
throw new Error('"error" not implemented.')
|
||||
},
|
||||
info: () => {
|
||||
throw new Error('"info" not implemented.')
|
||||
},
|
||||
warning: () => {
|
||||
throw new Error('"warning" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -24,12 +24,12 @@ class PeerRoom {
|
|||
return this.room
|
||||
}
|
||||
|
||||
async sendMessage<T extends PeerMessage>(message: T) {
|
||||
async sendMessage<T extends PeerMessage>(message: T, id?: string) {
|
||||
if (!this.room) {
|
||||
throw new Error('Room not joined')
|
||||
}
|
||||
const [send] = this.room!.makeAction('MESSAGE')
|
||||
return await send(message as DataPayload)
|
||||
return await send(message as DataPayload, id)
|
||||
}
|
||||
|
||||
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
|
||||
|
@ -54,13 +54,6 @@ class PeerRoom {
|
|||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
}
|
||||
|
||||
getRoomPeers() {
|
||||
if (!this.room) {
|
||||
throw new Error('Room not joined')
|
||||
}
|
||||
return Object.keys(this.room.getPeers()).map((id) => id)
|
||||
}
|
||||
|
||||
async leaveRoom() {
|
||||
return await this.room?.leave()
|
||||
}
|
||||
|
|
17
src/domain/impls/Toast.ts
Normal file
17
src/domain/impls/Toast.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { toast } from 'sonner'
|
||||
import { ToastExtern } from '@/domain/externs/Toast'
|
||||
|
||||
export const ToastImpl = ToastExtern.impl({
|
||||
success: (message: string) => {
|
||||
toast.success(message)
|
||||
},
|
||||
error: (message: string) => {
|
||||
toast.error(message)
|
||||
},
|
||||
info: (message: string) => {
|
||||
toast.info(message)
|
||||
},
|
||||
warning: (message: string) => {
|
||||
toast.warning(message)
|
||||
}
|
||||
})
|
|
@ -12,7 +12,7 @@ export interface StatusOptions {
|
|||
}
|
||||
|
||||
const StatusModule = (domain: RemeshDomainContext, options: StatusOptions) => {
|
||||
const StatusState = domain.state({
|
||||
const RoomStatusModule = domain.state({
|
||||
name: `${options.name}.StatusState`,
|
||||
default: options.default ?? Status.Initial
|
||||
})
|
||||
|
@ -20,14 +20,14 @@ const StatusModule = (domain: RemeshDomainContext, options: StatusOptions) => {
|
|||
const StatusQuery = domain.query({
|
||||
name: `${options.name}.StatusQuery`,
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState())
|
||||
return get(RoomStatusModule())
|
||||
}
|
||||
})
|
||||
|
||||
const IsInitialQuery = domain.query({
|
||||
name: `${options.name}.IsInitialQuery`,
|
||||
impl: ({ get }) => {
|
||||
const state = get(StatusState())
|
||||
const state = get(RoomStatusModule())
|
||||
return (state & Status.Initial) !== 0
|
||||
}
|
||||
})
|
||||
|
@ -35,7 +35,7 @@ const StatusModule = (domain: RemeshDomainContext, options: StatusOptions) => {
|
|||
const IsLoadingQuery = domain.query({
|
||||
name: `${options.name}.IsLoadingQuery`,
|
||||
impl: ({ get }) => {
|
||||
const state = get(StatusState())
|
||||
const state = get(RoomStatusModule())
|
||||
return (state & Status.Loading) !== 0
|
||||
}
|
||||
})
|
||||
|
@ -43,7 +43,7 @@ const StatusModule = (domain: RemeshDomainContext, options: StatusOptions) => {
|
|||
const IsFinishedQuery = domain.query({
|
||||
name: `${options.name}.IsFinishedQuery`,
|
||||
impl: ({ get }) => {
|
||||
const state = get(StatusState())
|
||||
const state = get(RoomStatusModule())
|
||||
return (state & Status.Finished) !== 0
|
||||
}
|
||||
})
|
||||
|
@ -55,28 +55,28 @@ const StatusModule = (domain: RemeshDomainContext, options: StatusOptions) => {
|
|||
const SetInitialCommand = domain.command({
|
||||
name: `${options.name}.SetInitialCommand`,
|
||||
impl: () => {
|
||||
return [StatusState().new(Status.Initial), UpdateStatusEvent(Status.Initial)]
|
||||
return [RoomStatusModule().new(Status.Initial), UpdateStatusEvent(Status.Initial)]
|
||||
}
|
||||
})
|
||||
|
||||
const SetLoadingCommand = domain.command({
|
||||
name: `${options.name}.SetLoadingCommand`,
|
||||
impl: () => {
|
||||
return [StatusState().new(Status.Loading), UpdateStatusEvent(Status.Loading)]
|
||||
return [RoomStatusModule().new(Status.Loading), UpdateStatusEvent(Status.Loading)]
|
||||
}
|
||||
})
|
||||
|
||||
const SetFinishedCommand = domain.command({
|
||||
name: `${options.name}.SetFinishedCommand`,
|
||||
impl: () => {
|
||||
return [StatusState().new(Status.Finished), UpdateStatusEvent(Status.Finished)]
|
||||
return [RoomStatusModule().new(Status.Finished), UpdateStatusEvent(Status.Finished)]
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateStatusCommand = domain.command({
|
||||
name: `${options.name}.UpdateStatusCommand`,
|
||||
impl: (_, status: Status) => {
|
||||
return [StatusState().new(status), UpdateStatusEvent(status)]
|
||||
return [RoomStatusModule().new(status), UpdateStatusEvent(status)]
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { type RemeshDomainContext, type DomainConceptName } from 'remesh'
|
||||
import { toast } from 'sonner'
|
||||
import { ToastExtern } from '../externs/Toast'
|
||||
|
||||
export interface ToastOptions {
|
||||
name: DomainConceptName<'ToastModule'>
|
||||
}
|
||||
|
||||
export const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
|
||||
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
|
||||
const SuccessEvent = domain.event({
|
||||
name: `${options.name}.SuccessEvent`
|
||||
})
|
||||
|
@ -69,3 +71,5 @@ export const ToastModule = (domain: RemeshDomainContext, options: ToastOptions =
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ToastModule
|
||||
|
|
Loading…
Reference in a new issue