diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 0e6a93f2..f36bb63a 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -14,6 +14,7 @@ import { renderDayDivider, renderMessage, } from "./message-list/renderers"; +import { FenwickTree } from "./message-list/fenwick"; // -- Options ------------------------------------------------------------------ @@ -36,8 +37,11 @@ const SCROLL_BOTTOM_THRESHOLD = 100; /** Number of items to render beyond visible viewport in each direction. */ const OVERSCAN = 20; -/** Estimated pixel height per row (message or day divider) for initial layout. */ -const ESTIMATED_ROW_HEIGHT = 52; +/** Regex for direct image URLs in message content. */ +const IMAGE_URL_RE = /\.(?:png|jpe?g|gif|webp)(?:\?[^\s]*)?(?:\s|$)/i; + +/** Regex for YouTube URLs in message content. */ +const YOUTUBE_URL_RE = /(?:youtube\.com\/watch|youtu\.be\/)/i; // -- Virtual item types ------------------------------------------------------- @@ -54,6 +58,33 @@ interface VirtualItemDivider { type VirtualItem = VirtualItemMessage | VirtualItemDivider; +// -- Smart height estimation -------------------------------------------------- + +function estimateItemHeight(item: VirtualItem): number { + if (item.kind === "divider") return 32; + + let height = item.isGrouped ? 42 : 72; + + // Image attachments + for (const att of item.message.attachments) { + if (att.mime.startsWith("image/")) { + height += 220; + } + } + + // Inline image URLs in content + if (IMAGE_URL_RE.test(item.message.content)) { + height += 220; + } + + // YouTube embeds + if (YOUTUBE_URL_RE.test(item.message.content)) { + height += 320; + } + + return height; +} + // -- Pre-process messages into virtual items ---------------------------------- function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] { @@ -89,7 +120,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo // Virtual scroll state let virtualItems: readonly VirtualItem[] = []; let allMessages: readonly Message[] = []; - const heightCache = new Map(); // itemKey → measured px + const heightCache = new Map(); // itemKey -> measured px + let tree: FenwickTree | null = null; let topSpacer: HTMLDivElement | null = null; let bottomSpacer: HTMLDivElement | null = null; let contentContainer: HTMLDivElement | null = null; @@ -97,7 +129,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo let renderedEnd = 0; // --------------------------------------------------------------------------- - // Height estimation + // Height estimation (Fenwick tree backed) // --------------------------------------------------------------------------- function itemKey(index: number): string { @@ -108,10 +140,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } function getItemHeight(index: number): number { - return heightCache.get(itemKey(index)) ?? ESTIMATED_ROW_HEIGHT; + const cached = heightCache.get(itemKey(index)); + if (cached !== undefined) return cached; + return estimateItemHeight(virtualItems[index]!); } function totalHeight(): number { + if (tree !== null) return tree.total(); let h = 0; for (let i = 0; i < virtualItems.length; i++) { h += getItemHeight(i); @@ -120,6 +155,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } function offsetToIndex(scrollTop: number): number { + if (tree !== null) return tree.findIndex(scrollTop); let offset = 0; for (let i = 0; i < virtualItems.length; i++) { const h = getItemHeight(i); @@ -130,6 +166,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo } function offsetBefore(index: number): number { + if (tree !== null && index > 0) return tree.prefixSum(index - 1); + if (tree !== null && index <= 0) return 0; let offset = 0; for (let i = 0; i < index && i < virtualItems.length; i++) { offset += getItemHeight(i); @@ -164,7 +202,28 @@ export function createMessageList(options: MessageListOptions): MessageListCompo const el = children[i] as HTMLElement; const h = el.offsetHeight; if (h > 0) { - heightCache.set(itemKey(globalIdx), h); + const key = itemKey(globalIdx); + heightCache.set(key, h); + if (tree !== null && globalIdx < tree.size) { + tree.set(globalIdx, h); + } + } + } + } + + function updateSpacers(): void { + if (topSpacer !== null) { + topSpacer.style.height = `${offsetBefore(renderedStart)}px`; + } + if (bottomSpacer !== null) { + if (tree !== null) { + const totalH = tree.total(); + const endOffset = renderedEnd > 0 ? tree.prefixSum(renderedEnd - 1) : 0; + bottomSpacer.style.height = `${totalH - endOffset}px`; + } else { + let bh = 0; + for (let i = renderedEnd; i < virtualItems.length; i++) bh += getItemHeight(i); + bottomSpacer.style.height = `${bh}px`; } } } @@ -216,13 +275,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo contentContainer.appendChild(fragment); // Set spacer heights - topSpacer.style.height = `${offsetBefore(start)}px`; - - let bottomHeight = 0; - for (let i = end; i < virtualItems.length; i++) { - bottomHeight += getItemHeight(i); - } - bottomSpacer.style.height = `${bottomHeight}px`; + updateSpacers(); // Measure newly rendered elements measureRendered(); @@ -235,37 +288,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo function rebuildItems(): void { allMessages = getChannelMessages(options.channelId); virtualItems = buildVirtualItems(allMessages); - } - /** Render all items temporarily to measure their actual heights, then - * restore the normal virtual window. This eliminates the first-scroll - * jump caused by estimated heights differing from measured ones. */ - function premeasureAll(): void { - if (contentContainer === null || virtualItems.length === 0) return; - clearChildren(contentContainer); - const fragment = document.createDocumentFragment(); + // Build Fenwick tree initialized with smart estimates / cached heights + tree = new FenwickTree(virtualItems.length); for (let i = 0; i < virtualItems.length; i++) { - const item = virtualItems[i]!; - if (item.kind === "divider") { - fragment.appendChild(renderDayDivider(item.timestamp)); - } else { - fragment.appendChild( - renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal), - ); - } + const cached = heightCache.get(itemKey(i)); + const h = cached !== undefined ? cached : estimateItemHeight(virtualItems[i]!); + tree.set(i, h); } - contentContainer.appendChild(fragment); - // Measure all - const children = contentContainer.children; - for (let i = 0; i < children.length; i++) { - const h = (children[i] as HTMLElement).offsetHeight; - if (h > 0) heightCache.set(itemKey(i), h); - } - // Restore virtual window - renderedStart = -1; - renderedEnd = -1; - clearChildren(contentContainer); - renderWindow(); } function renderAll(): void { @@ -304,6 +334,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo ); let scrollRafId = 0; + let resizeRafId = 0; + let resizeDirty = false; function handleScroll(): void { if (root === null) return; @@ -350,24 +382,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo }); // Watch for height changes in rendered items (images loading, embeds expanding). - // Re-measure heights and update spacers. The CSS scroll-anchor element handles - // pin-to-bottom automatically; for "scrolled up" we preserve distance-from-bottom. + // Batched via RAF with anchor-based scroll preservation. const resizeObserver = new ResizeObserver(() => { if (root === null || contentContainer === null) return; - // Capture scroll position relative to the bottom (stable reference point) - const distFromBottom = root.scrollHeight - root.scrollTop - root.clientHeight; - measureRendered(); - // Update spacer heights with new measurements - if (topSpacer !== null) topSpacer.style.height = `${offsetBefore(renderedStart)}px`; - if (bottomSpacer !== null) { - let bh = 0; - for (let i = renderedEnd; i < virtualItems.length; i++) bh += getItemHeight(i); - bottomSpacer.style.height = `${bh}px`; - } - // Restore scroll position (distance from bottom stays the same) - if (distFromBottom > SCROLL_BOTTOM_THRESHOLD) { - root.scrollTop = root.scrollHeight - root.clientHeight - distFromBottom; - } + resizeDirty = true; + if (resizeRafId !== 0) return; + + resizeRafId = requestAnimationFrame(() => { + resizeRafId = 0; + resizeDirty = false; + if (root === null || contentContainer === null) return; + + const atBottom = isNearBottom(); + + // Capture anchor: topmost visible item and its offset from viewport top + const anchorIdx = offsetToIndex(root.scrollTop); + const anchorOffset = root.scrollTop - offsetBefore(anchorIdx); + + // Re-measure rendered elements + measureRendered(); + + // Update spacer heights with new measurements + updateSpacers(); + + // Restore scroll position using anchor + if (atBottom) { + scrollToBottom(); + } else { + root.scrollTop = offsetBefore(anchorIdx) + anchorOffset; + } + }); }); resizeObserver.observe(contentContainer); ac.signal.addEventListener("abort", () => resizeObserver.disconnect()); @@ -375,9 +419,6 @@ export function createMessageList(options: MessageListOptions): MessageListCompo parentContainer.appendChild(root); renderAll(); - // Pre-measure all items to warm the height cache so scrolling up - // doesn't cause jumps from estimate→measured height corrections. - premeasureAll(); scrollToBottom(); const initialScrollRaf = requestAnimationFrame(() => scrollToBottom()); ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf)); @@ -400,10 +441,15 @@ export function createMessageList(options: MessageListOptions): MessageListCompo cancelAnimationFrame(scrollRafId); scrollRafId = 0; } + if (resizeRafId !== 0) { + cancelAnimationFrame(resizeRafId); + resizeRafId = 0; + } unsubLoadingReset(); for (const unsub of unsubscribers) { unsub(); } unsubscribers.length = 0; heightCache.clear(); + tree = null; if (root !== null) { root.remove(); root = null; } contentContainer = null; topSpacer = null; diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts index c3c2bf7b..5f557fd4 100644 --- a/Client/tauri-client/src/components/message-list/attachments.ts +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -186,6 +186,19 @@ export function renderAttachment(att: Attachment): HTMLDivElement { if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) { const wrap = createElement("div", { class: "msg-image" }); + // Reserve space using server-provided dimensions to prevent layout shift. + if (att.width != null && att.height != null && att.width > 0 && att.height > 0) { + const maxW = 400, maxH = 350; + const scale = Math.min(1, maxW / att.width, maxH / att.height); + const w = Math.round(att.width * scale); + const h = Math.round(att.height * scale); + wrap.style.width = `${w}px`; + wrap.style.height = `${h}px`; + } else { + // Fallback for old attachments without dimensions — use placeholder height. + wrap.style.minHeight = "200px"; + } + function attachLightbox(img: HTMLImageElement): void { img.addEventListener("click", () => { openImageLightbox(img.src, att.filename); @@ -194,6 +207,9 @@ export function renderAttachment(att: Attachment): HTMLDivElement { const isGif = att.mime === "image/gif"; + // Clear min-height reservation once image has loaded and sized itself. + const clearReservation = (): void => { wrap.style.minHeight = ""; }; + // Check cache first for instant render const cached = memoryCache.get(resolvedUrl); if (cached !== undefined) { @@ -202,9 +218,10 @@ export function renderAttachment(att: Attachment): HTMLDivElement { alt: att.filename, }) as HTMLImageElement; attachLightbox(img); - if (isGif) { - img.addEventListener("load", () => { observeMedia(img, cached, wrap); }, { once: true }); - } + img.addEventListener("load", () => { + clearReservation(); + if (isGif) observeMedia(img, cached, wrap); + }, { once: true }); wrap.appendChild(img); } else { // Show loading placeholder, then replace with image @@ -218,9 +235,10 @@ export function renderAttachment(att: Attachment): HTMLDivElement { alt: att.filename, }) as HTMLImageElement; attachLightbox(img); - if (isGif) { - img.addEventListener("load", () => { observeMedia(img, dataUrl, wrap); }, { once: true }); - } + img.addEventListener("load", () => { + clearReservation(); + if (isGif) observeMedia(img, dataUrl, wrap); + }, { once: true }); placeholder.replaceWith(img); } }); diff --git a/Client/tauri-client/src/components/message-list/fenwick.ts b/Client/tauri-client/src/components/message-list/fenwick.ts new file mode 100644 index 00000000..ac8431b5 --- /dev/null +++ b/Client/tauri-client/src/components/message-list/fenwick.ts @@ -0,0 +1,65 @@ +/** + * Fenwick Tree (Binary Indexed Tree) for O(log n) prefix sums and updates. + * Used by the virtual scroll to efficiently compute item offsets. + */ +export class FenwickTree { + private readonly tree: Float64Array; + private readonly values: Float64Array; + readonly size: number; + + constructor(size: number) { + this.size = size; + this.tree = new Float64Array(size + 1); + this.values = new Float64Array(size); + } + + /** Set value at index and update tree. */ + set(i: number, value: number): void { + const prev = this.values[i] as number; + const delta = value - prev; + if (delta === 0) return; + this.values[i] = value; + for (let x = i + 1; x <= this.size; x += x & (-x)) { + (this.tree as Float64Array)[x] = (this.tree[x] as number) + delta; + } + } + + /** Get value at index. */ + get(i: number): number { + return this.values[i] as number; + } + + /** Prefix sum of [0..i] inclusive. */ + prefixSum(i: number): number { + if (i < 0) return 0; + let s = 0; + for (let x = i + 1; x > 0; x -= x & (-x)) { + s += this.tree[x] as number; + } + return s; + } + + /** Total sum of all values. */ + total(): number { + return this.prefixSum(this.size - 1); + } + + /** Find smallest index where prefix sum > target (for scroll offset to index). */ + findIndex(target: number): number { + let pos = 0; + let bitMask = 1; + while (bitMask <= this.size) bitMask <<= 1; + bitMask >>= 1; + + let sum = 0; + while (bitMask > 0) { + const next = pos + bitMask; + if (next <= this.size && sum + (this.tree[next] as number) <= target) { + pos = next; + sum += this.tree[next] as number; + } + bitMask >>= 1; + } + return Math.min(pos, this.size - 1); + } +} diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts index f046b6d6..4754ca2e 100644 --- a/Client/tauri-client/src/components/message-list/media.ts +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -157,7 +157,9 @@ export function isDirectImageUrl(url: string): boolean { export function renderInlineImage(url: string): HTMLDivElement { const wrap = createElement("div", { class: "msg-image", - style: "max-width: 400px; contain: layout;", + // Reserve 200px before image loads to prevent layout shift. + // Cleared on load when natural dimensions are known. + style: "max-width: 400px; contain: layout; min-height: 200px;", }); const attrs: Record = { @@ -172,6 +174,9 @@ export function renderInlineImage(url: string): HTMLDivElement { } const img = createElement("img", attrs); + // Clear min-height reservation once the image has loaded and sized itself. + img.addEventListener("load", () => { wrap.style.minHeight = ""; }, { once: true }); + // Observe GIFs for visibility-based freeze/unfreeze + play/pause button if (isGifUrl(url)) { img.addEventListener("load", () => { observeMedia(img, url, wrap); }, { once: true }); diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index ac5e3f9d..b3916a2b 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -67,6 +67,8 @@ export interface Attachment { readonly size: number; readonly mime: string; readonly url: string; + readonly width?: number; + readonly height?: number; } /** Reaction summary on a REST message response. */ diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 4d1e76ac..707ea157 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -407,7 +407,7 @@ /* Image attachment */ .msg-image { margin-top: 4px; max-width: 400px; border-radius: var(--radius-md); - overflow: hidden; cursor: pointer; + overflow: hidden; cursor: pointer; contain: layout style; } .msg-image img { display: block; max-width: 100%; max-height: 350px; diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index a6e20567..decae231 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -116,7 +116,9 @@ CREATE TABLE IF NOT EXISTS attachments ( stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, - uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + width INTEGER, + height INTEGER ); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 2715705f..8644aa01 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -2,6 +2,10 @@ package api import ( "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "log/slog" "mime" "net/http" @@ -21,6 +25,8 @@ type uploadResponse struct { Size int64 `json:"size"` Mime string `json:"mime"` URL string `json:"url"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` } // MountUploadRoutes registers upload and file-serving endpoints. @@ -78,8 +84,25 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { return } + // Extract image dimensions if the file is an image. + var width, height *int + if strings.HasPrefix(mime, "image/") { + f, openErr := store.Open(fileID) + if openErr == nil { + cfg, _, decErr := image.DecodeConfig(f) + f.Close() //nolint:errcheck + if decErr == nil { + w2, h2 := cfg.Width, cfg.Height + width = &w2 + height = &h2 + } else { + slog.Warn("failed to decode image dimensions", "id", fileID, "error", decErr) + } + } + } + // Insert attachment record in DB (unlinked — message_id is NULL). - if err := database.CreateAttachment(fileID, header.Filename, fileID, mime, header.Size); err != nil { + if err := database.CreateAttachment(fileID, header.Filename, fileID, mime, header.Size, width, height); err != nil { // Clean up stored file on DB failure. _ = store.Delete(fileID) slog.Error("failed to create attachment record", "error", err) @@ -98,6 +121,8 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { Size: header.Size, Mime: mime, URL: "/api/v1/files/" + fileID, + Width: width, + Height: height, }) } } diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index 637ee5e0..c320d40e 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -19,10 +19,11 @@ type Attachment struct { } // CreateAttachment inserts a new attachment record (initially unlinked to any message). -func (d *DB) CreateAttachment(id, filename, storedAs, mimeType string, size int64) error { +// width and height are optional image dimensions (pass nil for non-image files). +func (d *DB) CreateAttachment(id, filename, storedAs, mimeType string, size int64, width, height *int) error { _, err := d.sqlDB.Exec( - `INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`, - id, filename, storedAs, mimeType, size, + `INSERT INTO attachments (id, filename, stored_as, mime_type, size, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, filename, storedAs, mimeType, size, width, height, ) if err != nil { return fmt.Errorf("CreateAttachment: %w", err) @@ -88,7 +89,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI } query := fmt.Sprintf( - `SELECT id, message_id, filename, size, mime_type + `SELECT id, message_id, filename, size, mime_type, width, height FROM attachments WHERE message_id IN (%s)`, strings.Join(placeholders, ","), ) @@ -103,7 +104,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI var id string var msgID int64 var ai AttachmentInfo - if scanErr := rows.Scan(&id, &msgID, &ai.Filename, &ai.Size, &ai.Mime); scanErr != nil { + if scanErr := rows.Scan(&id, &msgID, &ai.Filename, &ai.Size, &ai.Mime, &ai.Width, &ai.Height); scanErr != nil { return nil, fmt.Errorf("GetAttachmentsByMessageIDs scan: %w", scanErr) } ai.ID = id diff --git a/Server/db/models.go b/Server/db/models.go index e7e70132..dba7fd47 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -135,6 +135,8 @@ type AttachmentInfo struct { Size int64 `json:"size"` Mime string `json:"mime"` URL string `json:"url"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` } // ReactionInfo is the reaction shape in API responses. diff --git a/Server/migrations/007_attachment_dimensions.sql b/Server/migrations/007_attachment_dimensions.sql new file mode 100644 index 00000000..d6182fed --- /dev/null +++ b/Server/migrations/007_attachment_dimensions.sql @@ -0,0 +1,2 @@ +ALTER TABLE attachments ADD COLUMN width INTEGER; +ALTER TABLE attachments ADD COLUMN height INTEGER; diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 11249cbe..a5a841c1 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -48,7 +48,9 @@ CREATE TABLE IF NOT EXISTS attachments ( stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, - uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + width INTEGER, + height INTEGER ); `)...) diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 17ac5e8a..ce8acbab 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -45,7 +45,9 @@ CREATE TABLE IF NOT EXISTS attachments ( stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, - uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + width INTEGER, + height INTEGER ); `)...)