/** * Pure chat-domain helpers: transcript maths (merge / dedupe / grouping), * stamps, attachment classification and the limits the backend enforces. * * Every limit mirrors `gitdataai/lib/config/src/chat.rs` (defaults) — the server * is still the authority, these only stop the composer from sending a post that * is guaranteed to be rejected. */ import { getLocale, t } from '@app/i18n' import type { Attachment, ChatMessage, ReactionAgg } from './chat' import { isSystemMessage } from './chat' /** `chat.max_message_len` — counted in code points, like Rust's `chars().count()`. */ export const MAX_MESSAGE_LEN = 4000 /** `chat.max_attachments_per_message`. */ export const MAX_ATTACHMENTS = 10 /** `chat.upload.max_size` (100 MiB). */ export const MAX_UPLOAD_BYTES = 104_857_600 /** `chat.max_mentions_per_message`. */ export const MAX_MENTIONS = 20 /** `chat.ws.history_limit`: the transcript is capped so a huge room stays cheap. */ export const TRANSCRIPT_CAP = 600 /** Messages fetched per HTTP page (also the "load older" step). */ export const MESSAGE_PAGE_SIZE = 50 /** Emojis offered by the hover toolbar before the full picker opens. */ export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮', '👀', '🚀', '✅'] /** Reaction suggestions must stay below `chat.max_reaction_emoji_len`. */ export const MAX_EMOJI_LEN = 64 /** Tokens the backend resolves to an @everyone ping. */ export const EVERYONE_TOKENS = ['@everyone', '@here', '@channel'] /** Code-point length, the same unit the backend validates against. */ export function textLength(value: string): number { return [...value].length } /** `crypto.randomUUID` with a fallback for non-secure contexts (http://LAN IP). */ export function clientNonce(): string { const random = globalThis.crypto if (random && typeof random.randomUUID === 'function') return random.randomUUID() const bytes = new Uint8Array(16) if (random?.getRandomValues) random.getRandomValues(bytes) else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256) bytes[6] = (bytes[6] ?? 0) & 0x0f bytes[8] = (bytes[8] ?? 0) & 0x3f const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')) return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` } /** * Parse a backend timestamp in *either* shape this API produces: * - `2026-09-05T10:00:00.000000+00:00` (serde RFC 3339, chat/WS payloads); * - `2026-09-03 10:55:00.863466 +00:00:00` (`time::OffsetDateTime`'s Display, * which some rows still carry: a space instead of `T` and seconds inside the * offset — both rejected by `Date`). * Returns `NaN` for anything unparseable, so a caller shows the raw string * instead of a confident-looking 1970. */ export function parseStamp(value: string | null | undefined): number { if (!value) return Number.NaN const iso = value .trim() .replace(/^(\d{4}-\d{2}-\d{2})[ T]/, '$1T') .replace(/\s+(?=[+-]\d{2})/, '') .replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2') const stamp = Date.parse(iso) return Number.isFinite(stamp) ? stamp : Number.NaN } const pad = (value: number) => String(value).padStart(2, '0') /** `HH:MM` in the viewer's zone; the raw value when it cannot be parsed. */ export function clockStamp(value: string): string { const stamp = parseStamp(value) if (!Number.isFinite(stamp)) return value const date = new Date(stamp) return `${pad(date.getHours())}:${pad(date.getMinutes())}` } /** Local calendar key, so grouping never splits at a UTC midnight. */ export function dayKey(value: string): string { const stamp = parseStamp(value) if (!Number.isFinite(stamp)) return value const date = new Date(stamp) return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } /** Divider label: today / yesterday / weekday / full date for anything older. */ export function dayLabel(value: string): string { const stamp = parseStamp(value) if (!Number.isFinite(stamp)) return value const date = new Date(stamp) const now = new Date() const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() const startOfThat = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() const days = Math.round((startOfToday - startOfThat) / 86_400_000) if (days === 0) return t('chat.day.today') if (days === 1) return t('chat.day.yesterday') // Within the last week the weekday alone is unambiguous; `weekday: 'short'` // yields 周一 in Chinese and Mon in English without a hand-written table. if (days > 1 && days < 7) { return new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date) } return new Intl.DateTimeFormat(getLocale(), { year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'long', day: 'numeric', }).format(date) } /** Full stamp for tooltips and system rows. */ export function fullStamp(value: string): string { const stamp = parseStamp(value) if (!Number.isFinite(stamp)) return value const date = new Date(stamp) return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } /** * `true` when a message may join the one above it: same author, same thread, * plain post, and within ten minutes. */ export function isGrouped(previous: ChatMessage | undefined, message: ChatMessage): boolean { if (!previous) return false if (previous.author_id !== message.author_id) return false if (isSystemMessage(previous) || isSystemMessage(message)) return false if ((previous.thread_id ?? null) !== (message.thread_id ?? null)) return false const gap = parseStamp(message.created_at) - parseStamp(previous.created_at) return Number.isFinite(gap) && gap >= 0 && gap < 10 * 60_000 } /** * Embeds are replaced wholesale by the server's async unfurl pass, so rows whose * cards changed must not compare equal: a cached row would otherwise keep * rendering the stale card (or a broken iframe) after a reload. */ function embedsKey(embeds: ChatMessage['embeds']): string { if (!embeds || embeds.length === 0) return '' return embeds .map((embed) => [embed.kind, embed.src, embed.url, embed.title, embed.image_url, embed.description].join('\u0001')) .join('\u0002') } /** * Field-level equality for the parts a row renders. The socket re-delivers rows * (a `history` snapshot repeats what the channel already delivered), and treating * an identical copy as a change would re-render the whole transcript on every * reconnect. */ export function sameRow(a: ChatMessage, b: ChatMessage): boolean { if (a === b) return true if (a.content !== b.content) return false if (embedsKey(a.embeds) !== embedsKey(b.embeds)) return false if (a.edited_at !== b.edited_at || a.deleted_at !== b.deleted_at) return false if (a.pinned !== b.pinned || a.pinned_at !== b.pinned_at) return false if ((a.thread_id ?? null) !== (b.thread_id ?? null)) return false if (a.has_thread !== b.has_thread) return false if (a.attachments.length !== b.attachments.length) return false if (authorKey(a) !== authorKey(b)) return false const left = a.reactions ?? [] const right = b.reactions ?? [] if (left.length !== right.length) return false return left.every((reaction, index) => { const other = right[index] return ( other !== undefined && reaction.emoji === other.emoji && reaction.count === other.count && reaction.me === other.me ) }) } function authorKey(message: ChatMessage): string { const author = message.author return author ? `${author.user_id}|${author.username}|${author.display_name}|${author.avatar_url}` : `id:${message.author_id}` } /** Highest seq in a list, ignoring pending rows (which have no seq). */ export function tipSeq(messages: readonly { seq?: number }[]): number { let tip = 0 for (const message of messages) { const seq = typeof message.seq === 'number' && Number.isFinite(message.seq) ? message.seq : 0 if (seq > tip) tip = seq } return tip } /** * Merge a batch into an ascending transcript: dedupe by id (a `history` snapshot * repeats rows the room channel already delivered), keep the newest copy of an * edited row, and drop the oldest rows past {@link TRANSCRIPT_CAP}. * Returns the same array reference when nothing changed, so a React state update * can bail out. */ export function mergeMessages( current: readonly ChatMessage[], incoming: readonly ChatMessage[], ): { items: ChatMessage[]; changed: boolean; dropped: number } { if (incoming.length === 0) return { items: current as ChatMessage[], changed: false, dropped: 0 } const byId = new Map(current.map((message) => [message.id, message])) let changed = false for (const message of incoming) { const known = byId.get(message.id) if (!known) { byId.set(message.id, message) changed = true continue } // A WS row is built for the *actor*, so it can lose the viewer's `me` flags: // never let a fresher-looking payload erase what the local user reacted with. const next = keepViewerReactions(known, message) if (next !== known && !sameRow(known, next)) { byId.set(message.id, next) changed = true } } if (!changed) return { items: current as ChatMessage[], changed: false, dropped: 0 } const items = [...byId.values()].sort((a, b) => a.seq - b.seq) let dropped = 0 if (items.length > TRANSCRIPT_CAP) { dropped = items.length - TRANSCRIPT_CAP items.splice(0, dropped) } return { items, changed: true, dropped } } /** * Merge a reaction aggregate (`reaction_updated` carries no `me`), keeping the * local user's own reaction flags. */ export function mergeReactions( previous: readonly ReactionAgg[] | undefined, next: readonly { emoji: string; count: number }[], ): ReactionAgg[] { const mine = new Map((previous ?? []).map((reaction) => [reaction.emoji, reaction.me])) return next.map((reaction) => ({ emoji: reaction.emoji, count: reaction.count, me: mine.get(reaction.emoji) ?? false, })) } /** Re-apply the local viewer flags to a row that arrived from the socket. */ function keepViewerReactions(known: ChatMessage, incoming: ChatMessage): ChatMessage { if (incoming.author_id !== known.author_id) return incoming const knownReactions = known.reactions ?? [] if (knownReactions.some((reaction) => reaction.me) === false) return incoming const mine = new Set(knownReactions.filter((reaction) => reaction.me).map((reaction) => reaction.emoji)) return { ...incoming, reactions: (incoming.reactions ?? []).map((reaction) => mine.has(reaction.emoji) ? { ...reaction, me: true } : reaction, ), } } /* ------------------------------------------------------------- attachments */ export type AttachmentKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'file' export function attachmentKind(attachment: Pick): AttachmentKind { const type = (attachment.content_type ?? '').toLowerCase() if (type.startsWith('image/')) return 'image' if (type.startsWith('video/')) return 'video' if (type.startsWith('audio/')) return 'audio' if (type === 'application/pdf') return 'pdf' if (type.startsWith('text/') || type === 'application/json' || type.endsWith('+json')) return 'text' // Some uploaders send application/octet-stream; fall back to the extension. const name = attachment.filename.toLowerCase() if (/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/.test(name)) return 'image' if (/\.(mp4|webm|mov|m4v)$/.test(name)) return 'video' if (/\.(mp3|wav|ogg|m4a|flac)$/.test(name)) return 'audio' if (name.endsWith('.pdf')) return 'pdf' if (/\.(txt|md|markdown|json|ya?ml|toml|csv|log|rs|ts|tsx|js|jsx|py|go|java|c|cc|h|css|html|sh)$/.test(name)) return 'text' return 'file' } /** * Same-origin stream endpoint (session cookie, no presigned expiry). * * `variant` selects which representation the server streams: `preview` is the * compressed derivative the transcript grid loads, `original` (the default) is * the uploaded bytes — which is what the lightbox and the download link need. * A missing preview degrades to the original server-side, so this is always a * valid URL. */ export function attachmentUrl(attachmentId: string, variant: 'preview' | 'original' = 'original'): string { const base = `/api/v1/chat/attachments/${encodeURIComponent(attachmentId)}/file` return variant === 'preview' ? `${base}?variant=preview` : base } /** Custom space emoji image, same-origin for the same reason. */ export function emojiImageUrl(spaceName: string, emojiId: string): string { return `/api/v1/chat/spaces/${encodeURIComponent(spaceName)}/emojis/${encodeURIComponent(emojiId)}/image` } export { formatBytes as formatSize } from '@app/i18n' /** First glyph of a person's name, for the coloured avatar tile. */ export function initialsOf(user?: { display_name?: string; username?: string } | null): string { const name = user?.display_name || user?.username || '?' return [...name][0]?.toUpperCase() ?? '?' } /* ---------------------------------------------------------------- failures */ /** * Why a message could not be sent, in one line the composer can show verbatim. * The backend already answers with a human-readable `error`/`message`, so this * only fills in the transport-level cases and translates the two status codes * whose raw text would be meaningless in a chat window. */ export function describeFailure(error: unknown): string { const code = failureCode(error) if (code === 401) return t('chat.composer.unauthorized') if (code === 403) return t('chat.composer.forbidden') if (code === 404) return t('chat.composer.notFound') if (code === 429) return t('chat.composer.rateLimited') if (code === 0 || code === 502 || code === 503 || code === 504) { return t('chat.composer.unavailable') } if (error instanceof Error && error.message) return error.message return t('chat.composer.failed') } /** HTTP status mirrored by the envelope, or undefined when it is not one. */ export function failureCode(error: unknown): number | undefined { const candidate = error as { code?: unknown } | null | undefined return typeof candidate?.code === 'number' ? candidate.code : undefined } /** * Seconds the backend asked us to wait. Two producers: the room's slow mode * ("slow mode active, retry in 12s") and the generic quota limiter. Reading the * number beats showing a countdown of zero while the server refuses every post. */ export function findRetryAfterSeconds(error: unknown): number { const message = error instanceof Error ? error.message : '' const match = /retry in (\d+)s/i.exec(message) const seconds = match ? Number.parseInt(match[1], 10) : 0 return Number.isFinite(seconds) && seconds > 0 && seconds < 3600 ? seconds : 0 } /** * The plain reason a request failed, with the retry countdown taken out: the * composer renders that number as a live timer, so repeating it inside the * sentence would read as two different clocks. */ export function failureReason(error: unknown): string { const message = error instanceof Error ? error.message : '' const cleaned = message .replace(/\s*\(retry in \d+s\)/i, '') .replace(/,?\s*retry in \d+s/i, '') .trim() return cleaned || describeFailure(error) }