mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
fix: (ai gen) chat improvements
This commit is contained in:
@@ -245,9 +245,19 @@ export default function ChatPanel(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
|
||||
<div ref={scrollRef} className={`flex-1 p-4 ${props.isObsPanel ? 'scrollbar-hide' : ''} overflow-y-auto flex flex-col`}>
|
||||
<div className="space-y-4 flex-1">
|
||||
<div
|
||||
className={`${props.isObsPanel ? 'w-full text-white' : 'md:border-l border-border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}
|
||||
>
|
||||
{!props.isObsPanel && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h2 className="font-semibold text-sm text-foreground">Live Chat</h2>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-1 px-4 py-2 ${props.isObsPanel ? 'scrollbar-hide' : 'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'} overflow-y-auto overflow-x-hidden`}
|
||||
>
|
||||
<div className="space-y-1 min-h-full flex flex-col justify-end">
|
||||
{chatMessages.map((msg, i) => (
|
||||
<Message
|
||||
key={i}
|
||||
@@ -260,8 +270,8 @@ export default function ChatPanel(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
{!props.isObsPanel && (
|
||||
<div className="p-4 border-t relative">
|
||||
<div className="flex space-x-2">
|
||||
<div className="p-3 border-t border-border relative">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
@@ -281,11 +291,16 @@ export default function ChatPanel(props: Props) {
|
||||
onClick={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
placeholder="Type a message"
|
||||
className="flex-1 bg-transparent focus-visible:ring-offset-0 min-h-[40px] max-h-[120px] resize-none py-2"
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-background/50 border-border focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 min-h-[40px] max-h-[100px] resize-none py-2 text-sm"
|
||||
rows={1}
|
||||
/>
|
||||
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
|
||||
<Button
|
||||
size="icon"
|
||||
className="shrink-0 transition-colors"
|
||||
onClick={sendMessage}
|
||||
disabled={!message.trim()}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,27 @@ export function EmojiSearch({
|
||||
useEffect(() => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
|
||||
|
||||
if (match) {
|
||||
const term = match[0].substring(1);
|
||||
setSearchTerm(term);
|
||||
|
||||
|
||||
if (term.length > 0) {
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.filter((name) => name.toLowerCase().includes(term.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term
|
||||
}));
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term,
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
@@ -63,22 +65,22 @@ export function EmojiSearch({
|
||||
const handleEmojiSearchResponse = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
|
||||
if (data.type === 'emojiSearchResponse') {
|
||||
const serverResults = data.results || [];
|
||||
|
||||
|
||||
const localResults = Array.from(emojiMap.keys())
|
||||
.filter(name => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.filter((name) => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach(name => {
|
||||
|
||||
localResults.forEach((name) => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
@@ -95,18 +97,18 @@ export function EmojiSearch({
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!searchTerm || searchResults.length === 0) return;
|
||||
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % searchResults.length);
|
||||
setSelectedIndex((prev) => (prev + 1) % searchResults.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
setSelectedIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
@@ -127,10 +129,10 @@ export function EmojiSearch({
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
@@ -150,25 +152,25 @@ export function EmojiSearch({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 left-4 bg-mantle border rounded-md shadow-lg max-h-60 overflow-y-auto z-10 min-w-[200px] max-w-[300px]">
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 mx-0 bg-mantle border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto z-10">
|
||||
<div ref={resultsRef} className="py-1">
|
||||
{searchResults.map((emojiName, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiUrl = emojiMap.get(emojiName);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={emojiName}
|
||||
className={`px-3 py-1.5 flex items-center gap-2 cursor-pointer ${
|
||||
className={`px-3 py-2 flex items-center gap-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
|
||||
}`}
|
||||
onClick={() => onSelect(emojiName)}
|
||||
>
|
||||
{emojiUrl && (
|
||||
<Image src={emojiUrl} alt={emojiName} width={20} height={20} className="w-5 h-5" />
|
||||
<Image src={emojiUrl} alt={emojiName} width={24} height={24} className="w-6 h-6" />
|
||||
)}
|
||||
<span className="flex-grow text-sm">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
|
||||
<span className="flex-grow text-sm font-medium">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,28 +7,26 @@ import { Bot } from 'lucide-react';
|
||||
export function Message({ user, message, type, emojiMap }: MessageProps) {
|
||||
if (type === 'systemMsg') {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">{message}</span>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<span className="text-xs text-muted-foreground italic">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div lang="en" className="max-w-full break-all whitespace-pre-wrap hyphens-auto">
|
||||
<p className="flex flex-wrap items-center">
|
||||
<span className="font-bold mr-2 flex items-center">
|
||||
{user?.isBot && (
|
||||
<span className="text-xs text-muted-foreground flex mr-1">
|
||||
{' '}
|
||||
<Bot className="size-5" />
|
||||
</span>
|
||||
)}
|
||||
{user?.displayName || user?.username}
|
||||
</span>
|
||||
|
||||
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
|
||||
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
|
||||
<span>{user?.displayName || user?.username}</span>
|
||||
</span>
|
||||
<span
|
||||
lang="en"
|
||||
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
|
||||
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
|
||||
>
|
||||
<EmojiRenderer text={message} emojiMap={emojiMap} />
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -50,12 +48,8 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
return (
|
||||
<TooltipProvider key={index}>
|
||||
<Tooltip delayDuration={250}>
|
||||
<TooltipTrigger>
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block align-middle"
|
||||
style={{ height: '1.2em' }}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center align-middle mx-0.5">
|
||||
<Image
|
||||
src={emojiUrl}
|
||||
alt={part}
|
||||
@@ -72,7 +66,11 @@ export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>;
|
||||
// Preserve text as-is, handling whitespace properly
|
||||
if (part) {
|
||||
return <span key={index}>{part}</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function LiveStream(props: Props) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentStream = channels.find(s => s.username === props.username);
|
||||
const currentStream = channels.find((s) => s.username === props.username);
|
||||
if (currentStream?.channel?.isRestricted) {
|
||||
setIsRestricted(true);
|
||||
setRestrictionExpiresAt(currentStream.channel.restrictionExpiresAt || null);
|
||||
@@ -50,32 +50,28 @@ export default function LiveStream(props: Props) {
|
||||
Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Checking...' : 'Check again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<StreamPlayer />
|
||||
{isMobile && (
|
||||
<div className="h-[300px]">
|
||||
<div className="flex-1 min-h-[250px] max-h-[400px] border-t border-border">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
<UserInfoCard streamInfo={props.streamInfo} />
|
||||
</div>
|
||||
|
||||
|
||||
{!isMobile && (
|
||||
<div>
|
||||
<div className="h-full shrink-0">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
)}
|
||||
@@ -86,4 +82,4 @@ export default function LiveStream(props: Props) {
|
||||
interface Props {
|
||||
username: string;
|
||||
streamInfo: StreamInfo & { channel: Channel };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user