fix: virtual scroll jumping with images/GIFs

Server:
- Extract image width/height on upload via image.DecodeConfig (header-only)
- Add width/height columns to attachments table (migration 007)
- Include dimensions in AttachmentInfo JSON (optional, backward-compatible)

Client:
- Reserve exact space for images using server-provided dimensions
- Fallback min-height: 200px for external/old images, cleared on load
- Remove premeasureAll() — was caching wrong heights for unloaded images
- Smart per-type height estimates: 32px dividers, 42/72px text, +220px
  per image attachment, +320px for YouTube embeds
- Batch ResizeObserver corrections to single RAF with anchor-based scroll
  preservation (topmost visible item stays in place)
- Fenwick tree for O(log n) offset lookups (replaces O(n) linear scans)
- CSS contain: layout style on .msg-image to isolate layout shift
This commit is contained in:
jevb
2026-03-21 12:34:31 +01:00
parent db1a9aaaef
commit fc29968d3d
13 changed files with 249 additions and 77 deletions
+6 -5
View File
@@ -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
+2
View File
@@ -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.