feat(chat-ui): add types, hooks, and security utilities from chatcn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-12 13:46:35 +02:00
parent 34fe75cd12
commit a0bbbe15ca
No known key found for this signature in database
3 changed files with 422 additions and 0 deletions

View file

@ -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<HTMLDivElement>(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<HTMLTextAreaElement>(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<ReturnType<typeof setTimeout> | 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
}

View file

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

View file

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