diff --git a/src/app/content/App.tsx b/src/app/content/App.tsx index e658204..7ff010a 100644 --- a/src/app/content/App.tsx +++ b/src/app/content/App.tsx @@ -62,19 +62,31 @@ export default function App() { }, [danmakuIsEnabled]) return ( - appStatusLoadIsFinished && ( -
- -
-
-
- ) +
+ {appStatusLoadIsFinished && ( + <> + +
+
+
) } diff --git a/src/app/content/index.tsx b/src/app/content/index.tsx index 153f9c6..1d317cb 100644 --- a/src/app/content/index.tsx +++ b/src/app/content/index.tsx @@ -13,10 +13,10 @@ import { NotificationImpl } from '@/domain/impls/Notification' import { ToastImpl } from '@/domain/impls/Toast' // import { PeerRoomImpl } from '@/domain/impls/PeerRoom' import { PeerRoomImpl } from '@/domain/impls/PeerRoom2' -import '@/assets/styles/tailwind.css' // Remove import after merging: https://github.com/emilkowalski/sonner/pull/508 import '@/assets/styles/sonner.css' import '@/assets/styles/overlay.css' +import '@/assets/styles/tailwind.css' import NotificationDomain from '@/domain/Notification' import { createElement } from '@/utils' diff --git a/src/app/content/views/AppButton/index.tsx b/src/app/content/views/AppButton/index.tsx index 0d463c2..e30fd53 100644 --- a/src/app/content/views/AppButton/index.tsx +++ b/src/app/content/views/AppButton/index.tsx @@ -107,7 +107,7 @@ const AppButton: FC = ({ className }) => { - - diff --git a/src/domain/AppStatus.ts b/src/domain/AppStatus.ts index 8a6b732..fb12dbf 100644 --- a/src/domain/AppStatus.ts +++ b/src/domain/AppStatus.ts @@ -40,7 +40,7 @@ const AppStatusDomain = Remesh.domain({ }) const StatusState = domain.state({ - name: 'AppStatus.OpenState', + name: 'AppStatus.StatusState', default: defaultStatusState }) diff --git a/src/domain/Room.ts b/src/domain/Room.ts index b1f9132..3833d0d 100644 --- a/src/domain/Room.ts +++ b/src/domain/Room.ts @@ -58,7 +58,7 @@ export interface TextMessage extends MessageUser { export type RoomMessage = SyncUserMessage | SyncHistoryMessage | LikeMessage | HateMessage | TextMessage -export type RoomUser = MessageUser & { peerId: string; joinTime: number } +export type RoomUser = MessageUser & { peerIds: string[]; joinTime: number } const MessageUserSchema = { userId: v.string(), @@ -165,7 +165,7 @@ const RoomDomain = Remesh.domain({ const SelfUserQuery = domain.query({ name: 'Room.SelfUserQuery', impl: ({ get }) => { - return get(UserListQuery()).find((user) => user.peerId === get(PeerIdQuery()))! + return get(UserListQuery()).find((user) => user.peerIds.includes(peerRoom.peerId))! } }) @@ -185,9 +185,7 @@ const RoomDomain = Remesh.domain({ const JoinRoomCommand = domain.command({ name: 'Room.JoinRoomCommand', impl: ({ get }) => { - peerRoom.joinRoom() const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! - return [ UpdateUserListCommand({ type: 'create', @@ -204,15 +202,20 @@ const RoomDomain = Remesh.domain({ receiveTime: Date.now() }), JoinStatusModule.command.SetFinishedCommand(), - JoinRoomEvent(peerRoom.roomId) + JoinRoomEvent(peerRoom.roomId), + SelfJoinRoomEvent(peerRoom.roomId) ] } }) + JoinRoomCommand.after(() => { + peerRoom.joinRoom() + return null + }) + const LeaveRoomCommand = domain.command({ name: 'Room.LeaveRoomCommand', impl: ({ get }) => { - peerRoom.leaveRoom() const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())! return [ messageListDomain.command.CreateItemCommand({ @@ -230,11 +233,17 @@ const RoomDomain = Remesh.domain({ user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar } }), JoinStatusModule.command.SetInitialCommand(), - LeaveRoomEvent(peerRoom.roomId) + LeaveRoomEvent(peerRoom.roomId), + SelfLeaveRoomEvent(peerRoom.roomId) ] } }) + LeaveRoomCommand.after(() => { + peerRoom.leaveRoom() + return null + }) + const SendTextMessageCommand = domain.command({ name: 'Room.SendTextMessageCommand', impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => { @@ -314,6 +323,7 @@ const RoomDomain = Remesh.domain({ const syncUserMessage: SyncUserMessage = { ...self, id: nanoid(), + peerId: peerRoom.peerId, sendTime: Date.now(), lastMessageTime, type: SendType.SyncUser @@ -393,12 +403,32 @@ const RoomDomain = Remesh.domain({ const UpdateUserListCommand = domain.command({ name: 'Room.UpdateUserListCommand', - impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => { + impl: ({ get }, action: { type: 'create' | 'delete'; user: Omit & { peerId: string } }) => { const userList = get(UserListState()) + const existUser = userList.find((user) => user.userId === action.user.userId) if (action.type === 'create') { - return [UserListState().new(upsert(userList, action.user, 'userId'))] + return [ + UserListState().new( + upsert( + userList, + { ...action.user, peerIds: [...(existUser?.peerIds || []), action.user.peerId] }, + 'userId' + ) + ) + ] } else { - return [UserListState().new(userList.filter(({ userId }) => userId !== action.user.userId))] + return [ + UserListState().new( + upsert( + userList, + { + ...action.user, + peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || [] + }, + 'userId' + ).filter((user) => user.peerIds.length) + ) + ] } } }) @@ -443,10 +473,18 @@ const RoomDomain = Remesh.domain({ name: 'Room.OnJoinRoomEvent' }) + const SelfJoinRoomEvent = domain.event({ + name: 'Room.SelfJoinRoomEvent' + }) + const OnLeaveRoomEvent = domain.event({ name: 'Room.OnLeaveRoomEvent' }) + const SelfLeaveRoomEvent = domain.event({ + name: 'Room.SelfLeaveRoomEvent' + }) + const OnErrorEvent = domain.event({ name: 'Room.OnErrorEvent' }) @@ -486,37 +524,33 @@ const RoomDomain = Remesh.domain({ const messageCommand$ = (() => { switch (message.type) { case SendType.SyncUser: { - const userList = get(UserListQuery()) const selfUser = get(SelfUserQuery()) - // If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId - const isRepeatJoin = userList.some((user) => user.userId === message.userId) - // When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event - // Use joinTime to determine if it's a new user - const isNewJoinEvent = selfUser.joinTime < message.joinTime + + // If a new user joins after the current user has entered the room, a join log message needs to be created. + const existUser = get(UserListQuery()).find((user) => user.userId === message.userId) + const isNewJoinUser = !existUser && message.joinTime > selfUser.joinTime const lastMessageTime = get(LastMessageTimeQuery()) const needSyncHistory = lastMessageTime > message.lastMessageTime - return isRepeatJoin - ? EMPTY - : of( - UpdateUserListCommand({ type: 'create', user: message }), - isNewJoinEvent - ? messageListDomain.command.CreateItemCommand({ - ...message, - id: nanoid(), - body: `"${message.username}" joined the chat`, - type: MessageType.Prompt, - receiveTime: Date.now() - }) - : null, - needSyncHistory - ? SendSyncHistoryMessageCommand({ - peerId: message.peerId, - lastMessageTime: message.lastMessageTime - }) - : null - ) + return of( + UpdateUserListCommand({ type: 'create', user: message }), + isNewJoinUser + ? messageListDomain.command.CreateItemCommand({ + ...message, + id: nanoid(), + body: `"${message.username}" joined the chat`, + type: MessageType.Prompt, + receiveTime: Date.now() + }) + : null, + needSyncHistory + ? SendSyncHistoryMessageCommand({ + peerId: message.peerId, + lastMessageTime: message.lastMessageTime + }) + : null + ) } case SendType.SyncHistory: { @@ -574,20 +608,26 @@ const RoomDomain = Remesh.domain({ impl: ({ get }) => { const onLeaveRoom$ = fromEventPattern(peerRoom.onLeaveRoom).pipe( map((peerId) => { + if (get(JoinStatusModule.query.IsInitialQuery())) { + return null + } // console.log('onLeaveRoom', peerId) - const user = get(UserListQuery()).find((user) => user.peerId === peerId) - if (user) { + const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId)) + + if (existUser) { return [ - UpdateUserListCommand({ type: 'delete', user }), - messageListDomain.command.CreateItemCommand({ - ...user, - id: nanoid(), - body: `"${user.username}" left the chat`, - type: MessageType.Prompt, - sendTime: Date.now(), - receiveTime: Date.now() - }), + UpdateUserListCommand({ type: 'delete', user: { ...existUser, peerId } }), + existUser.peerIds.length === 1 + ? messageListDomain.command.CreateItemCommand({ + ...existUser, + id: nanoid(), + body: `"${existUser.username}" left the chat`, + type: MessageType.Prompt, + sendTime: Date.now(), + receiveTime: Date.now() + }) + : null, OnLeaveRoomEvent(peerId) ] } else { @@ -612,7 +652,6 @@ const RoomDomain = Remesh.domain({ } }) - // TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes domain.effect({ name: 'Room.OnUnloadEffect', impl: ({ get }) => { @@ -647,7 +686,9 @@ const RoomDomain = Remesh.domain({ SendSyncUserMessageEvent, SendSyncHistoryMessageEvent, JoinRoomEvent, + SelfJoinRoomEvent, LeaveRoomEvent, + SelfLeaveRoomEvent, OnMessageEvent, OnTextMessageEvent, OnJoinRoomEvent, diff --git a/src/domain/Toast.ts b/src/domain/Toast.ts index 6ff3dc5..32c1abb 100644 --- a/src/domain/Toast.ts +++ b/src/domain/Toast.ts @@ -8,6 +8,18 @@ const ToastDomain = Remesh.domain({ impl: (domain) => { const roomDomain = domain.getDomain(RoomDomain()) const toastModule = ToastModule(domain) + + domain.effect({ + name: 'Toast.OnRoomSelfJoinRoomEffect', + impl: ({ fromEvent }) => { + const onRoomJoin$ = fromEvent(roomDomain.event.SelfJoinRoomEvent).pipe( + map(() => toastModule.command.LoadingCommand('Connected to the chat.')) + ) + + return onRoomJoin$ + } + }) + domain.effect({ name: 'Toast.OnRoomErrorEffect', impl: ({ fromEvent }) => { diff --git a/src/domain/externs/PeerRoom.ts b/src/domain/externs/PeerRoom.ts index 5d79063..9e32627 100644 --- a/src/domain/externs/PeerRoom.ts +++ b/src/domain/externs/PeerRoom.ts @@ -5,7 +5,7 @@ export interface PeerRoom { readonly peerId: string readonly roomId: string joinRoom: () => PeerRoom - sendMessage: (message: RoomMessage, id?: string) => PeerRoom + sendMessage: (message: RoomMessage, id?: string | string[]) => PeerRoom onMessage: (callback: (message: RoomMessage) => void) => PeerRoom leaveRoom: () => PeerRoom onJoinRoom: (callback: (id: string) => void) => PeerRoom diff --git a/src/domain/externs/Toast.ts b/src/domain/externs/Toast.ts index 1ea08f9..0bf3030 100644 --- a/src/domain/externs/Toast.ts +++ b/src/domain/externs/Toast.ts @@ -1,10 +1,12 @@ import { Remesh } from 'remesh' export interface Toast { - success: (message: string) => void - error: (message: string) => void - info: (message: string) => void - warning: (message: string) => void + success: (message: string, duration?: number) => number | string + error: (message: string, duration?: number) => number | string + info: (message: string, duration?: number) => number | string + warning: (message: string, duration?: number) => number | string + loading: (message: string, duration?: number) => number | string + cancel: (id: number | string) => number | string } export const ToastExtern = Remesh.extern({ @@ -20,6 +22,12 @@ export const ToastExtern = Remesh.extern({ }, warning: () => { throw new Error('"warning" not implemented.') + }, + loading: () => { + throw new Error('"loading" not implemented.') + }, + cancel: () => { + throw new Error('"cancel" not implemented.') } } }) diff --git a/src/domain/impls/PeerRoom.ts b/src/domain/impls/PeerRoom.ts index 361f878..1f234b6 100644 --- a/src/domain/impls/PeerRoom.ts +++ b/src/domain/impls/PeerRoom.ts @@ -45,7 +45,7 @@ class PeerRoom extends EventHub { return this } - sendMessage(message: RoomMessage, id?: string) { + sendMessage(message: RoomMessage, id?: string | string[]) { if (!this.room) { this.once('action', () => { if (!this.room) { diff --git a/src/domain/impls/PeerRoom2.ts b/src/domain/impls/PeerRoom2.ts index 2336d70..30dc665 100644 --- a/src/domain/impls/PeerRoom2.ts +++ b/src/domain/impls/PeerRoom2.ts @@ -46,7 +46,7 @@ class PeerRoom extends EventHub { return this } - sendMessage(message: RoomMessage, id?: string) { + sendMessage(message: RoomMessage, id?: string | string[]) { if (!this.room) { this.once('action', () => { if (!this.room) { diff --git a/src/domain/impls/Toast.ts b/src/domain/impls/Toast.ts index db6c60d..7fb703b 100644 --- a/src/domain/impls/Toast.ts +++ b/src/domain/impls/Toast.ts @@ -2,16 +2,24 @@ import { toast } from 'sonner' import { ToastExtern } from '@/domain/externs/Toast' export const ToastImpl = ToastExtern.impl({ - success: (message: string) => { - toast.success(message) + success: (message: string, duration: number = 4000) => { + return toast.success(message, { duration }) }, - error: (message: string) => { - toast.error(message) + error: (message: string, duration: number = 4000) => { + return toast.error(message, { duration }) }, - info: (message: string) => { - toast.info(message) + info: (message: string, duration: number = 4000) => { + return toast.info(message, { duration }) }, - warning: (message: string) => { - toast.warning(message) + warning: (message: string, duration: number = 4000) => { + return toast.warning(message, { duration }) + }, + loading: (message: string, duration: number = 4000) => { + const id = toast.loading(message, { duration }) + setTimeout(() => toast.dismiss(id), duration) + return id + }, + cancel: (id: number | string) => { + return toast.dismiss(id) } }) diff --git a/src/domain/modules/Toast.ts b/src/domain/modules/Toast.ts index b117d84..788b08d 100644 --- a/src/domain/modules/Toast.ts +++ b/src/domain/modules/Toast.ts @@ -8,51 +8,90 @@ export interface ToastOptions { const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => { const toast = domain.getExtern(ToastExtern) - const SuccessEvent = domain.event({ + const SuccessEvent = domain.event({ name: `${options.name}.SuccessEvent` }) const SuccessCommand = domain.command({ name: `${options.name}.SuccessCommand`, - impl: (_, message: string) => { - toast.success(message) - return [SuccessEvent()] + impl: (_, message: string | { message: string; duration?: number }) => { + const id = toast.success( + typeof message === 'string' ? message : message.message, + typeof message === 'string' ? undefined : message.duration + ) + return [SuccessEvent(id)] } }) - const ErrorEvent = domain.event({ + const ErrorEvent = domain.event({ name: `${options.name}.ErrorEvent` }) const ErrorCommand = domain.command({ name: `${options.name}.ErrorCommand`, - impl: (_, message: string) => { - toast.error(message) - return [ErrorEvent()] + impl: (_, message: string | { message: string; duration?: number }) => { + const id = toast.error( + typeof message === 'string' ? message : message.message, + typeof message === 'string' ? undefined : message.duration + ) + return [ErrorEvent(id)] } }) - const InfoEvent = domain.event({ + const InfoEvent = domain.event({ name: `${options.name}.InfoEvent` }) const InfoCommand = domain.command({ name: `${options.name}.InfoCommand`, - impl: (_, message: string) => { - toast.info(message) - return [InfoEvent()] + impl: (_, message: string | { message: string; duration?: number }) => { + const id = toast.info( + typeof message === 'string' ? message : message.message, + typeof message === 'string' ? undefined : message.duration + ) + return [InfoEvent(id)] } }) - const WarningEvent = domain.event({ + const WarningEvent = domain.event({ name: `${options.name}.WarningEvent` }) const WarningCommand = domain.command({ name: `${options.name}.WarningCommand`, - impl: (_, message: string) => { - toast.warning(message) - return [WarningEvent()] + impl: (_, message: string | { message: string; duration?: number }) => { + const id = toast.warning( + typeof message === 'string' ? message : message.message, + typeof message === 'string' ? undefined : message.duration + ) + return [WarningEvent(id)] + } + }) + + const LoadingEvent = domain.event({ + name: `${options.name}.LoadingEvent` + }) + + const LoadingCommand = domain.command({ + name: `${options.name}.LoadingCommand`, + impl: (_, message: string | { message: string; duration?: number }) => { + const id = toast.loading( + typeof message === 'string' ? message : message.message, + typeof message === 'string' ? undefined : message.duration + ) + return [LoadingEvent(id)] + } + }) + + const CancelEvent = domain.event({ + name: `${options.name}.CancelEvent` + }) + + const CancelCommand = domain.command({ + name: `${options.name}.CancelCommand`, + impl: (_, id: number | string) => { + toast.cancel(id) + return [CancelEvent(id)] } }) @@ -61,13 +100,17 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name SuccessEvent, ErrorEvent, InfoEvent, - WarningEvent + WarningEvent, + LoadingEvent, + CancelEvent }, command: { SuccessCommand, ErrorCommand, InfoCommand, - WarningCommand + WarningCommand, + LoadingCommand, + CancelCommand } } }