main
/**
* 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<Attachment, 'content_type' | 'filename'>): 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)
}