From a0bbbe15cae446bcb4a161a4bf43b6b5b6dc8feb Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 12 Apr 2026 13:46:35 +0200 Subject: [PATCH] feat(chat-ui): add types, hooks, and security utilities from chatcn Co-Authored-By: Claude Sonnet 4.6 --- packages/chat-ui/src/hooks.ts | 231 +++++++++++++++++++++++++++++++ packages/chat-ui/src/security.ts | 116 ++++++++++++++++ packages/chat-ui/src/types.ts | 75 ++++++++++ 3 files changed, 422 insertions(+) create mode 100644 packages/chat-ui/src/hooks.ts create mode 100644 packages/chat-ui/src/security.ts create mode 100644 packages/chat-ui/src/types.ts diff --git a/packages/chat-ui/src/hooks.ts b/packages/chat-ui/src/hooks.ts new file mode 100644 index 0000000..467abc4 --- /dev/null +++ b/packages/chat-ui/src/hooks.ts @@ -0,0 +1,231 @@ +import { + useRef, + useEffect, + useCallback, + useState, +} from "react" +import { + isToday, + isYesterday, + format, + isSameDay, + differenceInSeconds, +} from "date-fns" +import type { ChatMessageData, MessageListItem, MessageGroup } from "./types" + +// ─── Date formatting ────────────────────────────────────────────────────────── + +export function formatDateLabel(date: Date): string { + if (isToday(date)) return "Today" + if (isYesterday(date)) return "Yesterday" + const now = new Date() + const diffDays = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24) + ) + if (diffDays < 7) return format(date, "EEEE") // "Tuesday" + if (date.getFullYear() === now.getFullYear()) + return format(date, "MMMM d") // "March 18" + return format(date, "MMMM d, yyyy") // "March 18, 2026" +} + +export function formatTimestamp(date: Date): string { + return format(date, "h:mm a") // "10:42 AM" +} + +// ─── Message grouping ───────────────────────────────────────────────────────── + +export function groupMessages( + messages: ChatMessageData[], + currentUserId: string, + intervalSeconds: number = 120 +): MessageListItem[] { + if (messages.length === 0) return [] + + const items: MessageListItem[] = [] + let currentGroup: MessageGroup | null = null + let lastDate: Date | null = null + + for (const msg of messages) { + const msgDate = new Date(msg.timestamp) + + // System messages break groups + if (msg.isSystem) { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + currentGroup = null + } + + // Insert date separator if needed + if (!lastDate || !isSameDay(lastDate, msgDate)) { + items.push({ type: "date", date: msgDate, label: formatDateLabel(msgDate) }) + lastDate = msgDate + } + + items.push({ type: "system", message: msg }) + continue + } + + // Insert date separator if needed + if (!lastDate || !isSameDay(lastDate, msgDate)) { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + currentGroup = null + } + items.push({ type: "date", date: msgDate, label: formatDateLabel(msgDate) }) + lastDate = msgDate + } + + // Check if message should continue the current group + const shouldGroup = + currentGroup && + currentGroup.senderId === msg.senderId && + currentGroup.messages.length > 0 && + differenceInSeconds( + msgDate, + new Date( + currentGroup.messages[currentGroup.messages.length - 1]!.timestamp + ) + ) <= intervalSeconds + + if (shouldGroup && currentGroup) { + currentGroup.messages.push(msg) + } else { + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + } + currentGroup = { + senderId: msg.senderId, + senderName: msg.senderName, + senderAvatar: msg.senderAvatar, + messages: [msg], + isOutgoing: msg.senderId === currentUserId, + } + } + } + + if (currentGroup) { + items.push({ type: "group", group: currentGroup }) + } + + return items +} + +// ─── Auto-scroll hook ───────────────────────────────────────────────────────── + +export function useAutoScroll( + messages: ChatMessageData[], + opts?: { threshold?: number } +) { + const containerRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const [unseenCount, setUnseenCount] = useState(0) + const prevLengthRef = useRef(messages.length) + const threshold = opts?.threshold ?? 100 + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = "smooth") => { + const el = containerRef.current + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior }) + setUnseenCount(0) + }, + [] + ) + + // Track scroll position + useEffect(() => { + const el = containerRef.current + if (!el) return + + const handleScroll = () => { + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight + const atBottom = distanceFromBottom <= threshold + setIsAtBottom(atBottom) + if (atBottom) setUnseenCount(0) + } + + el.addEventListener("scroll", handleScroll, { passive: true }) + return () => el.removeEventListener("scroll", handleScroll) + }, [threshold]) + + // Auto-scroll when new messages arrive and user is at bottom + useEffect(() => { + const newCount = messages.length - prevLengthRef.current + prevLengthRef.current = messages.length + + if (newCount <= 0) return + + if (isAtBottom) { + scrollToBottom("smooth") + } else { + setUnseenCount((c) => c + newCount) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + // Scroll to bottom on mount + useEffect(() => { + scrollToBottom("instant") + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { containerRef, scrollToBottom, isAtBottom, unseenCount } as const +} + +// ─── Auto-resize textarea hook ──────────────────────────────────────────────── + +export function useAutoResize(opts?: { maxRows?: number }) { + const textareaRef = useRef(null) + const maxRows = opts?.maxRows ?? 6 + + const resize = useCallback(() => { + const el = textareaRef.current + if (!el) return + el.style.height = "auto" + const lineHeight = parseInt(getComputedStyle(el).lineHeight) || 22 + const maxHeight = lineHeight * maxRows + el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px` + el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden" + }, [maxRows]) + + return { textareaRef, resize } as const +} + +// ─── Typing indicator hook ──────────────────────────────────────────────────── + +export function useTypingIndicator(opts?: { + onTypingChange?: (isTyping: boolean) => void + debounceMs?: number +}) { + const [isTyping, setIsTyping] = useState(false) + const timeoutRef = useRef | null>(null) + const debounceMs = opts?.debounceMs ?? 2000 + + const handleKeyDown = useCallback(() => { + if (!isTyping) { + setIsTyping(true) + opts?.onTypingChange?.(true) + } + + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setIsTyping(false) + opts?.onTypingChange?.(false) + }, debounceMs) + }, [isTyping, debounceMs, opts]) + + const stopTyping = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + setIsTyping(false) + opts?.onTypingChange?.(false) + }, [opts]) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + return { isTyping, handleKeyDown, stopTyping } as const +} diff --git a/packages/chat-ui/src/security.ts b/packages/chat-ui/src/security.ts new file mode 100644 index 0000000..40bab72 --- /dev/null +++ b/packages/chat-ui/src/security.ts @@ -0,0 +1,116 @@ +/** + * chatcn — Security Utilities + * XSS prevention, URL sanitization, file validation, bidi stripping, emoji validation + */ + +// ─── URL Sanitization ───────────────────────────────────────────────────────── + +const ALLOWED_URL_PROTOCOL = /^(https?:\/\/|mailto:)/i + +/** Sanitize a URL — only allow http, https, and mailto. Blocks javascript:, data:, etc. */ +export function sanitizeUrl(url: string | undefined | null): string { + if (!url) return "#" + const trimmed = url.trim() + if (!ALLOWED_URL_PROTOCOL.test(trimmed)) return "#" + return trimmed +} + +/** Extract hostname from a URL for display (prevents misleading long URLs) */ +export function displayHostname(url: string): string { + try { + return new URL(url).hostname + } catch { + return url + } +} + +// ─── Text Sanitization ─────────────────────────────────────────────────────── + +/** Strip bidi override characters that can disguise malicious content (RLO attacks) */ +export function stripBidiOverrides(text: string): string { + // Remove LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI + return text.replace(/[\u202A-\u202E\u2066-\u2069]/g, "") +} + +/** Truncate message text to prevent rendering DoS */ +export function truncateMessage( + text: string, + maxLength: number = 10_000 +): { text: string; truncated: boolean } { + if (text.length <= maxLength) return { text, truncated: false } + return { text: text.slice(0, maxLength) + "\u2026", truncated: true } +} + +/** Sanitize sender name — strip bidi, truncate */ +export function sanitizeSenderName(name: string): string { + return stripBidiOverrides(name).slice(0, 100) +} + +// ─── File Validation ────────────────────────────────────────────────────────── + +const BLOCKED_EXTENSIONS = new Set([ + ".exe", ".bat", ".cmd", ".scr", ".pif", ".com", + ".js", ".jsx", ".ts", ".tsx", ".mjs", + ".html", ".htm", ".xhtml", + ".svg", + ".xml", ".xsl", ".xslt", + ".hta", ".vbs", ".vbe", ".wsf", ".wsh", + ".ps1", ".psm1", + ".sh", ".bash", +]) + +const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB + +export interface FileValidationResult { + valid: boolean + error?: string +} + +export function validateFile( + file: File, + opts?: { maxSize?: number } +): FileValidationResult { + const maxSize = opts?.maxSize ?? DEFAULT_MAX_FILE_SIZE + + // Check extension + const parts = file.name.split(".") + const ext = parts.length > 1 ? "." + parts[parts.length - 1]!.toLowerCase() : "" + if (BLOCKED_EXTENSIONS.has(ext)) { + return { valid: false, error: `File type ${ext} is not allowed` } + } + + // Check size + if (file.size > maxSize) { + const mbLimit = Math.round(maxSize / (1024 * 1024)) + return { valid: false, error: `File exceeds maximum size of ${mbLimit}MB` } + } + + return { valid: true } +} + +/** Sanitize a file name for display */ +export function sanitizeFileName(name: string): string { + let clean = name.replace(/[/\\]/g, "_") // Remove path traversal + clean = clean.replace(/\0/g, "") // Remove null bytes + clean = stripBidiOverrides(clean) // Remove bidi overrides + if (clean.length > 100) clean = clean.slice(0, 97) + "..." + return clean +} + +// ─── Emoji Validation ───────────────────────────────────────────────────────── + +const EMOJI_REGEX = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*$/u + +/** Validate that a string is a valid emoji (not arbitrary text) */ +export function isValidEmoji(str: string): boolean { + return EMOJI_REGEX.test(str) && str.length <= 20 +} + +// ─── Reaction Count Safety ──────────────────────────────────────────────────── + +/** Format reaction count for display (cap at 999+, floor at 0) */ +export function formatReactionCount(count: number): string { + if (count <= 0) return "0" + if (count > 999) return "999+" + return String(count) +} diff --git a/packages/chat-ui/src/types.ts b/packages/chat-ui/src/types.ts new file mode 100644 index 0000000..f933a99 --- /dev/null +++ b/packages/chat-ui/src/types.ts @@ -0,0 +1,75 @@ +export interface ChatUser { + id: string + name: string + avatar?: string + status?: "online" | "away" | "dnd" | "offline" +} + +export interface ChatMessageData { + id: string + senderId: string + senderName: string + senderAvatar?: string + + // Content + text?: string + images?: { url: string; width: number; height: number; alt?: string }[] + files?: { name: string; size: number; type: string; url: string }[] + voice?: { url: string; duration: number; waveform: number[] } + linkPreview?: { + url: string + title: string + description: string + image?: string + } + code?: { language: string; code: string } + + // Metadata + timestamp: Date | number + status?: "sending" | "sent" | "delivered" | "read" | "failed" + replyTo?: { id: string; senderName: string; text: string } + reactions?: { emoji: string; userIds: string[]; count: number }[] + isEdited?: boolean + isPinned?: boolean + isSystem?: boolean + systemEvent?: string + + // Read receipts (group chat) — list of users who have read up to this message + readBy?: { userId: string; name: string; avatar?: string }[] +} + +export interface ChatConfig { + currentUser: ChatUser + dateFormat?: "relative" | "absolute" | "time-only" + messageGroupingInterval?: number // seconds, default 120 + + // Callbacks + onReactionAdd?: (messageId: string, emoji: string) => void + onReactionRemove?: (messageId: string, emoji: string) => void + onReply?: (message: ChatMessageData) => void + onEdit?: (message: ChatMessageData) => void + onDelete?: (messageId: string) => void + onPin?: (messageId: string) => void +} + +/** A group of consecutive messages from the same sender within the grouping interval */ +export interface MessageGroup { + senderId: string + senderName: string + senderAvatar?: string + messages: ChatMessageData[] + isOutgoing: boolean +} + +/** Items that can appear in the message list */ +export type MessageListItem = + | { type: "group"; group: MessageGroup } + | { type: "date"; date: Date; label: string } + | { type: "system"; message: ChatMessageData } + +/** Typing state for one or more users */ +export interface TypingUser { + id: string + name: string + avatar?: string +}