feat: add Vite configuration with custom plugins and server settings
This commit is contained in:
31
src/app/utils/avatarCache.ts
Normal file
31
src/app/utils/avatarCache.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Simple in-memory cache for avatar URLs that have been successfully loaded
|
||||
// This prevents the flicker when avatars re-render
|
||||
|
||||
const loadedAvatars = new Set<string>();
|
||||
|
||||
export function isAvatarCached(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
return loadedAvatars.has(url);
|
||||
}
|
||||
|
||||
export function cacheAvatar(url: string | undefined): void {
|
||||
if (url) {
|
||||
loadedAvatars.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: limit cache size to prevent memory issues
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
export function pruneAvatarCache(): void {
|
||||
if (loadedAvatars.size > MAX_CACHE_SIZE) {
|
||||
const iterator = loadedAvatars.values();
|
||||
// Remove oldest entries (first added)
|
||||
for (let i = 0; i < loadedAvatars.size - MAX_CACHE_SIZE; i++) {
|
||||
const result = iterator.next();
|
||||
if (!result.done) {
|
||||
loadedAvatars.delete(result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/app/utils/copyHandler.ts
Normal file
102
src/app/utils/copyHandler.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { timeHourMinute } from './time';
|
||||
|
||||
/**
|
||||
* Processes selected text from the chat timeline and formats it as:
|
||||
* "Username - Time: Message"
|
||||
*
|
||||
* This intercepts the copy event on the timeline and reformats the copied text.
|
||||
*/
|
||||
export const setupCopyHandler = (
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
getMessageData: (eventId: string) => { sender: string; ts: number; content: string } | null,
|
||||
hour24Clock: boolean
|
||||
): (() => void) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return () => {};
|
||||
|
||||
const handleCopy = (event: ClipboardEvent) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedContainer = range.commonAncestorContainer;
|
||||
|
||||
// Only process if selection is within our container
|
||||
if (!container.contains(selectedContainer)) return;
|
||||
|
||||
// Find all message elements within the selection
|
||||
const messages: Array<{ sender: string; time: string; content: string }> = [];
|
||||
|
||||
// Get all message elements in the container
|
||||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||||
|
||||
messageElements.forEach((msgEl) => {
|
||||
// Check if this message element intersects with the selection
|
||||
if (!selection.containsNode(msgEl, true)) return;
|
||||
|
||||
const eventId = msgEl.getAttribute('data-message-id');
|
||||
if (!eventId) return;
|
||||
|
||||
const data = getMessageData(eventId);
|
||||
if (!data) return;
|
||||
|
||||
// Get the selected text content within this message
|
||||
const tempRange = document.createRange();
|
||||
tempRange.selectNodeContents(msgEl);
|
||||
|
||||
// Check if the selection overlaps with this message
|
||||
const intersects = range.compareBoundaryPoints(Range.END_TO_START, tempRange) <= 0 &&
|
||||
range.compareBoundaryPoints(Range.START_TO_END, tempRange) >= 0;
|
||||
|
||||
if (!intersects) return;
|
||||
|
||||
// Format the time
|
||||
const timeStr = timeHourMinute(data.ts, hour24Clock);
|
||||
|
||||
// Get the text content that was selected from this message
|
||||
// Use the original content if the message is fully selected, otherwise get selected portion
|
||||
let selectedText = '';
|
||||
|
||||
// Find the message content element (the actual text, not header)
|
||||
const contentElement = msgEl.querySelector('[class*="MessageTextBody"], [class*="message-content"]');
|
||||
if (contentElement) {
|
||||
const clonedRange = range.cloneRange();
|
||||
clonedRange.selectNodeContents(contentElement);
|
||||
|
||||
// Intersect with selection
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, clonedRange) > 0) {
|
||||
clonedRange.setStart(range.startContainer, range.startOffset);
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, clonedRange) < 0) {
|
||||
clonedRange.setEnd(range.endContainer, range.endOffset);
|
||||
}
|
||||
|
||||
selectedText = clonedRange.toString().trim();
|
||||
} else {
|
||||
// Fallback: use all selected text in this message
|
||||
selectedText = range.toString().trim();
|
||||
}
|
||||
|
||||
if (selectedText) {
|
||||
messages.push({
|
||||
sender: data.sender,
|
||||
time: timeStr,
|
||||
content: selectedText,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If we found formatted messages, use our format
|
||||
if (messages.length > 0) {
|
||||
const formattedText = messages
|
||||
.map((msg) => `${msg.sender} - ${msg.time}: ${msg.content}`)
|
||||
.join('\n');
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData?.setData('text/plain', formattedText);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('copy', handleCopy);
|
||||
return () => container.removeEventListener('copy', handleCopy);
|
||||
};
|
||||
Reference in New Issue
Block a user