feat: chat step 3, emoji search

This commit is contained in:
2025-07-08 11:45:45 +02:00
parent f4e809ae83
commit ed5f29824c
5 changed files with 284 additions and 5 deletions

View File

@@ -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"
},

View File

@@ -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: [],
})
);
}
}
},
}))
);

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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"