Skip to content
Draft
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1ab31e6
Commit design update
Half-Shot Apr 7, 2026
8eea71e
Add figma links
Half-Shot Apr 7, 2026
2581a4d
Check in other changes
Half-Shot Apr 9, 2026
380c957
revert accidental change
Half-Shot Apr 9, 2026
a573cf1
Iterative update
Half-Shot Apr 9, 2026
4a4123f
linting n test fiddles
Half-Shot Apr 9, 2026
137a1b3
linting
Half-Shot Apr 9, 2026
d72c85f
Cleanup
Half-Shot Apr 9, 2026
938bbf1
update snaps
Half-Shot Apr 9, 2026
549dc68
Move URL previews to new home
Half-Shot Apr 9, 2026
854a506
Fix paths
Half-Shot Apr 9, 2026
84b77a6
compress img
Half-Shot Apr 9, 2026
f3515c0
Merge remote-tracking branch 'origin/develop' into hs/url-preview-new…
Half-Shot Apr 9, 2026
033e1f1
Add back all the stories
Half-Shot Apr 9, 2026
0c2ac8e
Improved rendering
Half-Shot Apr 9, 2026
2662a98
Fixup
Half-Shot Apr 9, 2026
1d88ebd
Update previews again
Half-Shot Apr 10, 2026
c77ecb7
lint
Half-Shot Apr 10, 2026
741ef2b
update stories
Half-Shot Apr 10, 2026
750398c
Update snaps again
Half-Shot Apr 10, 2026
a174ca9
More screenshots
Half-Shot Apr 10, 2026
5d93568
Also these
Half-Shot Apr 10, 2026
2b6e8fb
Update snaps
Half-Shot Apr 10, 2026
bc0df03
include site name
Half-Shot Apr 10, 2026
a9097fa
Update snaps again
Half-Shot Apr 10, 2026
8e4c0fa
Use a scale so the images don't go blur
Half-Shot Apr 10, 2026
b10fc9f
update snaps again
Half-Shot Apr 10, 2026
5f45b84
Update snaps
Half-Shot Apr 14, 2026
52f4915
Merge remote-tracking branch 'origin/develop' into hs/url-preview-new…
Half-Shot Apr 14, 2026
68ce33c
remove mistaken playwright cfg
Half-Shot Apr 14, 2026
bf75fc7
update pw snaps
Half-Shot Apr 14, 2026
52db36e
update snap
Half-Shot Apr 14, 2026
9717112
Initial work for file uploader exts
Half-Shot Apr 14, 2026
bcf8c45
Merge branch 'hs/url-preview-new-design' into hs/file-uploader-extens…
Half-Shot Apr 14, 2026
29cec1b
More bits
Half-Shot Apr 21, 2026
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
1 change: 1 addition & 0 deletions apps/web/playwright/e2e/messages/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
"og:title": "A simple site",
"og:description": "And with a brief description",
"og:image": mxc,
"og:image:alt": "The riot logo",
},
});
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions apps/web/src/PosthogTrackers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
import { type UrlPreview } from "@element-hq/web-shared-components";

import PageType from "./PageTypes";
import Views from "./Views";
Expand Down Expand Up @@ -151,7 +150,7 @@ export default class PosthogTrackers {
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
* @param previews The previews generated from the event.
*/
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
// Discount any previews that we have already tracked.
if (this.previewedEventIds.get(eventId)) {
return;
Expand Down
64 changes: 42 additions & 22 deletions apps/web/src/components/views/rooms/MessageComposerButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
StickerIcon,
TextFormattingIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { ComposorApiFileUploadLocal } from "@element-hq/element-web-module-api";
import { MultiOptionButton } from "@element-hq/web-shared-components";

import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
Expand All @@ -43,6 +45,7 @@ import { filterBoolean } from "../../../utils/arrays";
import { useSettingValue } from "../../../hooks/useSettings";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { ModuleApi } from "../../../modules/Api.ts";

interface IProps {
addEmoji: (emoji: string) => boolean;
Expand Down Expand Up @@ -89,7 +92,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
),
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
<UploadButton key="uploads" />, // props passed via UploadButtonContext
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
Expand All @@ -106,7 +109,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
) : (
emojiButton(props)
),
uploadButton(), // props passed via UploadButtonContext
<UploadButton key="uploads" />, // props passed via UploadButtonContext
];
moreButtons = [
showStickersButton(props),
Expand Down Expand Up @@ -164,9 +167,43 @@ function emojiButton(props: IProps): ReactElement {
);
}

function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const onLocalUploadClick = useContext(UploadButtonContext);
const { room } = useScopedRoomContext("room");

const onLocalClick = (): void => {
onLocalUploadClick?.();
overflowMenuCloser?.(); // close overflow menu
};

const options = [...ModuleApi.instance.composor.fileUploadOptions.values()].map((uploadOption) => {
if (uploadOption.type === ComposorApiFileUploadLocal) {
return {
icon: AttachmentIcon,
label: _t("common|attachment"),
onSelect: onLocalClick,
};
} else {
return {
icon: uploadOption.icon,
label: uploadOption.label,
onSelect: () => {
uploadOption.onSelected(room!.roomId, (res) => {
console.log("Do something with the result", res);
});
},
};
}
});

return (
<MultiOptionButton
options={options}
multipleOptionsButton={{ label: _t("action|upload_file"), icon: AttachmentIcon }}
/>
);
};

type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(null);
Expand Down Expand Up @@ -234,23 +271,6 @@ const UploadButtonContextProvider: React.FC<IUploadButtonProps> = ({ roomId, rel
);
};

// Must be rendered within an UploadButtonContextProvider
const UploadButton: React.FC = () => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const uploadButtonFn = useContext(UploadButtonContext);

const onClick = (): void => {
uploadButtonFn?.();
overflowMenuCloser?.(); // close overflow menu
};

return (
<CollapsibleButton className="mx_MessageComposer_button" onClick={onClick} title={_t("common|attachment")}>
<AttachmentIcon />
</CollapsibleButton>
);
};

function showStickersButton(props: IProps): ReactElement | null {
return props.showStickersButton ? (
<CollapsibleButton
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
import { WidgetApi } from "./WidgetApi.ts";
import { CustomisationsApi } from "./customisationsApi.ts";
import { ComposorApi } from "./ComposorApi.ts";

const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
Expand Down Expand Up @@ -94,6 +95,7 @@ export class ModuleApi implements Api {
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public readonly stores = new StoresApi();
public readonly composor = new ComposorApi();

public createRoot(element: Element): Root {
return createRoot(element);
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/modules/ComposorApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import AttachmentIcon from "@vector-im/compound-design-tokens/assets/web/icons/attachment";
import {
ComposorApiFileUploadLocal,
type ComposorApiFileUploadOption,
type ComposorApi as ModuleComposorApi,
} from "@element-hq/element-web-module-api";

import { _t } from "../languageHandler";

export class ComposorApi implements ModuleComposorApi {
public readonly ComposorApiFileUploadLocal: string = ComposorApiFileUploadLocal;
public readonly fileUploadOptions: Map<string, ComposorApiFileUploadOption> = new Map();

public constructor() {
this.fileUploadOptions.set(ComposorApiFileUploadLocal, {
type: ComposorApiFileUploadLocal,
icon: AttachmentIcon as any,
label: _t("common|attachment"),
onSelected: () => {
// TODO: Fill in
},
});
}

public addFileUploadOption(option: ComposorApiFileUploadOption): void {
if (this.fileUploadOptions.has(option.type)) {
throw new Error(`Another module has already registered "${option.type}"`);
}
this.fileUploadOptions.set(option.type, option);
}
public disableFileUploadOption(type: string): boolean {
return this.fileUploadOptions.delete(type);
}
}
103 changes: 85 additions & 18 deletions apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export interface UrlPreviewGroupViewModelProps {
}

export const MAX_PREVIEWS_WHEN_LIMITED = 2;
export const PREVIEW_WIDTH = 100;
export const PREVIEW_HEIGHT = 100;
export const PREVIEW_WIDTH_PX = 478;
export const PREVIEW_HEIGHT_PX = 200;
export const MIN_PREVIEW_PX = 96;
export const MIN_IMAGE_SIZE_BYTES = 8192;

export enum PreviewVisibility {
/**
Expand Down Expand Up @@ -100,28 +102,77 @@ export class UrlPreviewGroupViewModel
typeof response["og:description"] === "string" && response["og:description"].trim()
? response["og:description"].trim()
: undefined;
let siteName =
const siteName =
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
? response["og:site_name"].trim()
: undefined;
: new URL(link).hostname;

// If there is no title, use the description as the title.
if (!title && description) {
title = description;
description = undefined;
} else if (!title && siteName) {
title = siteName;
siteName = undefined;
} else if (!title) {
title = link;
}

// If the description matches the site name, don't bother with a description.
if (description && description.toLowerCase() === siteName.toLowerCase()) {
description = undefined;
}

return {
title,
description: description && decode(description),
siteName,
};
}

/**
* Calculate the best possible author from an opengraph response.
* @param response The opengraph response
* @returns The author value, or undefined if no valid author could be found.
*/
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
let calculatedAuthor: string | undefined;
if (response["og:type"] === "article") {
if (typeof response["article:author"] === "string" && response["article:author"]) {
calculatedAuthor = response["article:author"];
}
// Otherwise fall through to check the profile.
}
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
calculatedAuthor = response["profile:username"];
}
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
return;
}
return calculatedAuthor;
}

/**
* Calculate whether the provided image from the preview response is an full size preview or
* a site icon.
* @returns `true` if the image should be used as a preview, otherwise `false`
*/
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
// 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 dimensions & size of the file and
// deciding whether to render it as a full preview or icon.
if (width && width < MIN_PREVIEW_PX) {
return false;
}
if (height && height < MIN_PREVIEW_PX) {
return false;
}
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
return false;
}
return true;
}

/**
* Determine if an anchor element can be rendered into a preview.
* If it can, return the value of `href`
Expand Down Expand Up @@ -278,38 +329,54 @@ export class UrlPreviewGroupViewModel
}

const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
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.
if (title === link && !hasImage) {
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"]);
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;

const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
if (isImagePreview) {
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
const height =
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale");
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
// No thumb, no preview.
if (thumb) {
image = {
imageThumb: thumb,
imageFull: media.srcHttp ?? thumb,
width,
height,
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
alt,
playable,
};
}
} else if (media.srcHttp) {
siteIcon = media.srcHttp;
}
}

const result = {
link,
title,
author,
description,
siteName,
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
siteIcon,
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
image,
} satisfies UrlPreview;
this.previewCache.set(link, result);
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/viewmodels/room/UploaderViewModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// /*
// * Copyright 2026 Element Creations Ltd.
// *
// * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
// * Please see LICENSE files in the repository root for full details.
// */

// import { BaseViewModel } from "@element-hq/web-shared-components";
// import { ComposorApi } from "../../modules/ComposorApi";

// interface UploaderViewSnapshot {
// primaryOption: {
// label: string;
// icon: string;
// }
// }

// export class UploaderViewModel extends BaseViewModel<UploaderViewSnapshot, never> {
// public constructor(moduleComposorApi: ComposorApi) {
// moduleComposorApi.
// super()
// }
// }
14 changes: 2 additions & 12 deletions apps/web/test/unit-tests/PosthogTrackers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
const tracker = new PosthogTrackers();
tracker.trackUrlPreview("$123456", false, [
{
title: "A preview",
image: {
imageThumb: "abc",
imageFull: "abc",
},
link: "a-link",
},
]);
tracker.trackUrlPreview("$123456", false, [
{
title: "A second preview",
link: "a-link",
image: {},
},
]);
tracker.trackUrlPreview("$123456", false, [{}]);
// Ignores subsequent calls.
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "UrlPreviewRendered",
Expand Down
Loading
Loading