From 1ab31e6f48018361796f981b5bb34fc6d6b5b620 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Apr 2026 14:54:05 +0100 Subject: [PATCH] Commit design update --- .../message-body/UrlPreviewGroupViewModel.ts | 66 ++++++++--- .../LinkPreview/LinkPreview.module.css | 105 +++++++++++------ .../LinkPreview/LinkPreview.stories.tsx | 61 +++++++++- .../LinkPreview/LinkPreview.tsx | 110 +++++++++++++----- .../event-tiles/UrlPreviewGroupView/types.ts | 15 ++- .../shared-components/static/wideImage.png | Bin 0 -> 902527 bytes 6 files changed, 279 insertions(+), 78 deletions(-) create mode 100644 packages/shared-components/static/wideImage.png diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 6d42baf2d5..4e3eb54633 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -34,8 +34,9 @@ export interface UrlPreviewGroupViewModelProps { } export const MAX_PREVIEWS_WHEN_LIMITED = 2; -export const PREVIEW_WIDTH = 100; -export const PREVIEW_HEIGHT = 100; +export const PREVIEW_WIDTH = 478; +export const PREVIEW_HEIGHT = 200; +export const MIN_IMAGE_SIZE_BYTES = 8192; export enum PreviewVisibility { /** @@ -122,6 +123,27 @@ export class UrlPreviewGroupViewModel }; } + /** + * Calculate the best possible title from an opengraph response. + * @param response The opengraph response + * @param link The link being used to preview. + * @returns The title value. + */ + private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] { + if (response["og:type"] === "article") { + if (typeof response["article:author"] === "string" && response["article:author"]) { + return { + name: response["article:author"], + }; + } + } + if (typeof response["profile:username"] === "string" && response["profile:username"]) { + return { + name: response["profile:username"], + }; + } + } + /** * Determine if an anchor element can be rendered into a preview. * If it can, return the value of `href` @@ -278,6 +300,8 @@ export class UrlPreviewGroupViewModel } const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link); + const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview); + const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"]; const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string"; // Ensure we have something relevant to render. // The title must not just be the link, or we must have an image. @@ -285,32 +309,46 @@ export class UrlPreviewGroupViewModel return null; } let image: UrlPreview["image"]; + let siteIcon: string | undefined; if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) { const media = mediaFromMxc(preview["og:image"], this.client); const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]); const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]); - const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH); - const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH; - const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale"); - // No thumb, no preview. - if (thumb) { - image = { - imageThumb: thumb, - imageFull: media.srcHttp ?? thumb, - width, - height, - fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), - }; + const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]); + + // We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix + // have a clear distinction, so we're using a heuristic here to check the size of the file and + // deciding whether to render it as a full preview or icon. + const isIcon = imageSize && imageSize < MIN_IMAGE_SIZE_BYTES; + if (!isIcon) { + const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH); + const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH; + const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale"); + // No thumb, no preview. + if (thumb) { + image = { + imageThumb: thumb, + imageFull: media.srcHttp ?? thumb, + width, + height, + fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), + }; + } + } else if (media.srcHttp) { + siteIcon = media.srcHttp; } } const result = { link, title, + author, description, siteName, + siteIcon, showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(), image, + playable, } satisfies UrlPreview; this.previewCache.set(link, result); return result; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css index 86e67b73c7..0ce030c85b 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css +++ b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css @@ -6,51 +6,75 @@ */ .thumbnail { - /* Thumbnails are always limited to a maximum of 100px */ - max-width: 100px; - max-height: 100px; + /* Thumbnails are always limited to a maxiumum of 100px */ + max-height: 200px; /* Ensure we don't stretch the image */ object-fit: cover; } +.preview { + display: flex; + position: relative; + width: 100%; + height: 200px; + background-size: cover; + background-position: center; + border: none; + .playButton[data-kind="primary"] { + padding: 0; + width: 50px; + height: 50px; + margin: auto; + color: var(--cpd-color-gray-800); + > svg { + margin: auto; + border-radius: 50px; + color: var(--cpd-color-gray-400); + } + } +} + .link { color: var(--cpd-color-text-link-external); text-decoration-line: none; + width: 100%; + height: 50px; + aspect-ratio: 1; /* will make width equal to height (500px container) */ + object-fit: cover; /* use the one you need */ + border-radius: 6px; + margin-top: auto; + margin-bottom: auto; } .container { - display: inline flex; - column-gap: var(--cpd-space-1x); - border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); - border-radius: 2px; + max-width: 478px; + display: flex; + border: 1px solid var(--cpd-color-border-interactive-secondary); + border-radius: 12px; /* Get radius from cpd */ + flex-direction: column; color: var(--cpd-color-gray-900); + overflow: clip; - .wrapImageCaption { - display: inline-flex; + &.inline { flex-direction: row; - flex-wrap: wrap; - row-gap: var(--cpd-space-2x); - flex: 1; + + .siteAvatar { + margin: auto var(--cpd-space-2x); + } + } + + .textContent { + padding: var(--cpd-space-3x) var(--cpd-space-4x); + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); } - .image, .caption { display: inline-flex; flex-direction: column; - margin-inline-start: var(--cpd-space-4x); min-width: 0; /* Prevent blowout */ } - - .image { - /* Clear default - ); + if (preview.playable) { + img = ( +
+ {preview.playable && ( + + )} +
+ ); + } else { + img = ( +