mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: chat step 3, emoji search
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"@hctv/hono-ws": "*",
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@hono/node-ws": "^1.1.0",
|
||||
"@leeoniya/ufuzzy": "^1.0.18",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"hono": "^4.7.5"
|
||||
},
|
||||
|
||||
@@ -6,10 +6,13 @@ import { lucia } from '@hctv/auth';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { getPersonalChannel } from './utils/personalChannel.js';
|
||||
import { getRedisConnection, prisma } from '@hctv/db';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const MESSAGE_HISTORY_SIZE = 15;
|
||||
const MESSAGE_TTL = 60 * 60 * 24;
|
||||
const threed = await readFile('./src/3d.txt', 'utf-8');
|
||||
const uf = new uFuzzy();
|
||||
|
||||
const app = new Hono();
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
||||
@@ -103,7 +106,6 @@ app.get(
|
||||
});
|
||||
},
|
||||
async onMessage(evt, ws) {
|
||||
const redis = getRedisConnection();
|
||||
const msg = JSON.parse(evt.data.toString());
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(
|
||||
@@ -141,7 +143,6 @@ app.get(
|
||||
ws.wss.clients.forEach((c) => {
|
||||
const client = c as ModifiedWebSocket;
|
||||
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
|
||||
console.log('Sending message to client:', msgStr);
|
||||
c.send(msgStr);
|
||||
}
|
||||
});
|
||||
@@ -172,6 +173,46 @@ app.get(
|
||||
})
|
||||
);
|
||||
}
|
||||
if (msg.type === 'emojiSearch') {
|
||||
console.log('emoji search request:', msg);
|
||||
const searchTerm = msg.searchTerm as string;
|
||||
|
||||
const emojis = await redis.hgetall('emojis');
|
||||
const emojiKeys = Object.keys(emojis);
|
||||
const idxs = uf.filter(emojiKeys, searchTerm);
|
||||
console.log(`Emoji search for "${searchTerm}" found ${idxs?.length || 0} results.`);
|
||||
|
||||
if (idxs && idxs.length > 0) {
|
||||
const results: string[] = [];
|
||||
|
||||
if (idxs.length <= 150) {
|
||||
const info = uf.info(idxs, emojiKeys, searchTerm);
|
||||
const order = uf.sort(info, emojiKeys, searchTerm);
|
||||
for (let i = 0; i < order.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[order[i]]]);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < idxs.length && i < 10; i++) {
|
||||
results.push(emojiKeys[idxs[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: results,
|
||||
})
|
||||
);
|
||||
console.log(`Sending emoji search results: ${results.join(', ')}`);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'emojiSearchResponse',
|
||||
results: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Message } from './message';
|
||||
import { useMap } from '@uidotdev/usehooks';
|
||||
import { EmojiSearch } from './EmojiSearch';
|
||||
|
||||
export default function ChatPanel() {
|
||||
const { username } = useParams();
|
||||
@@ -16,6 +17,8 @@ export default function ChatPanel() {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const emojiMap = useMap() as Map<string, string>;
|
||||
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Initializing WebSocket connection for user:', username);
|
||||
@@ -213,6 +216,39 @@ export default function ChatPanel() {
|
||||
}
|
||||
}, [emojisToReq, emojiMap, username]);
|
||||
|
||||
const handleEmojiSelect = (emojiName: string) => {
|
||||
if (!textareaRef.current) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const afterCursor = message.substring(cursorPosition);
|
||||
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
if (!match) return;
|
||||
|
||||
const startPos = beforeCursor.lastIndexOf(match[0]);
|
||||
const newBeforeCursor = beforeCursor.substring(0, startPos);
|
||||
|
||||
const newMessage = `${newBeforeCursor}:${emojiName}: ${afterCursor}`;
|
||||
setMessage(newMessage);
|
||||
|
||||
// 3 for colons and space
|
||||
const newCursorPos = newBeforeCursor.length + emojiName.length + 3;
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
setCursorPosition(newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isEmojiSearchOpen = () => {
|
||||
const beforeCursor = message.substring(0, cursorPosition);
|
||||
const match = beforeCursor.match(/:[\w\-+]*$/);
|
||||
return match !== null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:border flex flex-col w-[350px] max-w-[350px] h-full bg-mantle">
|
||||
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
|
||||
@@ -228,17 +264,27 @@ export default function ChatPanel() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t">
|
||||
<div className="p-4 border-t relative">
|
||||
<div className="flex space-x-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart || 0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isEmojiSearchOpen()) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart || 0);
|
||||
}}
|
||||
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"
|
||||
rows={1}
|
||||
@@ -247,6 +293,14 @@ export default function ChatPanel() {
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<EmojiSearch
|
||||
message={message}
|
||||
cursorPosition={cursorPosition}
|
||||
onSelect={handleEmojiSelect}
|
||||
socket={socketRef.current}
|
||||
emojiMap={emojiMap}
|
||||
textareaRef={textareaRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
178
apps/web/src/components/app/ChatPanel/EmojiSearch.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
// source: ai
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EmojiSearchProps {
|
||||
message: string;
|
||||
cursorPosition: number;
|
||||
onSelect: (emoji: string) => void;
|
||||
socket: WebSocket | null;
|
||||
emojiMap: Map<string, string>;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function EmojiSearch({
|
||||
message,
|
||||
cursorPosition,
|
||||
onSelect,
|
||||
socket,
|
||||
emojiMap,
|
||||
textareaRef,
|
||||
}: EmojiSearchProps) {
|
||||
const [searchTerm, setSearchTerm] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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()))
|
||||
.slice(0, 5);
|
||||
|
||||
if (localResults.length > 0) {
|
||||
setSearchResults(localResults);
|
||||
}
|
||||
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'emojiSearch',
|
||||
searchTerm: term
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} else {
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [message, cursorPosition, socket, emojiMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
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()))
|
||||
.slice(0, 5);
|
||||
|
||||
const combinedResults = [...serverResults];
|
||||
|
||||
localResults.forEach(name => {
|
||||
if (!combinedResults.includes(name)) {
|
||||
combinedResults.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(combinedResults.slice(0, 10));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error processing emoji search response:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('message', handleEmojiSearchResponse);
|
||||
return () => {
|
||||
socket.removeEventListener('message', handleEmojiSearchResponse);
|
||||
};
|
||||
}, [socket, searchTerm, emojiMap]);
|
||||
|
||||
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);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
break;
|
||||
case 'Enter':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
if (searchResults[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
onSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setSearchTerm(null);
|
||||
setSearchResults([]);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
textarea.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [searchTerm, searchResults, selectedIndex, onSelect, textareaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resultsRef.current) {
|
||||
const selectedElement = resultsRef.current.children[selectedIndex] as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!searchTerm || searchResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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 ${
|
||||
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" />
|
||||
)}
|
||||
<span className="flex-grow text-sm">{emojiName}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -733,6 +733,11 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@leeoniya/ufuzzy@^1.0.18":
|
||||
version "1.0.18"
|
||||
resolved "https://registry.yarnpkg.com/@leeoniya/ufuzzy/-/ufuzzy-1.0.18.tgz#98e38c1308208bd47524aa2c53da0fe33dbbdab8"
|
||||
integrity sha512-5D54A86/VaPvJVf7UWJgy+UyhDtstUxq0iQd8UOZ2TG3NjV2oSoa9m4qW3VsotDD6dH2SNHDQwSPq+IAuudnag==
|
||||
|
||||
"@livekit/components-core@0.12.1":
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.1.tgz#6663ab60b8b55b1bde9588821a8b609987536e66"
|
||||
|
||||
Reference in New Issue
Block a user