diff --git a/src/app/features/app-feed/AppPostCard.css.ts b/src/app/features/app-feed/AppPostCard.css.ts
index c4f69087..d8712a22 100644
--- a/src/app/features/app-feed/AppPostCard.css.ts
+++ b/src/app/features/app-feed/AppPostCard.css.ts
@@ -68,8 +68,10 @@ export const OptionsButton = style({
});
export const CoverImage = style({
- height: '250px',
+ width: '100%',
+ maxHeight: '480px',
position: 'relative',
+ overflow: 'hidden',
background: '#eef2f5',
});
diff --git a/src/app/features/app-feed/AppPostCard.tsx b/src/app/features/app-feed/AppPostCard.tsx
index eeccc1f6..c6f559ea 100644
--- a/src/app/features/app-feed/AppPostCard.tsx
+++ b/src/app/features/app-feed/AppPostCard.tsx
@@ -22,13 +22,18 @@ import { nameInitials } from '../../utils/common';
import { mxcUrlToHttp } from '../../utils/matrix';
import { relativeTime } from '../../utils/time';
import { RenderMessageContent } from '../../components/RenderMessageContent';
-import { MImage, ImageContent } from '../../components/message';
+import { ImageContent, MText, RenderBody } from '../../components/message';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { GetContentCallback, MessageEvent } from '../../../types/matrix/room';
-import { IImageContent } from '../../../types/matrix/common';
+import {
+ IImageContent,
+ MATRIX_SPOILER_PROPERTY_NAME,
+ MATRIX_SPOILER_REASON_PROPERTY_NAME,
+} from '../../../types/matrix/common';
import { AMEN_REACTION_KEY, summarizePostReactions } from './postReactions';
import { canRemovePost } from './postOptions';
+import { getPostImageAspectRatio, hasImageCaption } from './postImage';
import { AppPostOptionsMenu } from './AppPostOptionsMenu';
import * as css from './AppPostCard.css';
@@ -119,6 +124,9 @@ export function AppPostCard({ room, event, community, focused, onOpenComments }:
const msgType = typeof content.msgtype === 'string' ? content.msgtype : undefined;
const getContent = useCallback(() => content, [content]) as GetContentCallback;
const isImagePost = msgType === MsgType.Image;
+ const imageContent = content as IImageContent;
+ const imageUrl = imageContent.file?.url ?? imageContent.url;
+ const showImageCaption = isImagePost && hasImageCaption(content);
const eventId = event.getId();
const reactionRelations = eventId
@@ -197,16 +205,35 @@ export function AppPostCard({ room, event, community, focused, onOpenComments }:
)}
- {isImagePost && (
-
-
(
- }
- renderViewer={(p) => }
+ {isImagePost && typeof imageUrl === 'string' && (
+
+ }
+ renderViewer={(p) => }
+ />
+
+ )}
+
+ {showImageCaption && (
+
+ (
+
)}
/>
diff --git a/src/app/features/app-feed/postImage.test.ts b/src/app/features/app-feed/postImage.test.ts
new file mode 100644
index 00000000..97d7f060
--- /dev/null
+++ b/src/app/features/app-feed/postImage.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { getPostImageAspectRatio, hasImageCaption } from './postImage';
+
+describe('getPostImageAspectRatio', () => {
+ it('uses the image dimensions', () => {
+ expect(getPostImageAspectRatio({ w: 1600, h: 1200 })).toBeCloseTo(4 / 3);
+ });
+
+ it('falls back to 4:3 when dimensions are missing or invalid', () => {
+ expect(getPostImageAspectRatio(undefined)).toBeCloseTo(4 / 3);
+ expect(getPostImageAspectRatio({ w: 100 })).toBeCloseTo(4 / 3);
+ expect(getPostImageAspectRatio({ w: 0, h: 100 })).toBeCloseTo(4 / 3);
+ expect(getPostImageAspectRatio({ w: '100', h: '100' })).toBeCloseTo(4 / 3);
+ });
+
+ it('clamps extreme ratios', () => {
+ expect(getPostImageAspectRatio({ w: 5000, h: 100 })).toBe(3);
+ expect(getPostImageAspectRatio({ w: 100, h: 5000 })).toBe(0.5);
+ });
+});
+
+describe('hasImageCaption', () => {
+ it('is true when body differs from filename', () => {
+ expect(hasImageCaption({ body: 'Look at this', filename: 'river.jpg' })).toBe(true);
+ });
+
+ it('is false when body is just the filename', () => {
+ expect(hasImageCaption({ body: 'river.jpg', filename: 'river.jpg' })).toBe(false);
+ });
+
+ it('is false without a filename or with an empty body', () => {
+ expect(hasImageCaption({ body: 'river.jpg' })).toBe(false);
+ expect(hasImageCaption({ body: ' ', filename: 'river.jpg' })).toBe(false);
+ });
+});
diff --git a/src/app/features/app-feed/postImage.ts b/src/app/features/app-feed/postImage.ts
new file mode 100644
index 00000000..2e11aefa
--- /dev/null
+++ b/src/app/features/app-feed/postImage.ts
@@ -0,0 +1,30 @@
+import { IContent } from 'matrix-js-sdk';
+
+const DEFAULT_ASPECT_RATIO = 4 / 3;
+// Keep extreme panoramas from collapsing into a sliver; tall images are
+// bounded by the cover's max-height instead.
+const MIN_ASPECT_RATIO = 1 / 2;
+const MAX_ASPECT_RATIO = 3;
+
+/**
+ * Width/height ratio for an image post's cover, from the image's `info`
+ * dimensions when present, clamped so odd sizes still render sensibly.
+ */
+export const getPostImageAspectRatio = (info?: { w?: unknown; h?: unknown }): number => {
+ const w = info?.w;
+ const h = info?.h;
+ if (typeof w !== 'number' || typeof h !== 'number' || w <= 0 || h <= 0) {
+ return DEFAULT_ASPECT_RATIO;
+ }
+ return Math.min(MAX_ASPECT_RATIO, Math.max(MIN_ASPECT_RATIO, w / h));
+};
+
+/**
+ * Whether an image event carries caption text: per the Matrix spec, `body` is
+ * a caption when `filename` is present and differs from it.
+ */
+export const hasImageCaption = (content: IContent): boolean =>
+ typeof content.body === 'string' &&
+ content.body.trim() !== '' &&
+ typeof content.filename === 'string' &&
+ content.filename !== content.body;