mirror of
https://github.com/duckietm/Nitro-Cool-UI.git
synced 2025-06-21 22:36:58 +00:00
🆙 Fix the Chat History with autoscroll at bottom
This commit is contained in:
parent
de0f723c64
commit
9d6744ae23
BIN
src/assets/images/chat/chathistory_background.png
Normal file
BIN
src/assets/images/chat/chathistory_background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 682 KiB |
@ -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;
|
|
||||||
|
|
||||||
let text = searchText.toLowerCase();
|
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 chatHistory.filter(entry => ((entry.message && entry.message.toLowerCase().includes(text))) || (entry.name && entry.name.toLowerCase().includes(text)));
|
return [...result];
|
||||||
}, [chatHistory, searchText]);
|
}, [chatHistory, searchText]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() => {
|
||||||
{
|
if (!elementRef.current || !isVisible) return;
|
||||||
if(elementRef && elementRef.current && isVisible) elementRef.current.scrollTop = elementRef.current.scrollHeight;
|
|
||||||
}, [ isVisible ]);
|
|
||||||
|
|
||||||
useEffect(() =>
|
const element = elementRef.current;
|
||||||
{
|
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||||
|
const isAtBottom = maxScrollTop === 0 || Math.abs(element.scrollTop - maxScrollTop) <= 50;
|
||||||
|
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
|
||||||
|
isFirstRender.current = false;
|
||||||
|
} else if (filteredChatHistory.length > prevChatLength.current) {
|
||||||
|
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -59,38 +74,40 @@ export const ChatHistoryView: FC<{}> = props =>
|
|||||||
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' } }>
|
<div className="bubble-container" style={{position: 'relative', display: 'inline-flex', alignItems: 'center'}}>
|
||||||
{ (row.style === 0) &&
|
<div
|
||||||
<div className="user-container-bg" style={ { backgroundColor: row.color } } /> }
|
className={`chat-bubble bubble-${row.style} type-${row.chatType}`}
|
||||||
<div className={ `chat-bubble bubble-${ row.style } type-${ row.chatType }` } style={ { maxWidth: '100%' } }>
|
style={{ maxWidth: '100%', backgroundColor: row.style === 0 ? row.color : 'transparent', position: 'relative', zIndex: 1 }}>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
31
src/css/chat/ChatHistoryView.css
Normal file
31
src/css/chat/ChatHistoryView.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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) =>
|
||||||
{
|
{
|
||||||
|
@ -2,16 +2,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,9 +37,9 @@ const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<Se
|
|||||||
{
|
{
|
||||||
NitroLogger.error(error);
|
NitroLogger.error(error);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
return [ storedValue, setValue ];
|
return [ storedValue, setValue ];
|
||||||
};
|
}
|
||||||
|
|
||||||
export const useLocalStorage = useLocalStorageState;
|
export const useLocalStorage = useLocalStorageState;
|
||||||
|
@ -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';
|
||||||
|
@ -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 } />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user