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;