Multi-select a handful of photos in tonGues and they land as a single grid
bubble — one caption, one timestamp, one tick, one notification — instead of a
full-size bubble per image. That's the album bubble, and it's the feature we
shipped this week on top of a messenger that, until now, was built entirely around
one image per message.
The interesting part was never the grid. It was doing this without tearing up
the invariant the whole codebase leans on: one message row = one media item. The
rest of this post is how we layered Flutter photo albums on top of that rule
instead of around it.
tonGues is a language-exchange messenger — chat, calls and live translation,
Flutter on the client, FastAPI and PostgreSQL behind it. If you're new here, start
with the overview of tonGues.
This post is one of a few we're writing today, alongside the
shared media gallery
and the Linux desktop video work.
One row per media item — the rule we refused to break
Here's the constraint, stated plainly. Our messages table is one row per piece
of media. That single fact is load-bearing across the app:
- Delivery reconcile is URL-based. When the upload pipeline finishes, the client matches the optimistic bubble to the real row by the uploaded file's URL — one file, one row, one match.
- Read receipts and reactions are per row. A thumb, a "read" tick, a count — all column-level facts on a message.
- The offline outbox is one durable record per file. Every attachment gets its own record with its own progress and retry, so a killed app can resume each file independently.
-
Orphan cleanup scans
image_urlper row. A row that never got its URL is an orphan; the sweep already knows how to find it.
A naive album feature — "add an attachments table" or "stuff a JSON array of
URLs into one message" — breaks all four at once. Every read path (history,
search, pins, previews) would need rewriting, and the URL-based reconcile would
stop matching because one row would now carry N URLs.
So we didn't do that. We kept one row per media item and layered a group key on
top of it.
Why Flutter photo albums need a group key
Three nullable columns on messages turn N independent rows into one logical
album:
ALTER TABLE messages ADD COLUMN album_id TEXT NULL;
ALTER TABLE messages ADD COLUMN album_index INTEGER NULL;
ALTER TABLE messages ADD COLUMN album_size INTEGER NULL;
CREATE INDEX ix_messages_album_id ON messages (album_id);
The additive-column
shape matters: it's three plain columns and an index, not a new entity.
-
album_id(indexedTEXT) is the shared group key. The client generates it at send time — a UUID-ish token — so an offline or still-uploading album is already grouped before any server round-trip. Its length is bounded on the server so a client can't stuff the indexed column with unbounded text. -
album_index(INTEGER, 0-based) is the only ordering authority inside a group. This one matters more than it sounds. Sibling rows upload concurrently, so theircreated_attimestamps can arrive out of order. If any code ever sorted a group by time, the grid would shuffle. It doesn't — it sorts byalbum_index, full stop. -
album_size(INTEGER) is the total tile count, duplicated onto every sibling on purpose. Denormalizing it means the+Noverlay and the notification label render before the last sibling has uploaded, without aCOUNT(*)on the hot path.
Because the columns are nullable and additive, the migration is safe to deploy
ahead of the client — and the part we care about most is that album_id IS NULL
renders exactly like before. Old rows, old clients, no regressions.
The anchor row
An album needs somewhere to hang the things that only make sense once per group.
We call that row the anchor, and it's simply the sibling with the lowest index
(album_index == 0):
- it carries the caption (every other sibling stores an empty
content); - it carries the reply target, and therefore the quote preview and the "forwarded" flag;
- it owns the footer surface — time, ticks, read count — and the reaction surface;
- it's the row that fires the notification.
Siblings are collapsed: no caption, no reply, no footer, no separate push. The
server enforces the shape on send — album_size within range,
0 <= album_index < album_size, media present, and album_id rejected on a row
with no image — so a client can't post a half-formed group.
Grouping Flutter photo albums on the client
Grouping happens on the reader's device, in the same pass that builds the message
list's display items. The rule is deliberately narrow: consecutive rows that
share an album_id, from the same sender, with no date separator or system
chip between them, collapse into one album item. Break the run — a different
sender replies, a day boundary lands — and you get two albums, which is correct.
Inside the item, the tiles are ordered by album_index and never by time:
siblings
..sort((a, b) => (a.albumIndex ?? 0).compareTo(b.albumIndex ?? 0));
That's the whole trick. There's no album table, no join, no second read path. The
list already built display groups for date separators and sender-name bands; an
album is just one more kind of group, and it counts as a single visual unit
for the gap logic between messages.
A grid that's sized on the first frame
The premium feel of an album lives or dies on one thing: the grid must be
exactly the right size before the images load. Get that wrong and the whole
conversation jumps as tiles pop in.
We avoid the jump by sizing from data we already store. Every image row keeps its
image_width and image_height from the moment of upload, and the album block's
aspect ratio comes from the anchor's own dimensions (clamped so an extreme
panorama can't produce a degenerate grid). Each tile uses BoxFit.cover, so it
fills its cell whatever its native ratio — the same
layout model Flutter applies everywhere
else. Sender and recipient both get the final layout on frame one; the images
just fill in.
The layout is nothing more than a lookup from tile count:
| Count | Layout |
|---|---|
| 1 | single full bubble — the existing path, unchanged |
| 2 | two equal columns |
| 3 | one large tile left, two stacked right |
| 4 | 2×2 |
| 5 | two on top, three on the bottom |
| 6 | 3×2 |
| 7–9 | three columns; the last row is left-aligned |
| 10+ | capped at ten, with a frosted +N pill over the last tile |
Two small details make it feel finished. Tiles cascade in with a short
per-index fade-and-slide — and that stagger is gated on the app's motion setting,
so it's simply zero when the user has reduced motion on. And the whole grid sits
inside one shared media border, so only the outer corners are rounded and
accent-bordered; the interior seams are a hairline 2 px.
Open one tile and you land in the swipe-through gallery at exactly that photo.
That gallery is its own story — we wrote it up in the
shared media gallery post
— and albums just hand it an entry point and a starting tile.
One notification, one preview, one progress bar
An album is one logical message, so it should behave like one to the person
receiving it — and this is where the fan-out bugs live.
Notifications. Exactly one push per album, fired from the anchor. A ten-photo
album must not buzz ten times. The broadcast layer treats a sibling (any row with
album_index > 0) as suppressed: it still emits the message event so the tile
renders and gets its delivered tick, but it skips the push and the
conversation-list preview entirely. The body is the same everywhere — 📷 N photos
— whether it's the push, the in-app notification list, or the friend-list preview.
Uploading. The send path is still per-file, which is what gives us per-tile
progress, retry and cancel for free. For an album we generate one album_id, then
loop the staged files, putting the caption and reply on the anchor only:
final albumId = newAlbumId();
final albumSize = staged.length;
for (var i = 0; i < albumSize; i++) {
final isAnchor = i == 0;
beginPendingUpload(
filePath: staged[i].path,
kind: UploadKind.image,
content: isAnchor ? text : '', // caption lives on the anchor
replyToId: isAnchor ? replyId : null, // so does the reply
albumId: albumId,
albumIndex: i,
albumSize: albumSize,
);
}
On screen that becomes one thin aggregate bar across the bottom of the grid,
plus a small spinner on each tile still in flight. A tile whose upload fails gets
its own retry badge instead of spinning forever. Each tile is its own durable
outbox record — the same "this must survive a session ending" instinct behind our
Linux keyring session work
— so killing the app mid-send restores the album, grouped, and resumes the tiles
that hadn't landed.
Forward keeps the group; delete takes it apart
Two actions are worth calling out because albums change their meaning.
Forward copies album_id, album_index and album_size onto every copy, so
the album re-groups in the destination. Forward ten photos and the other
conversation gets an album, not ten loose images.
Delete on an album removes all siblings. And there's a tidy edge case on
the other side: if an album ever drops to a single surviving row — siblings
deleted one by one, say — that row clears its album_id and degrades back to a
plain single image. The group key is optional, so the model simply falls back to
the behaviour it had before albums existed.
What we'd do next
This phase is deliberately images-only. The model doesn't need to change to
add video tiles — album_index is media-agnostic — so mixed photo-and-video
albums are a clean later step: a per-tile player, a play badge, and the chunked
upload path video already has. The
Linux desktop video pipeline
is a good look at the machinery that would slot in. Encrypted chats stay as they
are: media is already blocked there, so albums inherit the same guard and the same
friendly message.
The takeaway, if there is one: a big feature doesn't have to mean a big schema. We
added three nullable columns, one convention (the anchor), and a client-side
grouping rule — and kept the invariants that make delivery, receipts, reactions
and cleanup work. The grid is the visible part. The three columns are the whole
trick.
FAQ
Do albums need a separate API endpoint?
No. An album is just a normal send with album_id, album_index and album_size
attached to each row. The same REST and WebSocket send paths handle it, and the
same broadcast fans it out. There's nothing album-specific to route.
How are album tiles ordered if the uploads finish out of order?
By album_index, always. Sibling rows upload concurrently and can land with
timestamps in any order, which is exactly why we forbid created_at from ordering
a group. The index is the only authority.
What happens if an album loses a sibling?
The group stays a group while any sibling remains — a failed tile shows a retry
badge rather than a hole. If the album drops all the way to one row, that row
clears its album_id and renders as a normal single image.
Does this work in encrypted chats?
Not yet. Media is already disabled in encrypted chats, so albums inherit the same
restriction and the same user-facing message. When photo and video sending arrives
there, albums will come along for free.
— the tonGues team
Get tonGues — a language-exchange messenger with live translation.
- Android: Google Play
- Windows: .zip or installer
- Linux: .deb or portable tarball
Top comments (0)