Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0ebdef8
add partById to resolve message parts by id instead of unstable list …
DeveloperBlue Jul 30, 2026
3bdc475
fix: use message-part ids for swipe-to-reply, bubble effects, reply-r…
DeveloperBlue Jul 30, 2026
f8ca292
fix: plumb attachmentGuid through reply context to resolve specific a…
DeveloperBlue Jul 30, 2026
2c1f077
fix: guard reply/thread paths when a reply targets a missing message …
DeveloperBlue Jul 30, 2026
c4cc33a
fix: add isLeading/isTrailingMessagePart helpers for multipart layouts
DeveloperBlue Jul 30, 2026
2b01e17
fix: target correct message parts for bubble reactions/stickers for c…
DeveloperBlue Jul 30, 2026
f948391
fix: resolve reply threads by message-part id across collapsed galle…
DeveloperBlue Aug 8, 2026
5e8f265
chore: make gallery stacks a uniform size instead of tallest-image he…
DeveloperBlue Aug 3, 2026
6038389
feat: explicit fill for gallery cards
DeveloperBlue Aug 5, 2026
3f147bb
refactor: (gallery) unify fan direction
DeveloperBlue Aug 5, 2026
f919351
refactor: (gallery) tighten fan spread, past cards on left, future ca…
DeveloperBlue Aug 5, 2026
3028c64
fix: decouple 'X Photos & Vdeos' label from stack math
DeveloperBlue Aug 5, 2026
eb87307
fix: align gallery fan stack with author inset edges
DeveloperBlue Aug 5, 2026
fb1b391
fix: sync gallery fan index with currentIndexNotifier
DeveloperBlue Aug 5, 2026
2c4de8c
feat: tap to jump to non-focused cards
DeveloperBlue Aug 5, 2026
d82e028
fix: scope stack drag listener to cards only
DeveloperBlue Aug 5, 2026
15a9dfd
fix: vertical-scroll bail-out and guards for fan stack drag
DeveloperBlue Aug 5, 2026
e524056
polish: use shorter ios 'X Items' instead of 'X Photos & Videos' labe…
DeveloperBlue Aug 5, 2026
fa200d6
chore: rename media gallery flags to collection
DeveloperBlue Aug 6, 2026
a4a5a27
fix: correctly render subect + multi-attachment messages
DeveloperBlue Aug 6, 2026
d60cd4e
refactor: split MessageImageGallery into collection stack and reusabl…
DeveloperBlue Aug 6, 2026
6b1a89a
feat: move collection long-press into children collection attachment …
DeveloperBlue Aug 7, 2026
87c29cd
feat: attachment popup scope appropriately sizes, bounds, and aligns …
DeveloperBlue Aug 7, 2026
134c7a8
refactor: collection attachment tapback with inside-tail bubble
DeveloperBlue Aug 7, 2026
bf3d223
cleanup: rename partIndexForAttachment to partIdForAttachment
DeveloperBlue Aug 7, 2026
7eeb9cb
feat: add reusable download button for collection stack
DeveloperBlue Aug 7, 2026
889050d
polish: collection stack spacing
DeveloperBlue Aug 7, 2026
700825b
chore: update docs for attachment collections
DeveloperBlue Aug 7, 2026
edea955
chore: lift collection card shadow + clipping onto CollectionAttachme…
DeveloperBlue Aug 8, 2026
b30078d
chore: AttachmentHolder uses 'fill' as the sole collection presentati…
DeveloperBlue Aug 8, 2026
f240d27
core: document AttachmentHolder fill for collection cards
DeveloperBlue Aug 8, 2026
105dc04
feat: add CollectionGroupCollage widget
DeveloperBlue Aug 7, 2026
cba9c37
feat: route iOS 2-3 attachments to collage collection
DeveloperBlue Aug 7, 2026
8dea205
feat: swipe-to-reply on collage cards
DeveloperBlue Aug 7, 2026
5731f33
fix: use standard reaction tails on collage cards, inside on stack cards
DeveloperBlue Aug 8, 2026
0f4ae98
chore: document collage collection layout
DeveloperBlue Aug 8, 2026
a747caf
polish: ios parity for collage card stagger
DeveloperBlue Aug 8, 2026
52d93b2
fix: redacted mode leaking contact name and message contents in reply…
DeveloperBlue Jul 29, 2026
7c4b8fd
feat: add CollectionGroupGrid widget
DeveloperBlue Aug 8, 2026
3e1899b
feat: route Material/Samsung collections to grid
DeveloperBlue Aug 8, 2026
e365630
fix: keep grid cell reactions from clipping
DeveloperBlue Aug 8, 2026
6fbccc6
fix: disable card shadows on grid tiles
DeveloperBlue Aug 8, 2026
453c057
feat: add grid 'see more' +N cell
DeveloperBlue Aug 8, 2026
f7c539c
fix: square media inside grill cells
DeveloperBlue Aug 8, 2026
f3f2ed1
fix: align grid item reactions with tighter overhang
DeveloperBlue Aug 8, 2026
bb0cfbf
fix: tighten author-edge grid corners when subject or body is present
DeveloperBlue Aug 8, 2026
1696a1a
refactor: clip collection grid silhouette outer instead of per-cell
DeveloperBlue Aug 8, 2026
73dad81
chore: document grid collection layout
DeveloperBlue Aug 8, 2026
d1f8d32
fix: add missing bottom spacing under Material reply bubble
DeveloperBlue Aug 8, 2026
542184c
fix: drop extra avatar inset on collections
DeveloperBlue Aug 8, 2026
0540d7d
feat: rework grid layout with composable bands
DeveloperBlue Aug 9, 2026
e40dd0f
fix: tune collection grid corner radius per skin
DeveloperBlue Aug 9, 2026
c754f93
fun: long-press '+N' on grid to load all remaining grid items inline
DeveloperBlue Aug 9, 2026
abb2745
polish: add haptics to longpress inline expand for grid
DeveloperBlue Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Renders all non-text media inside message bubbles. Entry point: `AttachmentHolder`, which dispatches to the appropriate renderer based on MIME type.

Multi-attachment **collections** live in `collections/` → [CLAUDE.md](collections/CLAUDE.md).

## Files

| File | Purpose |
Expand All @@ -14,11 +16,15 @@ Renders all non-text media inside message bubbles. Entry point: `AttachmentHolde
| `sticker_holder.dart` | Sticker rendering (full-size emoji-like overlays) |
| `other_file.dart` | Generic file display for docs, archives, APKs, etc. |
| `live_photo_mixin.dart` | Mixin for handling Live Photo metadata |
| `collections/` | media collections for multi-attachment rendering → CLAUDE.md |
| `parts/` | Per-transfer-state renderers for a single attachment slot → CLAUDE.md |

## Key Patterns

**Download state**: `AttachmentHolder` holds an `Rx<dynamic> content` that is `null` until downloaded. Observes `AttachmentDownloadController` for progress updates. Auto-download is gated by `AttachmentsSvc.canAutoDownload()`.

**`fill`**: When true, cover-expands into a parent-fixed frame and skips standalone bubble chrome. Collection parents own clip/shadow — see [collections/CLAUDE.md](collections/CLAUDE.md).

**Controller**: Extends `CustomStateful<MessageWidgetController>`. Always set `forceDelete = false` in `initState()` — the message list owns the controller lifecycle.

**Fullscreen**: Tap on `ImageViewer` or `VideoPlayer` pushes `FullscreenMedia` via `NavigationSvc`. See `lib/app/layouts/fullscreen_media/CLAUDE.md`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attach
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/parts/not_loaded_content.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/parts/downloading_content.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/attachment/parts/resolved_file_content.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/popup/message_popup_holder.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart';
import 'package:bluebubbles/app/state/attachment_state.dart';
import 'package:bluebubbles/app/state/attachment_state_scope.dart';
Expand All @@ -24,14 +25,16 @@ class AttachmentHolder extends StatefulWidget {
const AttachmentHolder({
super.key,
required this.message,
this.transparentBackground = false,
this.showCardShadow = false,
this.fill = false,
this.galleryAttachments,
});

final MessagePart message;
final bool transparentBackground;
final bool showCardShadow;

/// Cover-expand into a parent-fixed frame and suppress standalone bubble chrome
/// (padding, selection tint, holder shadow/radius). Parents own clip/shadow.
/// Disabled automatically inside [PopupScope].
final bool fill;
final List<Attachment>? galleryAttachments;

@override
Expand Down Expand Up @@ -160,7 +163,7 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
/// itself from [Attachment.displayBox], so reserving that box would just
/// move the jump rather than remove it.
({double width, double height})? _reservedImageBox(BuildContext context, bool isInReply, bool hideAttachments) {
if (isInReply || hideAttachments || widget.transparentBackground) return null;
if (isInReply || hideAttachments || widget.fill) return null;
if (attachment.mimeStart != "image") return null;
if (!attachment.hasValidSize) return null;
return attachment.displayBox(NavigationSvc.width(context) * 0.5);
Expand All @@ -178,6 +181,12 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
right: message.isFromMe! ? 10 : 0,
);

// Collection fill cards are sized by the parent; padding is on the collection wrapper.
// Use [widget.fill] (not the popup-adjusted layout fill) so popup cards stay zero-padded.
if (widget.fill) {
return EdgeInsets.zero;
}

// Treat an error preview the same as a resolved file — no extra padding.
final hasError = state.hasError.value || message.error > 0;
final effectiveFile =
Expand All @@ -195,12 +204,6 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
if (state.isSending.value && message.isFromMe!) {
return EdgeInsets.zero;
}
// Gallery cards (transparentBackground=true) constrain their height via an
// outer SizedBox. DownloadingContent / NotLoadedContent handle their own
// internal padding, so adding extra padding here causes overflow.
if (widget.transparentBackground) {
return EdgeInsets.zero;
}
return const EdgeInsets.symmetric(vertical: 10, horizontal: 15).add(sideInsets);
}

Expand Down Expand Up @@ -249,6 +252,8 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
required bool isInReply,
required bool isiOS,
required ({double width, double height})? reservedBox,
required bool fill,
required bool forceAllCornersRounded,
}) {
// Redacted mode always shows placeholder regardless of download status.
if (hideAttachments) {
Expand All @@ -271,7 +276,8 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
isiOS: isiOS,
cvController: controller.cvController,
isInReply: isInReply,
forceAllCornersRounded: widget.transparentBackground,
forceAllCornersRounded: forceAllCornersRounded,
fill: fill,
galleryAttachments: widget.galleryAttachments,
);
}
Expand All @@ -287,7 +293,8 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
isiOS: isiOS,
cvController: controller.cvController,
isInReply: isInReply,
forceAllCornersRounded: widget.transparentBackground,
forceAllCornersRounded: forceAllCornersRounded,
fill: fill,
galleryAttachments: widget.galleryAttachments,
);
}
Expand All @@ -310,7 +317,7 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
downloadController: download,
isInReply: isInReply,
isiOS: isiOS,
isInGallery: widget.transparentBackground,
isInCollection: widget.fill,
compact: compact,
showTail: showTail,
isFromMe: message.isFromMe!,
Expand Down Expand Up @@ -338,7 +345,7 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
final bool isInReply = ReplyScope.maybeOf(context) != null;
final bool isPass = attachment.isPkPass;
final bool showTail =
!isInReply && !isPass && message.showTail(newerMessage) && part.part == controller.parts.length - 1;
!isInReply && !isPass && message.showTail(newerMessage) && controller.isTrailingMessagePart(part);

// Resolve state once for the scope. The AttachmentState object is updated
// in-place by the service layer; no re-lookup is needed on reactive changes.
Expand Down Expand Up @@ -370,11 +377,18 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
final hasError = state.hasError.value || message.error > 0;
final hasPreview = state.resolvedFile.value != null ||
(hasError && message.isFromMe == true && state.uploadPreviewFile.value != null);
final transparentCard = hasPreview && (widget.transparentBackground || isPass || attachment.mimeStart == "image");
// Gallery cards in non-preview states (downloading, not-loaded, etc.) need
// to fill the SizedBox dimensions set by MessageImageGallery and have their
// background clipped to rounded corners.
final shouldExpandAndClipForGallery = widget.transparentBackground && !hasPreview;
// Message popup has no tight parent frame — skip cover-fill expand.
final inPopup = PopupScope.maybeOf(context) != null;
final fill = widget.fill && !inPopup;
// Parent ClipRRect owns collection corners; popup has no bubble tail so round all.
final forceAllCornersRounded = inPopup;
// Fill cards (and images / pkpasses) use transparent ink so media shows through.
// Popup videos also need transparency — otherwise the square Ink peeks past ClipRRect.
final transparentCard = hasPreview &&
(widget.fill ||
isPass ||
attachment.mimeStart == "image" ||
(inPopup && attachment.mimeStart == "video"));
// Only meaningful before the file resolves; once it has, the image
// itself defines the box.
final reservedBox = hasPreview ? null : _reservedImageBox(context, isInReply, hideAttachments);
Expand Down Expand Up @@ -402,10 +416,10 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
// AnimatedSize loosens constraints, so content would render at its
// natural size — smaller than the gallery SizedBox. SizedBox.expand()
// snaps back to the max loosened constraints (= cardWidth x cardHeight)
// and forces tight dimensions all the way down to the content widget.
child: shouldExpandAndClipForGallery
// natural size — smaller than the parent frame. SizedBox.expand()
// snaps back to the max loosened constraints and forces tight
// dimensions all the way down to the content widget.
child: fill
? SizedBox.expand(
child: SendingOpacityWrapper(
child: _buildContent(
Expand All @@ -415,6 +429,8 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
isInReply: isInReply,
isiOS: isiOS,
reservedBox: reservedBox,
fill: fill,
forceAllCornersRounded: forceAllCornersRounded,
),
),
)
Expand All @@ -423,28 +439,16 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
widthFactor: 1,
// SendingOpacityWrapper has its own Obx so isSending
// changes only rebuild the opacity layer, not this tree.
child: DecoratedBox(
decoration: widget.showCardShadow
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: context.theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
)
: const BoxDecoration(),
child: SendingOpacityWrapper(
child: _buildContent(
state: state,
hideAttachments: hideAttachments,
showTail: showTail,
isInReply: isInReply,
isiOS: isiOS,
reservedBox: reservedBox,
),
child: SendingOpacityWrapper(
child: _buildContent(
state: state,
hideAttachments: hideAttachments,
showTail: showTail,
isInReply: isInReply,
isiOS: isiOS,
reservedBox: reservedBox,
fill: fill,
forceAllCornersRounded: forceAllCornersRounded,
),
),
),
Expand All @@ -454,35 +458,11 @@ class _AttachmentHolderState extends State<AttachmentHolder> with ThemeHelpers {
),
),
);
// Gallery non-preview: wrap with shadow + rounded clip at the card boundary.
// This clips the surfaceContainerHighest Ink background to rounded corners and
// places the shadow around the full card rather than the smaller content widget.
if (shouldExpandAndClipForGallery) {
content = DecoratedBox(
decoration: widget.showCardShadow
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: context.theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
)
: const BoxDecoration(),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: content,
),
);
}
// ColorFiltered is only for standalone (non-gallery) selection tinting.
// In gallery mode (transparentBackground = true), the ColorFilter creates a
// saveLayer bounded by the messages view repaint boundary. The dstOver blend
// then fills every transparent pixel in that large layer with tertiaryContainer,
// turning the entire messages view pink/purple while an attachment downloads.
if (!transparentCard && !widget.transparentBackground) {
// ColorFiltered is only for standalone selection tinting.
// In fill mode the ColorFilter creates a saveLayer bounded by the messages
// view repaint boundary; dstOver then fills every transparent pixel in that
// large layer with tertiaryContainer (pink/purple flash while downloading).
if (!transparentCard && !widget.fill) {
content = ColorFiltered(
colorFilter: ColorFilter.mode(
context.theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# attachment/collections/ — Media Collections

`MessagePart.isMediaCollection` → `MessagePartContent` → `resolveMediaCollectionLayout`
(`helpers/ui/message_widget_helpers.dart`).

| Skin | Count | Layout |
|------|-------|--------|
| iOS | 2–3 | collage |
| iOS | 4+ | stack |
| Material / Samsung | 2+ | grid |

## Ownership

- Parents size the frame; `AttachmentHolder(fill: true)` fills it (popup skips fill).
- **Collage / stack:** card owns shadow + rounded clip; reactions outside the clip.
- **Grid:** no card shadow; outer `ClipRRect` owns silhouette; cells stay square. Reaction overlay above cells (author-edge, `tightOverhang`). Author-edge corners tighten when subject/body is adjacent.

## Grid layout (`collection_group_grid.dart`)

Three composable shapes on a shared 3-column grid (details in the `CollectionGroupGrid` class doc):

| Shape | Geometry |
|-------|----------|
| Banner | Full-width; height = `HeroStack(2)` × `4/3` |
| HeroStack(n) | 2-col hero + column of `n` squares (facing flips each hero) |
| SquareRow(n) | Full-width row of `n` equal squares |

**`+N` (count > 7):** tap → fullscreen; long-press → expand missing items in place.

## Files

| File | Purpose |
|------|---------|
| `collection_group_collage.dart` | Overlapping collage (iOS 2–3) |
| `collection_group_stack.dart` | Fan stack (iOS 4+) |
| `collection_group_grid.dart` | Material/Samsung grid (chrome + layout + reactions) |
| `collection_attachment_card.dart` | Shared card chrome + `fill` media + reactions |
| `collection_title.dart` | “X Photos/Videos/Items” label |
| `collection_download_button.dart` | Incoming download control (`CollectionDownloadButton.wrap`) |
Loading