Skip to content

feat(chat): send and show photos - #959

Closed
bmc08gt wants to merge 8 commits into
mainfrom
feat/chat-media-photos
Closed

bmc08gt wants to merge 8 commits into
mainfrom
feat/chat-media-photos

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Adds photo messages to chat: capture or pick photos from a + menu drawn over the keyboard, send each as its own message, and draw them in the transcript. The composer also moves to a one-row glass layout.

Sending

  • Photos are downscaled and JPEG-encoded down a quality ladder until they fit the upload policy's size cap. GetUploadPolicy is called with a chatMessage access context, and the shared chat_media.json vectors are synced and gated in CI.
  • Content.media and a media quote kind are modelled and stored locally. Database.schemaVersion is bumped.
  • In an encrypted DM a photo is sealed with the shared-core chat cipher and sent as EncryptedContent. Incoming ones are decrypted before they are drawn; one that can't be opened shows as unavailable.
  • A send survives backgrounding. The READY poll retries through a dropped stream, its deadline counts only time the app is awake, and the send holds a background task assertion.
  • Pending photos are written to disk at send. After a relaunch, one whose bytes were stored resumes, and one that never reached the server comes back failed with retry.
  • Outgoing bubbles show upload progress, then a sliding segment while the server processes the blob, which reports no progress.
  • Downloaded photos are cached on disk by blob id, so they no longer fall back to the blur hash after five minutes.

Composer and attach menu

  • One row: +, the field and send. A round $ sits left of the field and splits off the field's glass when typing starts. Send appears only with a draft.
  • Text that wraps or holds a newline stacks the field above + and send, and stays stacked until the draft is cleared.
  • The + menu holds camera and photos. It grows out of + over the live keyboard and morphs into the camera or photo card, and a capture or pick collapses into a chip inside the field.
  • The bar reports its height with onGeometryChange. The GeometryReader report missed the end height of an animated stack, so the clip cut off the top line.

ChatBubbleViewTests now expects the 0.135 lift shadow opacity that #908 set; it was failing on main.

Design notes are in .claude/plans/2026-10-02-attach-photo-card.md and .claude/plans/2026-10-03-pending-photo-persistence.md.

Adds the chat-media downscale target, the JPEG quality ladder that walks
down until a photo fits the policy's size cap, and the mime selection
that follows the policy's own order. GetUploadPolicy is wrapped with a
chatMessage access context. The shared chat_media.json vectors are
synced and gated in CI.
Adds Content.media with MediaAttachment and a media quote kind, and
stores media messages locally. Database.schemaVersion is bumped because
the store now writes media content.
Photos are staged as composer chips, uploaded through the downscale and
quality ladder, and sent one message each with retry. The transcript
draws photo bubbles, quotes and previews describe them, and tapping one
opens a full-screen viewer. An inline camera captures straight into the
composer. Only the downsampled copy is cached, not the original.
The attach menu grows out of +, and the menu, camera card and photo
card are one surface that morphs between states. The surface draws over
the live keyboard by joining the keyboard's window, falling back to an
input view swap and then to dismissing the keyboard. The camera and
picker start while the menu is up, so a card has content when it opens.
A tap anywhere outside the open menu only closes it. Staged photos sit
inside the message field and fade at its edges.

The keyboard overlay decisions are recorded in
.claude/plans/2026-10-02-attach-photo-card.md.
Downloaded photos go to a dedicated Kingfisher disk cache keyed by blob
id, so they no longer fall back to the blur hash after five minutes.

In an encrypted DM a photo is sealed with the shared-core chat cipher,
stored with endToEndEncryptedFor set to the chat, and sent as
EncryptedContent. Incoming encrypted photos are decrypted before they
are drawn, and one that can't be opened shows as unavailable.

Blob rejections now log the server's reason code instead of "unknown".
Outgoing photo bubbles show a thin progress bar in the corner: the
upload's byte fraction, then a sliding segment while the server
processes the blob, which has no progress signal.

A send no longer fails when the app is backgrounded mid-send. The
READY poll retries through a dropped stream, its deadline only counts
time the app is awake, and the send holds a background task assertion.
A retry after the bytes are stored polls again instead of uploading
again.

Pending photos are written to disk at send, so a relaunch restores them:
one whose bytes were stored resumes, one that never reached the server
comes back failed with retry, and one that already sent is dropped.
The composer is a single row: +, the message field and send, in glass
tinted 3% white with a lit rim and 28pt corners. A round $ sits left of
the field and splits from the field's glass on the draft's first
character, rejoining it when the draft is cleared. Send pops in on a
draft and is gone without one. An empty field takes focus from a tap
anywhere right of +.

Text that wraps or holds a newline stacks the field: the text takes the
full width and + and send drop to a row below. It stays stacked until
the draft is cleared, so editing near the wrap point does not flip the
layout. The caret and text spring into place together rather than the
caret trailing on UIKit's own animation.

The bar now reports its height with `onGeometryChange`. The
`GeometryReader` report delivered only the height an animated stack
started from, so the bar's clip stayed one row tall and cut off the top
line. When the composer shrinks, the clip eases down on the stack spring
while the transcript moves in one frame, as #908 does.

The + menu holds camera and photos. It opens from the $'s margin at
220pt, its rows sharpening out of a blur as it grows from +, and springs
back into + without a colour jump. The composer stays visible under the
camera and photo cards, and a capture or pick collapses into its chip
with the photo drawn throughout.
#908 lowered BubbleBackgroundView.liftShadowOpacity from 0.65 to 0.135
and left this expectation at 0.65.
@bmc08gt bmc08gt self-assigned this Oct 5, 2026
@bmc08gt

bmc08gt commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Split into #961 → #962 → #963 → #964, with the test fix as #960.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant