feat: implement join and leave prompts

This commit is contained in:
molvqingtai 2024-09-23 23:15:22 +08:00
parent 8a18871b90
commit ec62b1155e
20 changed files with 373 additions and 139 deletions

View file

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

View file

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

View 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

View file

@ -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>
<RemeshRoot store={store}>
<App />
</RemeshRoot>
</React.StrictMode>
// <React.StrictMode>
<RemeshRoot store={store}>
<App />
</RemeshRoot>
// </React.StrictMode>
)
return root
},

View file

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

View file

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

View file

@ -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) => ({
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}))
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,17 +34,25 @@ const Main: FC = () => {
return (
<MessageList>
{messageList.map((message, index) => (
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
index={index}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
></MessageItem>
))}
{messageList.map((message, index) =>
message.type === MessageType.Normal ? (
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
index={index}
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>
)
}

View file

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

View file

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

View file

@ -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 () => {

View file

@ -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()]
})

View file

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

View file

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

View file

@ -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.')
}
}
})

View 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.')
}
}
})

View file

@ -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
View 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)
}
})

View file

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

View file

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