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
4 changes: 3 additions & 1 deletion src/app/features/app-feed/AppPostCard.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ export const OptionsButton = style({
});

export const CoverImage = style({
height: '250px',
width: '100%',
maxHeight: '480px',
position: 'relative',
overflow: 'hidden',
background: '#eef2f5',
});

Expand Down
51 changes: 39 additions & 12 deletions src/app/features/app-feed/AppPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -197,16 +205,35 @@ export function AppPostCard({ room, event, community, focused, onOpenComments }:
)}
</div>

{isImagePost && (
<div className={css.CoverImage}>
<MImage
content={content as IImageContent}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
{isImagePost && typeof imageUrl === 'string' && (
<div
className={css.CoverImage}
style={{ aspectRatio: getPostImageAspectRatio(imageContent.info) }}
>
<ImageContent
body={imageContent.body || 'Image'}
info={imageContent.info}
mimeType={imageContent.info?.mimetype}
url={imageUrl}
encInfo={imageContent.file}
markedAsSpoiler={imageContent[MATRIX_SPOILER_PROPERTY_NAME]}
spoilerReason={imageContent[MATRIX_SPOILER_REASON_PROPERTY_NAME]}
autoPlay
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
</div>
)}

{showImageCaption && (
<div className={css.Body}>
<MText
content={content}
renderBody={(p) => (
<RenderBody
{...p}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
/>
Expand Down
35 changes: 35 additions & 0 deletions src/app/features/app-feed/postImage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/app/features/app-feed/postImage.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading