diff --git a/src/api/utils/LocalStorageKeys.ts b/src/api/utils/LocalStorageKeys.ts index 6c92279..dd74db5 100644 --- a/src/api/utils/LocalStorageKeys.ts +++ b/src/api/utils/LocalStorageKeys.ts @@ -2,4 +2,5 @@ export class LocalStorageKeys { public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects'; public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation'; + public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; } diff --git a/src/components/room/widgets/chat/ChatWidgetView.tsx b/src/components/room/widgets/chat/ChatWidgetView.tsx index 46cb7ea..ccab140 100644 --- a/src/components/room/widgets/chat/ChatWidgetView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetView.tsx @@ -1,14 +1,16 @@ import { RoomChatSettings } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useRef } from 'react'; import { ChatBubbleMessage, DoChatsOverlap, GetConfigurationValue } from '../../../../api'; -import { useChatWidget } from '../../../../hooks'; +import { useChatWidget, useChatWindow } from '../../../../hooks'; import IntervalWebWorker from '../../../../workers/IntervalWebWorker'; import { WorkerBuilder } from '../../../../workers/WorkerBuilder'; import { ChatWidgetMessageView } from './ChatWidgetMessageView'; +import { ChatWidgetWindowView } from './ChatWidgetWindowView'; export const ChatWidgetView: FC<{}> = props => { const { chatMessages = [], setChatMessages = null, chatSettings = null, getScrollSpeed = 6000 } = useChatWidget(); + const [ chatWindowEnabled ] = useChatWindow(); const elementRef = useRef(); const removeHiddenChats = useCallback(() => @@ -156,7 +158,8 @@ export const ChatWidgetView: FC<{}> = props => return (
- { chatMessages.map(chat => ) } + { !chatWindowEnabled && chatMessages.map(chat => ) } + { chatWindowEnabled && }
); }; diff --git a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx new file mode 100644 index 0000000..2b9dd21 --- /dev/null +++ b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx @@ -0,0 +1,172 @@ +import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer'; +import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChatEntryType } from '../../../../api'; +import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useChatHistory, useChatWindow } from '../../../../hooks'; +import { useRoom } from '../../../../hooks/rooms'; + +const BOTTOM_SCROLL_THRESHOLD = 20; + +export const ChatWidgetWindowView: FC<{}> = () => +{ + const contentRef = useRef(null); + const lastScrollTop = useRef(0); + const [ isAutoScrollEnabled, setIsAutoScrollEnabled ] = useState(true); + const [ hidePets, setHidePets ] = useState(false); + const [ hideAvatars, setHideAvatars ] = useState(false); + const [ hideBalloons, setHideBalloons ] = useState(false); + const [ search, setSearch ] = useState(''); + const { chatHistory = [], clearChatHistory = null } = useChatHistory(); + const [ , setChatWindowEnabled ] = useChatWindow(); + const { roomSession = null } = useRoom(); + const ownUserId = (GetSessionDataManager()?.userId || -1); + + const roomChatHistory = useMemo(() => + { + const normalizedSearch = search.trim().toLowerCase(); + + return chatHistory.filter(chat => + { + if(chat.type !== ChatEntryType.TYPE_CHAT) return false; + if(chat.roomId !== roomSession?.roomId) return false; + if(hidePets && chat.entityType === RoomObjectType.PET) return false; + + if(!normalizedSearch.length) return true; + + return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch)); + }); + }, [ chatHistory, roomSession?.roomId, hidePets, search ]); + + const isAtBottom = useCallback((element: HTMLDivElement) => + { + const distanceToBottom = (element.scrollHeight - element.clientHeight - element.scrollTop); + + return (distanceToBottom <= BOTTOM_SCROLL_THRESHOLD); + }, []); + + const scrollToLatest = useCallback((smooth: boolean = true) => + { + if(!contentRef.current) return; + + const element = contentRef.current; + + element.scrollTo({ top: element.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); + }, []); + + const onScroll = useCallback((event: UIEvent) => + { + const element = event.currentTarget; + const atBottom = isAtBottom(element); + const isScrollingUp = (element.scrollTop < lastScrollTop.current); + + lastScrollTop.current = element.scrollTop; + + if(atBottom) + { + if(!isAutoScrollEnabled) setIsAutoScrollEnabled(true); + + return; + } + + if(isAutoScrollEnabled && isScrollingUp) setIsAutoScrollEnabled(false); + }, [ isAtBottom, isAutoScrollEnabled ]); + + useEffect(() => + { + if(!contentRef.current || !isAutoScrollEnabled) return; + + scrollToLatest(); + }, [ roomChatHistory.length, isAutoScrollEnabled, scrollToLatest ]); + + return ( + + + { + setChatWindowEnabled(false); + + if(clearChatHistory) clearChatHistory(); + } } /> + +
+ + + + +
+ setSearch(event.target.value) } /> +
+
+
+ { roomChatHistory.map(chat => + { + const isOwnMessage = (chat.webId === ownUserId); + const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`; + + return ( +
+ { hideBalloons && !hideAvatars &&
} + { hideBalloons && ( +
+ + +
+ ) } + { !hideBalloons && ( +
+ { chat.style === 0 && ( +
+ ) } +
+
+ { !hideAvatars && chat.imageUrl && chat.imageUrl.length > 0 && ( +
+ ) } +
+
+ + +
+
+
+ ) } +
+ ); + }) } +
+ { !isAutoScrollEnabled && ( + + ) } + + + ); +}; diff --git a/src/components/room/widgets/chat/ChatWidgetWindowView_old.tsx b/src/components/room/widgets/chat/ChatWidgetWindowView_old.tsx new file mode 100644 index 0000000..46c6bc2 --- /dev/null +++ b/src/components/room/widgets/chat/ChatWidgetWindowView_old.tsx @@ -0,0 +1,93 @@ +import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChatEntryType } from '../../../../api'; +import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useChatHistory } from '../../../../hooks'; +import { useRoom } from '../../../../hooks/rooms'; + +const BOTTOM_SCROLL_THRESHOLD = 20; + +export const ChatWidgetWindowView: FC<{}> = () => +{ + const contentRef = useRef(null); + const lastScrollTop = useRef(0); + const [ isAutoScrollEnabled, setIsAutoScrollEnabled ] = useState(true); + const { chatHistory = [] } = useChatHistory(); + const { roomSession = null } = useRoom(); + + const roomChatHistory = useMemo(() => chatHistory.filter(chat => ((chat.type === ChatEntryType.TYPE_CHAT) && (chat.roomId === roomSession?.roomId))), [ chatHistory, roomSession?.roomId ]); + + const isAtBottom = useCallback((element: HTMLDivElement) => + { + const distanceToBottom = (element.scrollHeight - element.clientHeight - element.scrollTop); + + return (distanceToBottom <= BOTTOM_SCROLL_THRESHOLD); + }, []); + + const scrollToLatest = useCallback((smooth: boolean = true) => + { + if(!contentRef.current) return; + + const element = contentRef.current; + + element.scrollTo({ top: element.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); + }, []); + + const onScroll = useCallback((event: UIEvent) => + { + const element = event.currentTarget; + const atBottom = isAtBottom(element); + const isScrollingUp = (element.scrollTop < lastScrollTop.current); + + lastScrollTop.current = element.scrollTop; + + if(atBottom) + { + if(!isAutoScrollEnabled) setIsAutoScrollEnabled(true); + + return; + } + + if(isAutoScrollEnabled && isScrollingUp) setIsAutoScrollEnabled(false); + }, [ isAtBottom, isAutoScrollEnabled ]); + + useEffect(() => + { + if(!contentRef.current || !isAutoScrollEnabled) return; + + scrollToLatest(); + }, [ roomChatHistory.length, isAutoScrollEnabled, scrollToLatest ]); + + return ( + + + +
+ { roomChatHistory.map(chat => ( +
+
+
+ + +
+
+ )) } +
+ { !isAutoScrollEnabled && ( + + ) } + + + ); +}; diff --git a/src/components/user-settings/UserSettingsView.tsx b/src/components/user-settings/UserSettingsView.tsx index d52df74..12603f0 100644 --- a/src/components/user-settings/UserSettingsView.tsx +++ b/src/components/user-settings/UserSettingsView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useMessageEvent } from '../../hooks'; +import { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks'; import { classNames } from '../../layout'; export const UserSettingsView: FC<{}> = props => @@ -12,6 +12,7 @@ export const UserSettingsView: FC<{}> = props => const [ userSettings, setUserSettings ] = useState(null); const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); + const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow(); const processAction = (type: string, value?: boolean | number | string) => { @@ -151,6 +152,10 @@ export const UserSettingsView: FC<{}> = props => setCatalogSkipPurchaseConfirmation(event.target.checked) } /> { LocalizeText('memenu.settings.other.skip.purchase.confirmation') }
+
+ setChatWindowEnabled(event.target.checked) } /> + Enable chat window +
{ LocalizeText('widget.memenu.settings.volume') } diff --git a/src/hooks/chat-history/useChatHistory.ts b/src/hooks/chat-history/useChatHistory.ts index 83efe9a..217359d 100644 --- a/src/hooks/chat-history/useChatHistory.ts +++ b/src/hooks/chat-history/useChatHistory.ts @@ -5,7 +5,6 @@ import { ChatEntryType, ChatHistoryCurrentDate, IChatEntry, IRoomHistoryEntry, M import { useMessageEvent, useNitroEvent } from '../events'; import { useLocalStorage } from '../useLocalStorage'; -const CHAT_HISTORY_MAX = 1000; const ROOM_HISTORY_MAX = 10; const MESSENGER_HISTORY_MAX = 1000; @@ -29,12 +28,13 @@ const useChatHistoryState = () => newValue.push(entry); - if(newValue.length > CHAT_HISTORY_MAX) newValue.shift(); return newValue; }); }; + const clearChatHistory = () => setChatHistory([]); + const addRoomHistoryEntry = (entry: IRoomHistoryEntry) => { setRoomHistory(prevValue => @@ -99,7 +99,7 @@ const useChatHistoryState = () => addMessengerEntry({ id: -1, webId: parser.senderId, entityId: -1, name: '', message: parser.messageText, roomId: -1, timestamp: MessengerHistoryCurrentDate(), type: ChatEntryType.TYPE_IM }); }); - return { addChatEntry, chatHistory, roomHistory, messengerHistory }; + return { addChatEntry, clearChatHistory, chatHistory, roomHistory, messengerHistory }; }; export const useChatHistory = () => useBetween(useChatHistoryState); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 955e919..037ab0a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,3 +22,4 @@ export * from './session'; export * from './useLocalStorage'; export * from './useSharedVisibility'; export * from './wired'; +export * from './useChatWindow'; diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index fa59fe0..d430a38 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -5,6 +5,8 @@ import { useMessageEvent, useNitroEvent } from '../../events'; import { useRoom } from '../useRoom'; import { useChatHistory } from './../../chat-history'; +const CHAT_MESSAGES_MAX = 250; + const useChatWidgetState = () => { const [chatMessages, setChatMessages] = useState([]); @@ -146,7 +148,14 @@ const useChatWidgetState = () => imageUrl, color); - setChatMessages(prevValue => [...prevValue, chatMessage]); + setChatMessages(prevValue => + { + const newValue = [ ...prevValue, chatMessage ]; + + if(newValue.length > CHAT_MESSAGES_MAX) newValue.shift(); + + return newValue; + }); addChatEntry({ id: -1, webId: userData.webID, entityId: userData.roomIndex, name: username, imageUrl, style: styleId, chatType: chatType, entityType: userData.type, message: formattedText, timestamp: ChatHistoryCurrentDate(), type: ChatEntryType.TYPE_CHAT, roomId: roomSession.roomId, color }); }); diff --git a/src/hooks/useChatWindow.ts b/src/hooks/useChatWindow.ts new file mode 100644 index 0000000..425dd51 --- /dev/null +++ b/src/hooks/useChatWindow.ts @@ -0,0 +1,7 @@ +import { useBetween } from 'use-between'; +import { LocalStorageKeys } from '../api'; +import { useLocalStorage } from './useLocalStorage'; + +const useChatWindowState = () => useLocalStorage(LocalStorageKeys.CHAT_WINDOW_ENABLED, false); + +export const useChatWindow = () => useBetween(useChatWindowState);