Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 49 additions & 12 deletions src/app/features/feed/FeedPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -225,17 +238,41 @@ export function FeedPostCard({
dateFormatString={dateFormatString}
/>
</Box>
{events.map((event) => (
<FeedPostEventBody
key={event.getId()}
event={event}
displayName={displayName}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
))}
{groupFeedEventRuns(events).map((run) => {
const body = (event: MatrixEvent) => (
<FeedPostEventBody
key={event.getId()}
event={event}
displayName={displayName}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);

if (run.isAttachmentRun && run.events.length > 1) {
return (
<Scroll
key={run.events[0].getId()}
direction="Horizontal"
size="0"
visibility="Hover"
hideTrack
>
<Box gap="200">
{run.events.map((event) => (
<Box key={event.getId()} direction="Column" shrink="No">
{body(event)}
</Box>
))}
</Box>
</Scroll>
);
}

return run.events.map((event) => body(event));
})}
{eventId && (
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Box alignItems="Center" gap="200" wrap="Wrap">
Expand Down
77 changes: 77 additions & 0 deletions src/app/features/feed/feedAttachmentRuns.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
35 changes: 35 additions & 0 deletions src/app/features/feed/feedAttachmentRuns.ts
Original file line number Diff line number Diff line change
@@ -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;
};