🆙 Fix the Chat History with autoscroll at bottom

This commit is contained in:
duckietm 2025-03-19 17:02:09 +01:00
parent de0f723c64
commit 9d6744ae23
8 changed files with 115 additions and 75 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

View File

@ -1,42 +1,57 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../api'; import { ChatEntryType, LocalizeText } from '../../api';
import { Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useChatHistory } from '../../hooks'; import { useChatHistory } from '../../hooks';
import { NitroInput } from '../../layout'; import { NitroInput } from '../../layout';
export const ChatHistoryView: FC<{}> = props => export const ChatHistoryView: FC<{}> = props => {
{ const [isVisible, setIsVisible] = useState(false);
const [ isVisible, setIsVisible ] = useState(false); const [searchText, setSearchText] = useState<string>('');
const [ searchText, setSearchText ] = useState<string>('');
const { chatHistory = [] } = useChatHistory(); const { chatHistory = [] } = useChatHistory();
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
const prevChatLength = useRef<number>(0);
const filteredChatHistory = useMemo(() => const filteredChatHistory = useMemo(() => {
{ let result = chatHistory;
if(searchText.length === 0) return chatHistory;
if (searchText.length > 0) {
const text = searchText.toLowerCase();
result = chatHistory.filter(entry =>
(entry.message && entry.message.toLowerCase().includes(text)) ||
(entry.name && entry.name.toLowerCase().includes(text))
);
}
return [...result];
}, [chatHistory, searchText]);
let text = searchText.toLowerCase(); useEffect(() => {
if (!elementRef.current || !isVisible) return;
return chatHistory.filter(entry => ((entry.message && entry.message.toLowerCase().includes(text))) || (entry.name && entry.name.toLowerCase().includes(text))); const element = elementRef.current;
}, [ chatHistory, searchText ]); const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
const isAtBottom = maxScrollTop === 0 || Math.abs(element.scrollTop - maxScrollTop) <= 50;
useEffect(() => if (isFirstRender.current) {
{ element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
if(elementRef && elementRef.current && isVisible) elementRef.current.scrollTop = elementRef.current.scrollHeight; isFirstRender.current = false;
}, [ isVisible ]); } else if (filteredChatHistory.length > prevChatLength.current) {
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
}
useEffect(() => prevChatLength.current = filteredChatHistory.length;
{ }, [filteredChatHistory, isVisible]);
useEffect(() => {
const linkTracker: ILinkEventTracker = { const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => linkReceived: (url: string) => {
{
const parts = url.split('/'); const parts = url.split('/');
if(parts.length < 2) return; if (parts.length < 2) return;
switch(parts[1]) switch (parts[1]) {
{
case 'show': case 'show':
setIsVisible(true); setIsVisible(true);
return; return;
@ -44,7 +59,7 @@ export const ChatHistoryView: FC<{}> = props =>
setIsVisible(false); setIsVisible(false);
return; return;
case 'toggle': case 'toggle':
setIsVisible(prevValue => !prevValue); setIsVisible(prevValue => !prevChatLength.current);
return; return;
} }
}, },
@ -56,42 +71,44 @@ export const ChatHistoryView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker);
}, []); }, []);
if(!isVisible) return null; if (!isVisible) return null;
return ( return (
<NitroCardView className="nitro-chat-history" theme="primary-slim" uniqueKey="chat-history"> <NitroCardView className="nitro-chat-history" theme="primary-slim" uniqueKey="chat-history" style={{ height: '100%' }}>
<NitroCardHeaderView headerText={ LocalizeText('room.chathistory.button.text') } onCloseClick={ event => setIsVisible(false) } /> <NitroCardHeaderView headerText={LocalizeText('room.chathistory.button.text')} onCloseClick={event => setIsVisible(false)} />
<NitroCardContentView gap={ 2 } innerRef={ elementRef } overflow="hidden"> <NitroCardContentView className="nitro-card-content" gap={2} overflow="hidden" style={{ height: 'calc(100% - 40px)', display: 'flex', flexDirection: 'column' }}>
<NitroInput placeholder={ LocalizeText('generic.search') } type="text" value={ searchText } onChange={ event => setSearchText(event.target.value) } /> <NitroInput placeholder={LocalizeText('generic.search')} type="text" value={searchText} onChange={event => setSearchText(event.target.value)} />
<InfiniteScroll rowRender={ row => <div ref={elementRef} style={{ flex: 1, overflowY: 'auto', background: 'inherit' }}>
{ {filteredChatHistory.map((row, index) => (
return ( <Flex key={index} alignItems="center" className="p-1" gap={2}>
<Flex alignItems="center" className="p-1" gap={ 2 }> <Text variant="muted">{row.timestamp}</Text>
<Text variant="muted">{ row.timestamp }</Text> {row.type === ChatEntryType.TYPE_CHAT && (
{ (row.type === ChatEntryType.TYPE_CHAT) && <div className="bubble-container" style={{position: 'relative', display: 'inline-flex', alignItems: 'center'}}>
<div className="bubble-container" style={ { position: 'relative' } }> <div
{ (row.style === 0) && className={`chat-bubble bubble-${row.style} type-${row.chatType}`}
<div className="user-container-bg" style={ { backgroundColor: row.color } } /> } style={{ maxWidth: '100%', backgroundColor: row.style === 0 ? row.color : 'transparent', position: 'relative', zIndex: 1 }}>
<div className={ `chat-bubble bubble-${ row.style } type-${ row.chatType }` } style={ { maxWidth: '100%' } }>
<div className="user-container"> <div className="user-container">
{ row.imageUrl && (row.imageUrl.length > 0) && {row.imageUrl && row.imageUrl.length > 0 && (
<div className="user-image" style={ { backgroundImage: `url(${ row.imageUrl })` } } /> } <div className="user-image" style={{backgroundImage: `url(${row.imageUrl})`}} />
)}
</div> </div>
<div className="chat-content"> <div className="chat-content">
<b className="mr-1 username" dangerouslySetInnerHTML={ { __html: `${ row.name }: ` } } /> <b className="mr-1 username" dangerouslySetInnerHTML={{__html: `${row.name}: `}} />
<span className="message" dangerouslySetInnerHTML={ { __html: `${ row.message }` } } /> <span className="message" dangerouslySetInnerHTML={{__html: `${row.message}`}} />
</div> </div>
</div> </div>
</div> } </div>
{ (row.type === ChatEntryType.TYPE_ROOM_INFO) && )}
{row.type === ChatEntryType.TYPE_ROOM_INFO && (
<> <>
<i className="nitro-icon icon-small-room" /> <i className="nitro-icon icon-small-room" />
<Text grow textBreak wrap>{ row.name }</Text> <Text grow textBreak wrap>{row.name}</Text>
</> } </>
)}
</Flex> </Flex>
); ))}
} } rows={ filteredChatHistory } scrollToBottom={ true } /> </div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
}; };

View File

@ -0,0 +1,31 @@
/* ChatHistoryView.css */
.nitro-chat-history {
width: 400px;
height: 200px;
background-color: #f0f0f0;
}
/* Increase specificity by repeating the .nitro-chat-history class */
.nitro-chat-history .nitro-card-content {
height: 100%;
background-image: url('@/assets/images/chat/chathistory_background.png');
background-repeat: repeat;
background-size: auto;
background-color: #f0f0f0;
}
/* Increase specificity for the slide-in animation */
.nitro-chat-history .p-1.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
0% {
transform: translateY(-20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -853,4 +853,4 @@
background: url('@/assets/images/chat/chatbubbles/bubble_38_extra.png'); background: url('@/assets/images/chat/chatbubbles/bubble_38_extra.png');
} }
} }
} }

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { useBetween } from 'use-between'; import { useBetween } from 'use-between';
import { ChatEntryType, ChatHistoryCurrentDate, IChatEntry, IRoomHistoryEntry, MessengerHistoryCurrentDate } from '../../api'; import { ChatEntryType, ChatHistoryCurrentDate, IChatEntry, IRoomHistoryEntry, MessengerHistoryCurrentDate } from '../../api';
import { useMessageEvent, useNitroEvent } from '../events'; import { useMessageEvent, useNitroEvent } from '../events';
import { useLocalStorage } from '../useLocalStorage';
const CHAT_HISTORY_MAX = 1000; const CHAT_HISTORY_MAX = 1000;
const ROOM_HISTORY_MAX = 10; const ROOM_HISTORY_MAX = 10;
@ -13,10 +14,10 @@ let MESSENGER_HISTORY_COUNTER: number = 0;
const useChatHistoryState = () => const useChatHistoryState = () =>
{ {
const [ chatHistory, setChatHistory ] = useState<IChatEntry[]>([]); const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', []);
const [ roomHistory, setRoomHistory ] = useState<IRoomHistoryEntry[]>([]); const [ roomHistory, setRoomHistory ] = useLocalStorage<IRoomHistoryEntry[]>('roomHistory', []);
const [ messengerHistory, setMessengerHistory ] = useState<IChatEntry[]>([]); const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', []);
const [ needsRoomInsert, setNeedsRoomInsert ] = useState(false); const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false);
const addChatEntry = (entry: IChatEntry) => const addChatEntry = (entry: IChatEntry) =>
{ {

View File

@ -2,20 +2,21 @@ import { NitroLogger } from '@nitrots/nitro-renderer';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { GetLocalStorage, SetLocalStorage } from '../api'; import { GetLocalStorage, SetLocalStorage } from '../api';
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<SetStateAction<T>>] => const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<SetStateAction<T>>] =>
{ {
key = userId ? `${ key }.${ userId }` : key;
const [ storedValue, setStoredValue ] = useState<T>(() => const [ storedValue, setStoredValue ] = useState<T>(() =>
{ {
if(typeof window === 'undefined') return initialValue;
try try
{ {
const item = GetLocalStorage<T>(key); const item = typeof window !== 'undefined' ? GetLocalStorage<T>(key) as T : undefined;
return item ?? initialValue; return item ?? initialValue;
} }
catch (error) catch(error)
{ {
return initialValue; return initialValue;
} }
@ -32,13 +33,13 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore); if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore);
} }
catch (error) catch(error)
{ {
NitroLogger.error(error); NitroLogger.error(error);
} }
}; }
return [ storedValue, setValue ]; return [ storedValue, setValue ];
}; }
export const useLocalStorage = useLocalStorageState; export const useLocalStorage = useLocalStorageState;

View File

@ -3,7 +3,8 @@ import { App } from './App';
import './css/index.css'; import './css/index.css';
import './css/chat/chats.css'; import './css/chat/Chats.css';
import './css/chat/ChatHistoryView.css';
import './css/floorplan/FloorplanEditorView.css'; import './css/floorplan/FloorplanEditorView.css';
@ -19,7 +20,6 @@ import './css/notification/NotificationCenterView.css';
import './css/purse/PurseView.css'; import './css/purse/PurseView.css';
import './css/room/ChatHistoryView.css';
import './css/room/RoomWidgets.css'; import './css/room/RoomWidgets.css';
import './css/slider.css'; import './css/slider.css';

View File

@ -22,17 +22,7 @@ export const NitroInput = forwardRef<HTMLInputElement, PropsWithChildren<{
const { color = 'default', inputSize = 'default', rounded = true, disabled = false, type = 'text', autoComplete = 'off', className = null, ...rest } = props; const { color = 'default', inputSize = 'default', rounded = true, disabled = false, type = 'text', autoComplete = 'off', className = null, ...rest } = props;
return ( return (
<input <input ref={ ref } autoComplete={ autoComplete } className={ classNames( classes.base, classes.size[inputSize], rounded && classes.rounded, classes.color[color], disabled && classes.disabled, className ) }
ref={ ref }
autoComplete={ autoComplete }
className={ classNames(
classes.base,
classes.size[inputSize],
rounded && classes.rounded,
classes.color[color],
disabled && classes.disabled,
className
) }
disabled={ disabled } disabled={ disabled }
type={ type } type={ type }
{ ...rest } /> { ...rest } />