diff --git a/src/app/features/feed/FeedPostCard.tsx b/src/app/features/feed/FeedPostCard.tsx
index 587f88d3..b6d7bcf7 100644
--- a/src/app/features/feed/FeedPostCard.tsx
+++ b/src/app/features/feed/FeedPostCard.tsx
@@ -2,7 +2,19 @@ import React, { useCallback, useMemo, useState } from 'react';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
-import { Avatar, Box, Chip, Icon, IconButton, Icons, PopOut, RectCords, Text, config } from 'folds';
+import {
+ Avatar,
+ Box,
+ Chip,
+ Icon,
+ IconButton,
+ Icons,
+ PopOut,
+ RectCords,
+ Scroll,
+ Text,
+ config,
+} from 'folds';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -42,6 +54,7 @@ import { EmojiBoard } from '../../components/emoji-board';
import { Reactions } from '../room/message';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
import { IImageContent } from '../../../types/matrix/common';
+import { groupFeedEventRuns } from './feedAttachmentRuns';
type FeedPostEventBodyProps = {
event: MatrixEvent;
@@ -225,17 +238,41 @@ export function FeedPostCard({
dateFormatString={dateFormatString}
/>
- {events.map((event) => (
-
- ))}
+ {groupFeedEventRuns(events).map((run) => {
+ const body = (event: MatrixEvent) => (
+
+ );
+
+ if (run.isAttachmentRun && run.events.length > 1) {
+ return (
+
+
+ {run.events.map((event) => (
+
+ {body(event)}
+
+ ))}
+
+
+ );
+ }
+
+ return run.events.map((event) => body(event));
+ })}
{eventId && (
diff --git a/src/app/features/feed/feedAttachmentRuns.test.ts b/src/app/features/feed/feedAttachmentRuns.test.ts
new file mode 100644
index 00000000..5dced6ca
--- /dev/null
+++ b/src/app/features/feed/feedAttachmentRuns.test.ts
@@ -0,0 +1,77 @@
+import { MsgType } from 'matrix-js-sdk';
+import { describe, expect, it } from 'vitest';
+import { groupFeedEventRuns, isAttachmentEvent } from './feedAttachmentRuns';
+
+const fakeEvent = (id: string, msgtype: string | undefined) =>
+ ({
+ getId: () => id,
+ getContent: () => (msgtype === undefined ? {} : { msgtype }),
+ } as unknown as import('matrix-js-sdk').MatrixEvent);
+
+describe('isAttachmentEvent', () => {
+ it.each([MsgType.Image, MsgType.Video, MsgType.Audio, MsgType.File])(
+ 'treats %s events as attachments',
+ (msgtype) => {
+ expect(isAttachmentEvent(fakeEvent('a', msgtype))).toBe(true);
+ }
+ );
+
+ it.each([MsgType.Text, MsgType.Emote, MsgType.Notice, MsgType.Location])(
+ 'does not treat %s events as attachments',
+ (msgtype) => {
+ expect(isAttachmentEvent(fakeEvent('a', msgtype))).toBe(false);
+ }
+ );
+
+ it('does not treat events without a msgtype as attachments', () => {
+ expect(isAttachmentEvent(fakeEvent('a', undefined))).toBe(false);
+ });
+});
+
+describe('groupFeedEventRuns', () => {
+ it('groups consecutive attachment events into a single run', () => {
+ const events = [
+ fakeEvent('img1', MsgType.Image),
+ fakeEvent('img2', MsgType.Image),
+ fakeEvent('img3', MsgType.Image),
+ ];
+ const runs = groupFeedEventRuns(events);
+ expect(runs).toHaveLength(1);
+ expect(runs[0].isAttachmentRun).toBe(true);
+ expect(runs[0].events.map((e) => e.getId())).toEqual(['img1', 'img2', 'img3']);
+ });
+
+ it('splits a trailing caption into its own non-attachment run', () => {
+ const events = [
+ fakeEvent('img1', MsgType.Image),
+ fakeEvent('img2', MsgType.Image),
+ fakeEvent('caption', MsgType.Text),
+ ];
+ const runs = groupFeedEventRuns(events);
+ expect(runs).toHaveLength(2);
+ expect(runs[0]).toMatchObject({ isAttachmentRun: true });
+ expect(runs[0].events.map((e) => e.getId())).toEqual(['img1', 'img2']);
+ expect(runs[1]).toMatchObject({ isAttachmentRun: false });
+ expect(runs[1].events.map((e) => e.getId())).toEqual(['caption']);
+ });
+
+ it('alternates runs when attachments and text interleave', () => {
+ const events = [
+ fakeEvent('text1', MsgType.Text),
+ fakeEvent('img1', MsgType.Image),
+ fakeEvent('img2', MsgType.Video),
+ fakeEvent('text2', MsgType.Text),
+ ];
+ const runs = groupFeedEventRuns(events);
+ expect(runs.map((r) => r.isAttachmentRun)).toEqual([false, true, false]);
+ expect(runs.map((r) => r.events.map((e) => e.getId()))).toEqual([
+ ['text1'],
+ ['img1', 'img2'],
+ ['text2'],
+ ]);
+ });
+
+ it('returns an empty array for no events', () => {
+ expect(groupFeedEventRuns([])).toEqual([]);
+ });
+});
diff --git a/src/app/features/feed/feedAttachmentRuns.ts b/src/app/features/feed/feedAttachmentRuns.ts
new file mode 100644
index 00000000..7dc830ff
--- /dev/null
+++ b/src/app/features/feed/feedAttachmentRuns.ts
@@ -0,0 +1,35 @@
+import { MatrixEvent, MsgType } from 'matrix-js-sdk';
+
+const ATTACHMENT_MSG_TYPES: string[] = [MsgType.Image, MsgType.Video, MsgType.Audio, MsgType.File];
+
+export const isAttachmentEvent = (event: MatrixEvent): boolean => {
+ const msgType = event.getContent().msgtype;
+ return typeof msgType === 'string' && ATTACHMENT_MSG_TYPES.includes(msgType);
+};
+
+export type FeedEventRun = {
+ isAttachmentRun: boolean;
+ events: MatrixEvent[];
+};
+
+/**
+ * Splits a post's events into consecutive runs of attachment vs
+ * non-attachment events, preserving order, so a run of attachments can be
+ * rendered as one horizontally scrollable group distinct from surrounding
+ * text content.
+ */
+export const groupFeedEventRuns = (events: MatrixEvent[]): FeedEventRun[] => {
+ const runs: FeedEventRun[] = [];
+
+ events.forEach((event) => {
+ const isAttachmentRun = isAttachmentEvent(event);
+ const lastRun = runs[runs.length - 1];
+ if (lastRun && lastRun.isAttachmentRun === isAttachmentRun) {
+ lastRun.events.push(event);
+ } else {
+ runs.push({ isAttachmentRun, events: [event] });
+ }
+ });
+
+ return runs;
+};