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:
parent
34fe75cd12
commit
a0bbbe15ca
3 changed files with 422 additions and 0 deletions
231
packages/chat-ui/src/hooks.ts
Normal file
231
packages/chat-ui/src/hooks.ts
Normal 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
|
||||
}
|
||||
116
packages/chat-ui/src/security.ts
Normal file
116
packages/chat-ui/src/security.ts
Normal 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)
|
||||
}
|
||||
75
packages/chat-ui/src/types.ts
Normal file
75
packages/chat-ui/src/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue