feat: add Vite configuration with custom plugins and server settings

This commit is contained in:
2026-02-21 16:16:15 +11:00
parent 576e6d77c1
commit 6c741577ce
32 changed files with 1937 additions and 20 deletions

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

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