From 2607d473adac937a4c3d9b6f0a4e1ec993a882d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:28:11 +0000 Subject: [PATCH] Render feed post attachments in a horizontal scroll row A post's attachment events (image/video/audio/file) now group into one horizontally scrollable row instead of stacking vertically, so a multi-attachment post reads like a carousel. Text/caption events keep their normal stacked layout. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Gm3fUenbkKEw2B3xHkwVJe --- src/app/features/feed/FeedPostCard.tsx | 61 ++++++++++++--- .../features/feed/feedAttachmentRuns.test.ts | 77 +++++++++++++++++++ src/app/features/feed/feedAttachmentRuns.ts | 35 +++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 src/app/features/feed/feedAttachmentRuns.test.ts create mode 100644 src/app/features/feed/feedAttachmentRuns.ts 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; +};