diff --git a/apps/web/playwright/e2e/messages/messages.spec.ts b/apps/web/playwright/e2e/messages/messages.spec.ts index 67af9edb425..2115dc3394b 100644 --- a/apps/web/playwright/e2e/messages/messages.spec.ts +++ b/apps/web/playwright/e2e/messages/messages.spec.ts @@ -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", }, }); }); diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png index 6d004c8da21..6b9f5527321 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-basic-linux.png differ diff --git a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png index aa36fca9e92..b398e480c7d 100644 Binary files a/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png and b/apps/web/playwright/snapshots/messages/messages.spec.ts/preview-with-thumb-linux.png differ diff --git a/apps/web/res/css/_common.pcss b/apps/web/res/css/_common.pcss index 4cadbe71c64..a6722ee6f50 100644 --- a/apps/web/res/css/_common.pcss +++ b/apps/web/res/css/_common.pcss @@ -598,6 +598,9 @@ legend { .mx_AccessSecretStorageDialog button, .mx_InviteDialog_section button, .mx_InviteDialog_editor button, + .mx_ModuleOuterDialog button, + [data-kind|="primary"], + [data-kind|="secondary"], [class|="maplibregl"] ), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton), @@ -620,11 +623,14 @@ legend { button:not( .mx_Dialog_nonDialogButton, [class|="maplibregl"], + [data-kind|="primary"], + [data-kind|="secondary"], .mx_AccessibleButton, .mx_UserProfileSettings button, .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, + .mx_ModuleOuterDialog button, .mx_EncryptionUserSettingsTab button ):last-child { margin-right: 0px; @@ -634,6 +640,9 @@ legend { button:not( .mx_Dialog_nonDialogButton, [class|="maplibregl"], + [data-kind|="primary"], + [data-kind|="secondary"], + .mx_ModuleOuterDialog button, .mx_AccessibleButton, .mx_UserProfileSettings button, .mx_ThemeChoicePanel_CustomTheme button, @@ -659,6 +668,7 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, + .mx_ModuleOuterDialog button, .mx_EncryptionUserSettingsTab button ), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { @@ -678,6 +688,7 @@ legend { .mx_ThemeChoicePanel_CustomTheme button, .mx_UnpinAllDialog button, .mx_ShareDialog button, + .mx_ModuleOuterDialog button, .mx_EncryptionUserSettingsTab button ), .mx_Dialog_buttons input[type="submit"].danger { @@ -695,7 +706,9 @@ legend { .mx_Dialog button:not( .mx_Dialog_nonDialogButton, - [class|="maplibregl"], + [data-kind|="primary"], + [data-kind|="secondary"], + .mx_ModuleOuterDialog button, .mx_AccessibleButton, .mx_UserProfileSettings button, .mx_ThemeChoicePanel_CustomTheme button, diff --git a/apps/web/src/PosthogTrackers.ts b/apps/web/src/PosthogTrackers.ts index cc531bde9b3..19bb900b7c7 100644 --- a/apps/web/src/PosthogTrackers.ts +++ b/apps/web/src/PosthogTrackers.ts @@ -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"; @@ -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; diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 06c843f1907..f83b9b9ea43 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -38,7 +38,10 @@ import { RecordingState } from "../../../audio/VoiceRecording"; import type ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from "../../../utils/ShieldUtils"; import SendMessageComposer, { type SendMessageComposer as SendMessageComposerClass } from "./SendMessageComposer"; -import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import { + IComposerInsertEventContent, + type ComposerInsertPayload, +} from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; import type EditorModel from "../../../editor/model"; import UIStore, { UI_EVENTS } from "../../../stores/UIStore"; @@ -54,6 +57,7 @@ import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/M import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; +import type { ComposerExtraContentPreview } from "@element-hq/element-web-module-api"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -101,6 +105,7 @@ interface IState { isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; + extraEventContent: Map; renderer: ComposerExtraContentPreview }>; } type WysiwygComposerState = { @@ -152,6 +157,7 @@ export class MessageComposer extends React.Component { isWysiwygLabEnabled: isWysiwygLabEnabled, isRichTextEnabled: isRichTextEnabled, initialComposerContent: initialComposerContent, + extraEventContent: new Map(), }; this.instanceId = instanceCount++; @@ -276,6 +282,22 @@ export class MessageComposer extends React.Component { } break; + case Action.ComposerInsert: { + const composerInsertPayload = payload as IComposerInsertEventContent; + if ( + !composerInsertPayload.eventContent || + !composerInsertPayload.key || + !composerInsertPayload.previewRenderable + ) { + this.setState((s) => { + s.extraEventContent.set(composerInsertPayload.key, { + content: composerInsertPayload.eventContent, + renderer: composerInsertPayload.previewRenderable, + }); + }); + } + break; + } case Action.SettingUpdated: { const settingUpdatedPayload = payload as SettingUpdatedPayload; switch (settingUpdatedPayload.settingName) { @@ -526,6 +548,17 @@ export class MessageComposer extends React.Component { } }; + private readonly onExtraContentChange = (key: string, newContent: Record | null): void => { + this.setState((s) => { + if (newContent === null) { + s.extraEventContent.delete(key); + } else { + s.extraEventContent.set(key, { ...s.extraEventContent.get(key)!, content: newContent }); + } + return s; + }); + }; + public render(): React.ReactNode { let leftIcon: false | JSX.Element = false; if (!this.state.isWysiwygLabEnabled) { diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx index 2221bf7592d..8dd7e195719 100644 --- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx @@ -23,6 +23,8 @@ import { StickerIcon, TextFormattingIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { ComposerApiFileUploadLocal } from "@element-hq/element-web-module-api"; +import { MultiOptionButton } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import { CollapsibleButton } from "./CollapsibleButton"; @@ -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; @@ -89,7 +92,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ), ]; moreButtons = [ - uploadButton(), // props passed via UploadButtonContext + , // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), props.showPollsButton ? pollButton(room, props.relation) : null, @@ -106,7 +109,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ) : ( emojiButton(props) ), - uploadButton(), // props passed via UploadButtonContext + , // props passed via UploadButtonContext ]; moreButtons = [ showStickersButton(props), @@ -164,9 +167,43 @@ function emojiButton(props: IProps): ReactElement { ); } -function uploadButton(): ReactElement { - return ; -} +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.composer.fileUploadOptions.values()].map((uploadOption) => { + if (uploadOption.type === ComposerApiFileUploadLocal) { + 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 ( + + ); +}; type UploadButtonFn = () => void; export const UploadButtonContext = createContext(null); @@ -234,23 +271,6 @@ const UploadButtonContextProvider: React.FC = ({ 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 ( - - - - ); -}; - function showStickersButton(props: IProps): ReactElement | null { return props.showStickersButton ? ( , ): RoomMessageEventContent { const isEmote = containsEmote(model); if (isEmote) { @@ -85,6 +86,7 @@ export function createMessageContent( const content: RoomMessageEventContent = { msgtype: isEmote ? MsgType.Emote : MsgType.Text, body: body, + ...extraEventContent, }; const formattedBody = htmlSerializeIfNeeded(model, { useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), @@ -130,6 +132,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { disabled?: boolean; onChange?(model: EditorModel): void; toggleStickerPickerOpen: () => void; + extraEventContent?: Record; } export class SendMessageComposer extends React.Component { @@ -160,6 +163,9 @@ export class SendMessageComposer extends React.Component this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!), this.props.mxClient, ); + // Clear existing links + this.setState({ extraEventContent: new Map() }); if (replyToEvent) { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. @@ -638,6 +647,7 @@ export class SendMessageComposer extends React.Component { this.editorRef.current?.focus(); }; + 1; public render(): React.ReactNode { const threadId = diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts index 9712a8303a0..db12d581cdb 100644 --- a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import { type ActionPayload } from "../payloads"; import { type Action } from "../actions"; import { type TimelineRenderingType } from "../../contexts/RoomContext"; +import { ComposerExtraContentPreview } from "@element-hq/element-web-module-api"; export enum ComposerType { Send = "send", @@ -29,4 +30,13 @@ interface IComposerInsertPlaintextPayload extends IBaseComposerInsertPayload { text: string; } -export type ComposerInsertPayload = IComposerInsertMentionPayload | IComposerInsertPlaintextPayload; +export interface IComposerInsertEventContent> extends IBaseComposerInsertPayload { + key: string; + previewRenderable: ComposerExtraContentPreview; + eventContent: T; +} + +export type ComposerInsertPayload = + | IComposerInsertMentionPayload + | IComposerInsertPlaintextPayload + | IComposerInsertEventContent; diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index bb3c7497d52..21fec57eca3 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -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 { ComposerApi } from "./ComposerApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -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 composer = new ComposerApi(); public createRoot(element: Element): Root { return createRoot(element); diff --git a/apps/web/src/modules/ComposerApi.ts b/apps/web/src/modules/ComposerApi.ts new file mode 100644 index 00000000000..e77c701f236 --- /dev/null +++ b/apps/web/src/modules/ComposerApi.ts @@ -0,0 +1,69 @@ +/* +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 { + ComposerApiFileUploadLocal, + type ComposerExtraContentPreview, + type ComposerApiFileUploadOption, + type ComposerApi as ModuleComposerApi, +} from "@element-hq/element-web-module-api"; + +import { _t } from "../languageHandler"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { Action } from "../dispatcher/actions"; +import type { ComposerInsertPayload, IComposerInsertEventContent } from "../dispatcher/payloads/ComposerInsertPayload"; +import { TimelineRenderingType } from "../contexts/RoomContext"; + +export class ComposerApi implements ModuleComposerApi { + public readonly ComposerApiFileUploadLocal = ComposerApiFileUploadLocal; + public readonly fileUploadOptions: Map = new Map(); + + public constructor() { + this.fileUploadOptions.set(ComposerApiFileUploadLocal, { + type: ComposerApiFileUploadLocal, + icon: AttachmentIcon as any, + label: _t("common|attachment"), + onSelected: () => { + // TODO: Fill in + }, + }); + } + + public addFileUploadOption(option: ComposerApiFileUploadOption): 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); + } + + public insertTextIntoComposer(text: string): void { + defaultDispatcher.dispatch({ + action: Action.ComposerInsert, + text, + timelineRenderingType: TimelineRenderingType.Room, + } satisfies ComposerInsertPayload); + } + + public insertEventContentIntoComposer( + key: string, + eventContent: T, + previewRenderable: ComposerExtraContentPreview, + ): void { + defaultDispatcher.dispatch({ + action: Action.ComposerInsert, + key, + eventContent, + previewRenderable, + timelineRenderingType: TimelineRenderingType.Room, + } satisfies IComposerInsertEventContent); + } +} diff --git a/apps/web/src/modules/Dialog.tsx b/apps/web/src/modules/Dialog.tsx index ac1f7ae442f..b419283e50c 100644 --- a/apps/web/src/modules/Dialog.tsx +++ b/apps/web/src/modules/Dialog.tsx @@ -25,7 +25,7 @@ const OuterDialog = ({ const close = useCallback(() => onFinished(false, null), [onFinished]); const submit = useCallback((model: M) => onFinished(true, model), [onFinished]); return ( - + ); diff --git a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts index 6d42baf2d57..6cafe3b5af6 100644 --- a/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts +++ b/apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts @@ -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 { /** @@ -100,21 +102,26 @@ 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), @@ -122,6 +129,50 @@ export class UrlPreviewGroupViewModel }; } + /** + * 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` @@ -278,6 +329,7 @@ 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. @@ -285,31 +337,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"]); + 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); diff --git a/apps/web/src/viewmodels/room/UploaderViewModel.ts b/apps/web/src/viewmodels/room/UploaderViewModel.ts new file mode 100644 index 00000000000..9d33856f684 --- /dev/null +++ b/apps/web/src/viewmodels/room/UploaderViewModel.ts @@ -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 { ComposerApi } from "../../modules/ComposerApi"; + +// interface UploaderViewSnapshot { +// primaryOption: { +// label: string; +// icon: string; +// } +// } + +// export class UploaderViewModel extends BaseViewModel { +// public constructor(moduleComposerApi: ComposerApi) { +// moduleComposerApi. +// super() +// } +// } diff --git a/apps/web/test/unit-tests/PosthogTrackers-test.ts b/apps/web/test/unit-tests/PosthogTrackers-test.ts index c835ecacd08..a8904e4080a 100644 --- a/apps/web/test/unit-tests/PosthogTrackers-test.ts +++ b/apps/web/test/unit-tests/PosthogTrackers-test.ts @@ -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", diff --git a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts index 782cc8d7eed..9541e999a88 100644 --- a/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts +++ b/apps/web/test/viewmodels/message-body/UrlPreviewGroupViewModel-test.ts @@ -125,6 +125,32 @@ describe("UrlPreviewGroupViewModel", () => { await vm.updateEventElement(msg); expect(vm.getSnapshot()).toMatchSnapshot(); }); + it.each>([ + { "matrix:image:size": 8191 }, + { "og:image:width": 95 }, + { "og:image:height": 95 }, + ])("should preview a URL with a site icon", async (extraResp) => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ + "og:title": "This is an example!", + "og:type": "document", + "og:url": "https://example.org", + "og:image": IMAGE_MXC, + "og:image:height": 128, + "og:image:width": 128, + "matrix:image:size": 8193, + ...extraResp, + }); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url) => { + expect(url).toEqual(IMAGE_MXC); + return "https://example.org/image/src"; + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy(); + }); it("should ignore media when mediaVisible is false", async () => { const { vm, client } = getViewModel({ mediaVisible: false, visible: true }); client.getUrlPreview.mockResolvedValueOnce({ @@ -200,6 +226,41 @@ describe("UrlPreviewGroupViewModel", () => { expect(vm.getSnapshot()).toMatchSnapshot(); }); + describe("calculates author", () => { + it("should use the profile:username if provided", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].author).toEqual("my username"); + }); + it("should use author if the og:type is an article", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:type": "article", + "article:author": "my name", + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].author).toEqual("my name"); + }); + it("should NOT use author if the author is a URL", async () => { + const { vm, client } = getViewModel(); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:type": "article", + "article:author": "https://junk.example.org/foo", + }); + const msg = document.createElement("div"); + msg.innerHTML = 'Test'; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].author).toBeUndefined(); + }); + }); + it.each([ { text: "", href: "", hasPreview: false }, { text: "test", href: "noprotocol.example.org", hasPreview: false }, @@ -232,7 +293,7 @@ describe("UrlPreviewGroupViewModel", () => { // API *may* return a string, so check we parse correctly. "og:image:height": "500" as unknown as number, "og:image:width": 500, - "matrix:image:size": 1024, + "matrix:image:size": 10000, "og:image": IMAGE_MXC, }, ])("handles different kinds of opengraph responses %s", async (og) => { @@ -251,4 +312,25 @@ describe("UrlPreviewGroupViewModel", () => { await vm.updateEventElement(msg); expect(vm.getSnapshot().previews[0]).toMatchSnapshot(); }); + + it.each(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => { + const { vm, client } = getViewModel(); + // eslint-disable-next-line no-restricted-properties + client.mxcUrlToHttp.mockImplementation((url, width) => { + expect(url).toEqual(IMAGE_MXC); + if (width) { + return "https://example.org/image/thumb"; + } + return "https://example.org/image/src"; + }); + client.getUrlPreview.mockResolvedValueOnce({ + ...BASIC_PREVIEW_OGDATA, + "og:image": IMAGE_MXC, + [property]: "anything", + }); + const msg = document.createElement("div"); + msg.innerHTML = `test`; + await vm.updateEventElement(msg); + expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true); + }); }); diff --git a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap index 1f412d86cbe..e910d1b00d2 100644 --- a/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap +++ b/apps/web/test/viewmodels/message-body/__snapshots__/UrlPreviewGroupViewModel-test.ts.snap @@ -2,61 +2,73 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = ` { + "author": undefined, "description": undefined, "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", "title": "A description", } `; exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = ` { + "author": undefined, "description": undefined, "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "Site name", "title": "Site name", } `; exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = ` { + "author": undefined, "description": undefined, "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", "title": "Basic title", } `; exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = ` { + "author": undefined, "description": undefined, "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, "siteName": "Cool site", "title": "Cool blog", } `; -exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = ` +exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 10000,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = ` { + "author": undefined, "description": undefined, "image": { - "fileSize": 1024, - "height": 100, + "alt": undefined, + "fileSize": 10000, + "height": 478, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", - "width": 100, + "playable": false, + "width": 478, }, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", "title": "Media test", } `; @@ -67,10 +79,12 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa "overPreviewLimit": false, "previews": [ { + "author": undefined, "description": "This is a description", "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, "siteName": "Example.org", "title": "This is an example!", }, @@ -96,10 +110,12 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us "overPreviewLimit": false, "previews": [ { + "author": undefined, "description": "This is a description", "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, "siteName": "Example.org", "title": "This is an example!", }, @@ -135,11 +151,13 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false "overPreviewLimit": false, "previews": [ { + "author": undefined, "description": undefined, "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", "title": "This is an example!", }, ], @@ -154,17 +172,21 @@ exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = ` "overPreviewLimit": false, "previews": [ { + "author": undefined, "description": undefined, "image": { + "alt": undefined, "fileSize": 10000, - "height": 100, + "height": 128, "imageFull": "https://example.org/image/src", "imageThumb": "https://example.org/image/thumb", - "width": 100, + "playable": false, + "width": 128, }, "link": "https://example.org", - "showTooltipOnLink": undefined, - "siteName": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, + "siteName": "example.org", "title": "This is an example!", }, ], @@ -179,10 +201,12 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = ` "overPreviewLimit": false, "previews": [ { + "author": undefined, "description": "This is a description", "image": undefined, "link": "https://example.org", - "showTooltipOnLink": undefined, + "showTooltipOnLink": false, + "siteIcon": undefined, "siteName": "Example.org", "title": "This is an example!", }, diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index 8632818f5cc..882e8690edf 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -1,546 +1,586 @@ -## API Report File for "@element-hq/element-web-module-api" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { ComponentType } from 'react'; -import { IWidget } from 'matrix-widget-api'; -import { JSX } from 'react'; -import { ModuleApi } from '@matrix-org/react-sdk-module-api'; -import { ReactNode } from 'react'; -import { Root } from 'react-dom/client'; -import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; - -// @public -export interface AccountAuthApiExtension { - overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; -} - -// @public -export interface AccountAuthInfo { - accessToken: string; - deviceId: string; - homeserverUrl: string; - refreshToken?: string; - userId: string; -} - -// @public -export interface AccountDataApi { - delete(eventType: string): Promise; - get(eventType: string): Watchable; - set(eventType: string, content: unknown): Promise; -} - -// @alpha @deprecated (undocumented) -export interface AliasCustomisations { - // (undocumented) - getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; -} - -// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyModuleApiExtension" which is marked as @alpha -// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyCustomisationsApiExtension" which is marked as @alpha -// -// @public -export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { - // @alpha - readonly builtins: BuiltinsApi; - readonly client: ClientApi; - readonly config: ConfigApi; - createRoot(element: Element): Root; - // @alpha - readonly customComponents: CustomComponentsApi; - // @alpha - readonly customisations: CustomisationsApi; - // @alpha - readonly extras: ExtrasApi; - readonly i18n: I18nApi; - readonly navigation: NavigationApi; - readonly rootNode: HTMLElement; - readonly stores: StoresApi; - // @alpha - readonly widget: WidgetApi; - // @alpha - readonly widgetLifecycle: WidgetLifecycleApi; -} - -// @alpha -export interface BuiltinsApi { - renderNotificationDecoration(roomId: string): React.ReactNode; - renderRoomAvatar(roomId: string, size?: string): React.ReactNode; - renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; -} - -// @alpha -export type CapabilitiesApprover = (widget: WidgetDescriptor, requestedCapabilities: Set) => MaybePromise | undefined>; - -// @alpha @deprecated (undocumented) -export interface ChatExportCustomisations { - getForceChatExportParameters(): { - format?: ExportFormat; - range?: ExportType; - numberOfMessages?: number; - includeAttachments?: boolean; - sizeMb?: number; - }; -} - -// @public -export interface ClientApi { - accountData: AccountDataApi; - getRoom: (id: string) => Room | null; -} - -// @alpha @deprecated (undocumented) -export interface ComponentVisibilityCustomisations { - shouldShowComponent?(component: "UIComponent.sendInvites" | "UIComponent.roomCreation" | "UIComponent.spaceCreation" | "UIComponent.exploreRooms" | "UIComponent.addIntegrations" | "UIComponent.filterContainer" | "UIComponent.roomOptionsMenu"): boolean; -} - -// @public -export interface Config { - // (undocumented) - brand: string; -} - -// @public -export interface ConfigApi { - // (undocumented) - get(): Config; - // (undocumented) - get(key: K): Config[K]; - // (undocumented) - get(key?: K): Config | Config[K]; -} - -// @alpha -export type Container = "top" | "right" | "center"; - -// @alpha -export interface CustomComponentsApi { - registerLoginComponent(renderer: CustomLoginRenderFunction): void; - registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; - registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; -} - -// @alpha -export interface CustomisationsApi { - registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; -} - -// @alpha -export type CustomLoginComponentProps = { - serverConfig: CustomLoginComponentPropsServerConfig; - fragmentAfterLogin?: string; - children?: ReactNode; - onLoggedIn(data: AccountAuthInfo): void; - onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; -}; - -// @alpha -export interface CustomLoginComponentPropsServerConfig { - hsName: string; - hsUrl: string; -} - -// @alpha -export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; - -// @alpha -export type CustomMessageComponentProps = { - mxEvent: MatrixEvent; -}; - -// @alpha -export type CustomMessageRenderFunction = ( -props: CustomMessageComponentProps, -originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element) => JSX.Element; - -// @alpha -export type CustomMessageRenderHints = { - allowEditingEvent?: boolean; - allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; -}; - -// @alpha -export type CustomRoomPreviewBarComponentProps = { - roomId?: string; - roomAlias?: string; -}; - -// @alpha -export type CustomRoomPreviewBarRenderFunction = ( -props: CustomRoomPreviewBarComponentProps, -originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) => JSX.Element; - -// @public -export interface DialogApiExtension { - openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; -} - -// @public -export type DialogHandle = { - finished: Promise<{ - ok: boolean; - model: M | null; - }>; - close(): void; -}; - -// @public -export interface DialogOptions { - title: string; -} - -// @public -export type DialogProps = { - onSubmit(model: M): void; - onCancel(): void; -}; - -// @alpha @deprecated (undocumented) -export interface DirectoryCustomisations { - // (undocumented) - requireCanonicalAliasAccessToPublish?(): boolean; -} - -// @alpha -export type ExtendablePropsRenderFunction =

( -props: P, -originalComponent: (props: P) => JSX.Element) => JSX.Element; - -// @alpha -export interface ExtrasApi { - addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; - getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; - setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; -} - -// @public -export interface I18nApi { - humanizeTime(this: void, timeMillis: number): string; - get language(): string; - register(this: void, translations: Partial): void; - translate(this: void, key: keyof Translations, variables?: Variables): string; - translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; -} - -// @alpha -export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; - -// @alpha @deprecated (undocumented) -export type LegacyCustomisations = (customisations: T) => void; - -// @alpha @deprecated (undocumented) -export interface LegacyCustomisationsApiExtension { - // @deprecated (undocumented) - readonly _registerLegacyAliasCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; - // @deprecated (undocumented) - readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations>; - // @deprecated (undocumented) - readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; -} - -// @alpha @deprecated (undocumented) -export interface LegacyModuleApiExtension { - // @deprecated - _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; -} - -// @alpha @deprecated (undocumented) -export interface LifecycleCustomisations { - // (undocumented) - onLoggedOutAndStorageCleared?(): void; -} - -// @alpha -export type LocationRenderFunction = () => JSX.Element; - -// @alpha -export interface MatrixEvent { - content: Record; - eventId: string; - originServerTs: number; - roomId: string; - sender: string; - stateKey?: string; - type: string; - unsigned: Record; -} - -// @public -export type MaybePromise = T | PromiseLike; - -// @alpha @deprecated (undocumented) -export interface Media { - // (undocumented) - downloadSource(): Promise; - // (undocumented) - getSquareThumbnailHttp(dim: number): string | null; - // (undocumented) - getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; - // (undocumented) - getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; - // (undocumented) - readonly hasThumbnail: boolean; - // (undocumented) - readonly isEncrypted: boolean; - // (undocumented) - readonly srcHttp: string | null; - // (undocumented) - readonly srcMxc: string; - // (undocumented) - readonly thumbnailHttp: string | null; - // (undocumented) - readonly thumbnailMxc: string | null | undefined; -} - -// @alpha @deprecated (undocumented) -export interface MediaContructable { - // (undocumented) - new (prepared: PreparedMedia): Media; -} - -// @alpha @deprecated (undocumented) -export interface MediaCustomisations { - // (undocumented) - readonly Media: MediaContructable; - // (undocumented) - mediaFromContent(content: Content, client?: Client): Media; - // (undocumented) - mediaFromMxc(mxc?: string, client?: Client): Media; -} - -// @public -export interface Module { - // (undocumented) - load(): Promise; -} - -// @public -export interface ModuleFactory { - // (undocumented) - new (api: Api): Module; - // (undocumented) - readonly moduleApiVersion: string; - // (undocumented) - readonly prototype: Module; -} - -// @public -export class ModuleIncompatibleError extends Error { - constructor(pluginVersion: string); -} - -// @public -export class ModuleLoader { - constructor(api: Api); - // Warning: (ae-forgotten-export) The symbol "ModuleExport" needs to be exported by the entry point index.d.ts - // - // (undocumented) - load(moduleExport: ModuleExport): Promise; - // (undocumented) - start(): Promise; -} - -// @public -export interface NavigationApi { - openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; - // @alpha - registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; - toMatrixToLink(link: string, join?: boolean): Promise; -} - -// @public -export interface OpenRoomOptions { - autoJoin?: boolean; - viaServers?: string[]; -} - -// @alpha -export type OriginalMessageComponentProps = { - showUrlPreview?: boolean; -}; - -// @alpha -export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; - -// @public -export interface Profile { - displayName?: string; - isGuest?: boolean; - userId?: string; -} - -// @public -export interface ProfileApiExtension { - readonly profile: Watchable; -} - -// @public -export interface Room { - getLastActiveTimestamp: () => number; - id: string; - name: Watchable; -} - -// @alpha -export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; - -// @alpha @deprecated (undocumented) -export interface RoomListCustomisations { - isRoomVisible?(room: Room): boolean; -} - -// @public -export interface RoomListStoreApi { - getRooms(): Watchable; - waitForReady(): Promise; -} - -// @alpha -export interface RoomViewProps { - enableReadReceiptsAndMarkersOnActivity?: boolean; - hideComposer?: boolean; - hideHeader?: boolean; - hidePinnedMessageBanner?: boolean; - hideRightPanel?: boolean; - hideWidgets?: boolean; -} - -// @alpha @deprecated (undocumented) -export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; - -// @alpha -export interface SpacePanelItemProps { - className?: string; - icon?: JSX.Element; - label: string; - onSelected: () => void; - style?: React.CSSProperties; - tooltip?: string; -} - -// @public -export interface StoresApi { - roomListStore: RoomListStoreApi; -} - -// @public -export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); - -// @public -export type Tags = Record; - -// @public -export type Translations = Record; - -// @alpha -export const enum UIComponent { - AddIntegrations = "UIComponent.addIntegrations", - CreateRooms = "UIComponent.roomCreation", - CreateSpaces = "UIComponent.spaceCreation", - ExploreRooms = "UIComponent.exploreRooms", - FilterContainer = "UIComponent.filterContainer", - InviteUsers = "UIComponent.sendInvites", - RoomOptionsMenu = "UIComponent.roomOptionsMenu" -} - -// @alpha @deprecated (undocumented) -export interface UserIdentifierCustomisations { - getDisplayUserIdentifier(userId: string, opts: { - roomId?: string; - withDisplayName?: boolean; - }): string | null; -} - -// @public -export function useWatchable(watchable: Watchable): T; - -// @public -export type Variables = { - count?: number; - [key: string]: SubstitutionValue; -}; - -// @public -export class Watchable { - constructor(currentValue: T); - // Warning: (ae-forgotten-export) The symbol "WatchFn" needs to be exported by the entry point index.d.ts - // - // (undocumented) - protected readonly listeners: Set>; - protected onFirstWatch(): void; - protected onLastWatch(): void; - // (undocumented) - unwatch(listener: (value: T) => void): void; - get value(): T; - set value(value: T); - // (undocumented) - watch(listener: (value: T) => void): void; -} - -// @alpha -export interface WidgetApi { - getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; - getWidgetsInRoom(roomId: string): IWidget[]; - isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; - moveAppToContainer(app: IWidget, container: Container, roomId: string): void; -} - -// @alpha -export type WidgetDescriptor = { - id: string; - templateUrl: string; - creatorUserId: string; - type: string; - origin: string; - roomId?: string; -}; - -// @alpha -export interface WidgetLifecycleApi { - registerCapabilitiesApprover(approver: CapabilitiesApprover): void; - registerIdentityApprover(approver: IdentityApprover): void; - registerPreloadApprover(approver: PreloadApprover): void; -} - -// @alpha @deprecated (undocumented) -export interface WidgetPermissionsCustomisations { - preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; -} - -// @alpha @deprecated (undocumented) -export interface WidgetVariablesCustomisations { - isReady?(): Promise; - provideVariables?(): { - currentUserId: string; - userDisplayName?: string; - userHttpAvatarUrl?: string; - clientId?: string; - clientTheme?: string; - clientLanguage?: string; - deviceId?: string; - baseUrl?: string; - }; -} - -// (No @packageDocumentation comment for this package) - -``` +## API Report File for "@element-hq/element-web-module-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentType } from 'react'; +import { IWidget } from 'matrix-widget-api'; +import { JSX } from 'react'; +import { ModuleApi } from '@matrix-org/react-sdk-module-api'; +import { ReactNode } from 'react'; +import { Root } from 'react-dom/client'; +import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; +import { SVGAttributes } from 'react'; + +// @public +export interface AccountAuthApiExtension { + overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; +} + +// @public +export interface AccountAuthInfo { + accessToken: string; + deviceId: string; + homeserverUrl: string; + refreshToken?: string; + userId: string; +} + +// @public +export interface AccountDataApi { + delete(eventType: string): Promise; + get(eventType: string): Watchable; + set(eventType: string, content: unknown): Promise; +} + +// @alpha @deprecated (undocumented) +export interface AliasCustomisations { + // (undocumented) + getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; +} + +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyModuleApiExtension" which is marked as @alpha +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyCustomisationsApiExtension" which is marked as @alpha +// +// @public +export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { + // @alpha + readonly builtins: BuiltinsApi; + readonly client: ClientApi; + // @alpha + readonly composer: ComposerApi; + readonly config: ConfigApi; + createRoot(element: Element): Root; + // @alpha + readonly customComponents: CustomComponentsApi; + // @alpha + readonly customisations: CustomisationsApi; + // @alpha + readonly extras: ExtrasApi; + readonly i18n: I18nApi; + readonly navigation: NavigationApi; + readonly rootNode: HTMLElement; + readonly stores: StoresApi; + // @alpha + readonly widget: WidgetApi; + // @alpha + readonly widgetLifecycle: WidgetLifecycleApi; +} + +// @alpha +export interface BuiltinsApi { + renderNotificationDecoration(roomId: string): React.ReactNode; + renderRoomAvatar(roomId: string, size?: string): React.ReactNode; + renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; +} + +// @alpha +export type CapabilitiesApprover = (widget: WidgetDescriptor, requestedCapabilities: Set) => MaybePromise | undefined>; + +// @alpha @deprecated (undocumented) +export interface ChatExportCustomisations { + getForceChatExportParameters(): { + format?: ExportFormat; + range?: ExportType; + numberOfMessages?: number; + includeAttachments?: boolean; + sizeMb?: number; + }; +} + +// @public +export interface ClientApi { + accountData: AccountDataApi; + getRoom: (id: string) => Room | null; +} + +// @alpha @deprecated (undocumented) +export interface ComponentVisibilityCustomisations { + shouldShowComponent?(component: "UIComponent.sendInvites" | "UIComponent.roomCreation" | "UIComponent.spaceCreation" | "UIComponent.exploreRooms" | "UIComponent.addIntegrations" | "UIComponent.filterContainer" | "UIComponent.roomOptionsMenu"): boolean; +} + +// @alpha +export interface ComposerApi { + addFileUploadOption(option: ComposerApiFileUploadOption): void; + // (undocumented) + readonly ComposerApiFileUploadLocal: typeof ComposerApiFileUploadLocal; + disableFileUploadOption(type: string): boolean; + insertEventContentIntoComposer(key: string, eventContent: T, previewComponent: ComposerExtraContentPreview): void; + insertTextIntoComposer(text: string): void; +} + +// @alpha +export const ComposerApiFileUploadLocal = "local"; + +// @alpha +export type ComposerApiFileUploadOption = { + type: string; + label: string; + icon: ComponentType>; + onSelected: (roomId: string, onFileSelected: (result: FileUploadResult) => void) => Promise | void; +}; + +// @alpha +export type ComposerExtraContentPreview> = (props: { + contentKey: string; + content: T; + onContentChange: (newContent: T | null) => void; +}) => ReactNode; + +// @public +export interface Config { + // (undocumented) + brand: string; +} + +// @public +export interface ConfigApi { + // (undocumented) + get(): Config; + // (undocumented) + get(key: K): Config[K]; + // (undocumented) + get(key?: K): Config | Config[K]; +} + +// @alpha +export type Container = "top" | "right" | "center"; + +// @alpha +export interface CustomComponentsApi { + registerLoginComponent(renderer: CustomLoginRenderFunction): void; + registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; + registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; +} + +// @alpha +export interface CustomisationsApi { + registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; +} + +// @alpha +export type CustomLoginComponentProps = { + serverConfig: CustomLoginComponentPropsServerConfig; + fragmentAfterLogin?: string; + children?: ReactNode; + onLoggedIn(data: AccountAuthInfo): void; + onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; +}; + +// @alpha +export interface CustomLoginComponentPropsServerConfig { + hsName: string; + hsUrl: string; +} + +// @alpha +export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; + +// @alpha +export type CustomMessageComponentProps = { + mxEvent: MatrixEvent; +}; + +// @alpha +export type CustomMessageRenderFunction = ( +props: CustomMessageComponentProps, +originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element) => JSX.Element; + +// @alpha +export type CustomMessageRenderHints = { + allowEditingEvent?: boolean; + allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; +}; + +// @alpha +export type CustomRoomPreviewBarComponentProps = { + roomId?: string; + roomAlias?: string; +}; + +// @alpha +export type CustomRoomPreviewBarRenderFunction = ( +props: CustomRoomPreviewBarComponentProps, +originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) => JSX.Element; + +// @public +export interface DialogApiExtension { + openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; +} + +// @public +export type DialogHandle = { + finished: Promise<{ + ok: boolean; + model: M | null; + }>; + close(): void; +}; + +// @public +export interface DialogOptions { + title: string; +} + +// @public +export type DialogProps = { + onSubmit(model: M): void; + onCancel(): void; +}; + +// @alpha @deprecated (undocumented) +export interface DirectoryCustomisations { + // (undocumented) + requireCanonicalAliasAccessToPublish?(): boolean; +} + +// @alpha +export type ExtendablePropsRenderFunction =

( +props: P, +originalComponent: (props: P) => JSX.Element) => JSX.Element; + +// @alpha +export interface ExtrasApi { + addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; + getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; +} + +// @alpha +export type FileUploadResult = { + mxc: string; +} | { + file: File; +} | { + blob: Blob; +} | null; + +// @public +export interface I18nApi { + humanizeTime(this: void, timeMillis: number): string; + get language(): string; + register(this: void, translations: Partial): void; + translate(this: void, key: keyof Translations, variables?: Variables): string; + translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; +} + +// @alpha +export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @alpha @deprecated (undocumented) +export type LegacyCustomisations = (customisations: T) => void; + +// @alpha @deprecated (undocumented) +export interface LegacyCustomisationsApiExtension { + // @deprecated (undocumented) + readonly _registerLegacyAliasCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; +} + +// @alpha @deprecated (undocumented) +export interface LegacyModuleApiExtension { + // @deprecated + _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; +} + +// @alpha @deprecated (undocumented) +export interface LifecycleCustomisations { + // (undocumented) + onLoggedOutAndStorageCleared?(): void; +} + +// @alpha +export type LocationRenderFunction = () => JSX.Element; + +// @alpha +export interface MatrixEvent { + content: Record; + eventId: string; + originServerTs: number; + roomId: string; + sender: string; + stateKey?: string; + type: string; + unsigned: Record; +} + +// @public +export type MaybePromise = T | PromiseLike; + +// @alpha @deprecated (undocumented) +export interface Media { + // (undocumented) + downloadSource(): Promise; + // (undocumented) + getSquareThumbnailHttp(dim: number): string | null; + // (undocumented) + getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + readonly hasThumbnail: boolean; + // (undocumented) + readonly isEncrypted: boolean; + // (undocumented) + readonly srcHttp: string | null; + // (undocumented) + readonly srcMxc: string; + // (undocumented) + readonly thumbnailHttp: string | null; + // (undocumented) + readonly thumbnailMxc: string | null | undefined; +} + +// @alpha @deprecated (undocumented) +export interface MediaContructable { + // (undocumented) + new (prepared: PreparedMedia): Media; +} + +// @alpha @deprecated (undocumented) +export interface MediaCustomisations { + // (undocumented) + readonly Media: MediaContructable; + // (undocumented) + mediaFromContent(content: Content, client?: Client): Media; + // (undocumented) + mediaFromMxc(mxc?: string, client?: Client): Media; +} + +// @public +export interface Module { + // (undocumented) + load(): Promise; +} + +// @public +export interface ModuleFactory { + // (undocumented) + new (api: Api): Module; + // (undocumented) + readonly moduleApiVersion: string; + // (undocumented) + readonly prototype: Module; +} + +// @public +export class ModuleIncompatibleError extends Error { + constructor(pluginVersion: string); +} + +// @public +export class ModuleLoader { + constructor(api: Api); + // Warning: (ae-forgotten-export) The symbol "ModuleExport" needs to be exported by the entry point index.d.ts + // + // (undocumented) + load(moduleExport: ModuleExport): Promise; + // (undocumented) + start(): Promise; +} + +// @public +export interface NavigationApi { + openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; + // @alpha + registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; + toMatrixToLink(link: string, join?: boolean): Promise; +} + +// @public +export interface OpenRoomOptions { + autoJoin?: boolean; + viaServers?: string[]; +} + +// @alpha +export type OriginalMessageComponentProps = { + showUrlPreview?: boolean; +}; + +// @alpha +export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @public +export interface Profile { + displayName?: string; + isGuest?: boolean; + userId?: string; +} + +// @public +export interface ProfileApiExtension { + readonly profile: Watchable; +} + +// @public +export interface Room { + getLastActiveTimestamp: () => number; + id: string; + name: Watchable; +} + +// @alpha +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + +// @alpha @deprecated (undocumented) +export interface RoomListCustomisations { + isRoomVisible?(room: Room): boolean; +} + +// @public +export interface RoomListStoreApi { + getRooms(): Watchable; + waitForReady(): Promise; +} + +// @alpha +export interface RoomViewProps { + enableReadReceiptsAndMarkersOnActivity?: boolean; + hideComposer?: boolean; + hideHeader?: boolean; + hidePinnedMessageBanner?: boolean; + hideRightPanel?: boolean; + hideWidgets?: boolean; +} + +// @alpha @deprecated (undocumented) +export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; + +// @alpha +export interface SpacePanelItemProps { + className?: string; + icon?: JSX.Element; + label: string; + onSelected: () => void; + style?: React.CSSProperties; + tooltip?: string; +} + +// @public +export interface StoresApi { + roomListStore: RoomListStoreApi; +} + +// @public +export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); + +// @public +export type Tags = Record; + +// @public +export type Translations = Record; + +// @alpha +export const enum UIComponent { + AddIntegrations = "UIComponent.addIntegrations", + CreateRooms = "UIComponent.roomCreation", + CreateSpaces = "UIComponent.spaceCreation", + ExploreRooms = "UIComponent.exploreRooms", + FilterContainer = "UIComponent.filterContainer", + InviteUsers = "UIComponent.sendInvites", + RoomOptionsMenu = "UIComponent.roomOptionsMenu" +} + +// @alpha @deprecated (undocumented) +export interface UserIdentifierCustomisations { + getDisplayUserIdentifier(userId: string, opts: { + roomId?: string; + withDisplayName?: boolean; + }): string | null; +} + +// @public +export function useWatchable(watchable: Watchable): T; + +// @public +export type Variables = { + count?: number; + [key: string]: SubstitutionValue; +}; + +// @public +export class Watchable { + constructor(currentValue: T); + // Warning: (ae-forgotten-export) The symbol "WatchFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected readonly listeners: Set>; + protected onFirstWatch(): void; + protected onLastWatch(): void; + // (undocumented) + unwatch(listener: (value: T) => void): void; + get value(): T; + set value(value: T); + // (undocumented) + watch(listener: (value: T) => void): void; +} + +// @alpha +export interface WidgetApi { + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + getWidgetsInRoom(roomId: string): IWidget[]; + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} + +// @alpha +export type WidgetDescriptor = { + id: string; + templateUrl: string; + creatorUserId: string; + type: string; + origin: string; + roomId?: string; +}; + +// @alpha +export interface WidgetLifecycleApi { + registerCapabilitiesApprover(approver: CapabilitiesApprover): void; + registerIdentityApprover(approver: IdentityApprover): void; + registerPreloadApprover(approver: PreloadApprover): void; +} + +// @alpha @deprecated (undocumented) +export interface WidgetPermissionsCustomisations { + preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; +} + +// @alpha @deprecated (undocumented) +export interface WidgetVariablesCustomisations { + isReady?(): Promise; + provideVariables?(): { + currentUserId: string; + userDisplayName?: string; + userHttpAvatarUrl?: string; + clientId?: string; + clientTheme?: string; + clientLanguage?: string; + deviceId?: string; + baseUrl?: string; + }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/module-api/src/api/composer.ts b/packages/module-api/src/api/composer.ts new file mode 100644 index 00000000000..26881f22cbc --- /dev/null +++ b/packages/module-api/src/api/composer.ts @@ -0,0 +1,82 @@ +import { ComponentType, ReactNode, SVGAttributes } from "react"; + +/** + * An option presented to the user for uploading a file. + * @alpha Unlikely to change + */ +export type ComposerApiFileUploadOption = { + type: string; + label: string; + icon: ComponentType>; + onSelected: (roomId: string, onFileSelected: (result: FileUploadResult) => void) => Promise | void; +}; +/** + * A constant representing the ability to upload local files. + * This also handles drag and drop files. + * @alpha Unlikely to change + */ +export const ComposerApiFileUploadLocal = "local"; + +/** + * Result from a file upload. + * @alpha Unlikely to change + */ +export type FileUploadResult = { mxc: string } | { file: File } | { blob: Blob } | null; + +/** + * Rendered represntation of extra content for a message. + * @alpha Likely to change + */ +export type ComposerExtraContentPreview> = (props: { + contentKey: string; + content: T; + /** + * Called when the extra contents should be changed. + * @param newContent - The new content, or `null` if the contents should be removed. + */ + onContentChange: (newContent: T | null) => void; +}) => ReactNode; + +/** + * API to alter the message composer. + * @alpha Likely to change + */ +export interface ComposerApi { + readonly ComposerApiFileUploadLocal: typeof ComposerApiFileUploadLocal; + /** + * Add a new file upload option for the user. + * Use {@link ComposerApiFileUploadLocal} to alter the local file upload logic. + * @throws If another option is already using the same `type`. + * @param option - TODO + * @alpha Likely to change + */ + addFileUploadOption(option: ComposerApiFileUploadOption): void; + /** + * Disable an existing file upload option + * Use {@link ComposerApiFileUploadLocal} to disable local file uploads. + * @param type - The `type` of an {@link ComposerApiFileUploadOption} + * @returns Whether or not the option existed in the currenty configured set. + * @alpha Likely to change + */ + disableFileUploadOption(type: string): boolean; + /** + * Insert plaintext into the current composer. + * @param text - The plain text to insert + * @returns Returns immediately, does not await action. + * @alpha Likely to change + */ + insertTextIntoComposer(text: string): void; + /** + * Insert extra event content into the current composer. + * @param key - A unique key to identify the content, that can allow existing content to be overridden. + * @param eventContent - Freeform event contents to be added to the Matrix event. + * @param previewComponent - A component to render at the top of the composer to show the extra content. + * @returns Returns immediately, does not await action. + * @alpha Likely to change + */ + insertEventContentIntoComposer( + key: string, + eventContent: T, + previewComponent: ComposerExtraContentPreview, + ): void; +} diff --git a/packages/module-api/src/api/index.ts b/packages/module-api/src/api/index.ts index 3d60fc2a43f..0cbda41545f 100644 --- a/packages/module-api/src/api/index.ts +++ b/packages/module-api/src/api/index.ts @@ -23,6 +23,7 @@ import { type ClientApi } from "./client.ts"; import { type WidgetLifecycleApi } from "./widget-lifecycle.ts"; import { type WidgetApi } from "./widget.ts"; import { type CustomisationsApi } from "./customisations.ts"; +import { type ComposerApi } from "./composer.ts"; /** * Module interface for modules to implement. @@ -159,6 +160,12 @@ export interface Api */ readonly customisations: CustomisationsApi; + /** + * Allows modules to customise the message composer. + * @alpha + */ + readonly composer: ComposerApi; + /** * Create a ReactDOM root for rendering React components. * Exposed to allow modules to avoid needing to bundle their own ReactDOM. diff --git a/packages/module-api/src/index.ts b/packages/module-api/src/index.ts index a246b12f9e4..0a4673e0823 100644 --- a/packages/module-api/src/index.ts +++ b/packages/module-api/src/index.ts @@ -25,6 +25,7 @@ export type * from "./api/client"; export type * from "./api/widget-lifecycle"; export type * from "./api/widget"; export type * from "./api/customisations"; +export type * from "./api/composer"; export { UIComponent } from "./api/customisations"; export * from "./api/watchable"; export type * from "./utils"; diff --git a/packages/module-api/src/loader.ts b/packages/module-api/src/loader.ts index c057109f28e..cd8cc22d28b 100644 --- a/packages/module-api/src/loader.ts +++ b/packages/module-api/src/loader.ts @@ -37,9 +37,9 @@ export class ModuleLoader { if (!isModule(moduleExport)) { throw new Error("Invalid plugin"); } - if (!satisfies(__VERSION__, moduleExport.default.moduleApiVersion)) { - throw new ModuleIncompatibleError(moduleExport.default.moduleApiVersion); - } + // if (!satisfies(__VERSION__, moduleExport.default.moduleApiVersion)) { + // throw new ModuleIncompatibleError(moduleExport.default.moduleApiVersion); + // } const { default: Module } = moduleExport; this.modules.push(new Module(this.api)); } diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png index ff1cfa2b24e..fd8d0583870 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png index 5767d6aa32a..67801f4c04c 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png index 390c3589599..340540e14a2 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png index d4b4048145f..e24a3b4422e 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png index 263f3b1eeff..c3e635491c7 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png index b41968503d5..8171d99b63d 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png index fc3272f314c..d618d89d47e 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png index 2a5a79992b7..8fc73653b8f 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png index bca73a6e3b3..adfc1670f74 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/event-tiles/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png new file mode 100644 index 00000000000..62b95a412e8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/article-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png new file mode 100644 index 00000000000..cc4c3d462ed Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png new file mode 100644 index 00000000000..08929651552 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png new file mode 100644 index 00000000000..f07f8771cd7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/social-with-image-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png new file mode 100644 index 00000000000..4e6699454d3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-and-description-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png new file mode 100644 index 00000000000..8f8349d0ce6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/title-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png new file mode 100644 index 00000000000..7384ce79332 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/video-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png new file mode 100644 index 00000000000..48d75f6f6e8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-and-description-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png new file mode 100644 index 00000000000..fad25359e63 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-site-icon-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png new file mode 100644 index 00000000000..9af701fdcd4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png new file mode 100644 index 00000000000..b9f46eaafad Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx/with-very-long-text-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..43264fcf66f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png new file mode 100644 index 00000000000..db4a255ed4c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png new file mode 100644 index 00000000000..cf4fa1947c7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/multiple-previews-visible-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png new file mode 100644 index 00000000000..cd59915de56 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/UrlPreviewGroupView/UrlPreviewGroupView.stories.tsx/with-compact-view-auto.png differ diff --git a/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.stories.tsx b/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.stories.tsx new file mode 100644 index 00000000000..89e5eb24a40 --- /dev/null +++ b/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.stories.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 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 React from "react"; +import { type Meta, type StoryFn } from "@storybook/react-vite"; +import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search"; +import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; +import HelpIcon from "@vector-im/compound-design-tokens/assets/web/icons/help"; + +import { MultiOptionButton } from "./MultiOptionButton"; +import { fn } from "storybook/test"; + +const meta = { + title: "core/MultiOptionButton", + component: MultiOptionButton, + tags: ["autodocs"], + args: { + multipleOptionsButton: { + label: "Options", + icon: HelpIcon, + }, + }, +} satisfies Meta; + +export default meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + options: [ + { + label: "Split up", + icon: OverflowHorizontalIcon, + onSelect: fn(), + }, + { + label: "Search for clues", + icon: SearchIcon, + onSelect: fn(), + }, + ], +}; + +export const WithOneOption = Template.bind({}); + +WithOneOption.args = { + options: [ + { + label: "Search for clues", + icon: SearchIcon, + onSelect: fn(), + }, + ], +}; diff --git a/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.tsx b/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.tsx new file mode 100644 index 00000000000..fe635b8b814 --- /dev/null +++ b/packages/shared-components/src/core/MultiOptionButton/MultiOptionButton.tsx @@ -0,0 +1,71 @@ +/* + * Copyright (c) 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 React, { + type ReactElement, + type PropsWithChildren, + useState, + type ComponentProps, + SVGAttributes, + ComponentType, +} from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; + +// import styles from "./UploadButton.module.css"; + +export interface MultiOptionButtonProps { + options: { + label: string; + icon: ComponentType>; + onSelect: () => void; + }[]; + multipleOptionsButton: { + label: string; + icon: ComponentType>; + }; +} + +/** + * A button that may have one or more options that the user can select. + * + * @example + * ```tsx + * + * ``` + */ +export function MultiOptionButton({ + options, + multipleOptionsButton, + ...rootButtonProps +}: PropsWithChildren>): ReactElement | null { + const [open, setOpen] = useState(false); + if (options.length === 0) { + return null; + } else if (options.length === 1) { + const { label, icon: Icon, onSelect } = options[0]; + return ( + onSelect()}> + + + ); + } + + const Icon = multipleOptionsButton.icon; + const trigger = ( + + + + ); + + return ( +

setOpen(o)}> + {options.map((o) => ( + o.onSelect()} /> + ))} + + ); +} diff --git a/packages/shared-components/src/core/MultiOptionButton/index.ts b/packages/shared-components/src/core/MultiOptionButton/index.ts new file mode 100644 index 00000000000..f12b682ac1d --- /dev/null +++ b/packages/shared-components/src/core/MultiOptionButton/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 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. + */ + +export * from "./MultiOptionButton"; 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 deleted file mode 100644 index 86e67b73c71..00000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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. - */ - -.thumbnail { - /* Thumbnails are always limited to a maximum of 100px */ - max-width: 100px; - max-height: 100px; - /* Ensure we don't stretch the image */ - object-fit: cover; -} - -.link { - color: var(--cpd-color-text-link-external); - text-decoration-line: none; -} - -.container { - display: inline flex; - column-gap: var(--cpd-space-1x); - border-inline-start: 2px solid var(--cpd-color-bg-subtle-primary); - border-radius: 2px; - color: var(--cpd-color-gray-900); - - .wrapImageCaption { - display: inline-flex; - flex-direction: row; - flex-wrap: wrap; - row-gap: var(--cpd-space-2x); - flex: 1; - } - - .image, - .caption { - display: inline-flex; - flex-direction: column; - margin-inline-start: var(--cpd-space-4x); - min-width: 0; /* Prevent blowout */ - } - - .image { - /* Clear default - ); - } - - const anchor = ( - - {preview.title} - - ); - return ( -
-
- {img} -
- - {tooltipCaption ? {anchor} : anchor} - {preview.siteName && ( - - {" - " + preview.siteName} - - )} - - {preview.description && ( - {preview.description} - )} -
-
-
- ); -} diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap deleted file mode 100644 index 1e0d988f1b9..00000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/__snapshots__/LinkPreview.test.tsx.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`LinkPreview > renders a preview 1`] = ` -
-
-
- -
-

- - A simple title - - - - Site name - -

-

- A simple description -

-
-
-
-
-`; - -exports[`LinkPreview > renders a preview with just a title 1`] = ` -
-
- -
-
-`; - -exports[`LinkPreview > renders a preview with just a title and description 1`] = ` -
-
-
-
-

- - A simple title - -

-

- A simple description with a link to - - https://matrix.org - -

-
-
-
-
-`; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap b/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap deleted file mode 100644 index fb9e9494d59..00000000000 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/__snapshots__/UrlPreviewGroupView.test.tsx.snap +++ /dev/null @@ -1,492 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`UrlPreviewGroupView > renders a single preview 1`] = ` -
-
-
-
-
- -
-

- - A simple title - -

-

- A simple description -

-
-
-
-
- -
-
-`; - -exports[`UrlPreviewGroupView > renders multiple previews 1`] = ` -
-
-
-
-
- -
-

- - One - -

-

- A regular square image. -

-
-
-
-
-
- -
-

- - Two - -

-

- This one has a taller image which should crop nicely. -

-
-
-
-
-
- -
-

- - Three - -

-

- One more description -

-
-
-
- -
- -
-
-`; - -exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = ` -
-
-
-
-
- -
-

- - A simple title - -

-

- A simple description -

-
-
-
- -
- -
-
-`; - -exports[`UrlPreviewGroupView > renders with a compact view 1`] = ` -
-
-
-
-
- -
-

- - One - -

-

- A regular square image. -

-
-
-
-
-
- -
-

- - Two - -

-

- This one has a taller image which should crop nicely. -

-
-
-
-
-
- -
-

- - Three - -

-

- One more description -

-
-
-
- -
- -
-
-`; diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 7cd70dbb0d3..ec5897d834c 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -219,6 +219,7 @@ "message_timestamp_sent_at": "Sent at: %(dateTime)s", "url_preview": { "close": "Close preview", + "open_link": "Open link", "show_n_more": { "one": "Show %(count)s other preview", "other": "Show %(count)s other previews" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index c134ab8b57e..a4e45bc386a 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -12,7 +12,6 @@ export * from "./audio/SeekBar"; export * from "./core/avatar/AvatarWithDetails"; export * from "./composer/Banner"; export * from "./crypto/SasEmoji"; -export * from "./event-tiles/UrlPreviewGroupView"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; @@ -21,6 +20,7 @@ export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; export * from "./core/pill-input/Pill"; export * from "./core/pill-input/PillInput"; +export * from "./core/MultiOptionButton"; export * from "./room/RoomStatusBar"; export * from "./room/WidgetPip"; export * from "./room/HistoryVisibilityBadge"; @@ -39,6 +39,7 @@ export * from "./room/timeline/event-tile/reactions/ReactionsRow"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButton"; export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip"; export * from "./room/timeline/event-tile/timestamp/MessageTimestampView"; +export * from "./room/timeline/event-tile/UrlPreviewGroupView"; export * from "./core/rich-list/RichItem"; export * from "./core/rich-list/RichList"; export * from "./room-list/RoomListHeaderView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css new file mode 100644 index 00000000000..6e89cc20fa1 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.module.css @@ -0,0 +1,124 @@ +/* + * 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. + */ + +button.preview { + background: #fff; +} + +.preview { + display: flex; + position: relative; + width: 100%; + height: 200px; + background-size: cover; + background-position: center; + border: none; + padding: 0; + > img { + width: 100%; + object-fit: cover; + } + .playButton[data-kind="primary"] { + padding: 0; + width: 50px; + height: 50px; + margin: auto; + background: var(--cpd-color-text-on-solid-primary); + > svg { + margin: auto; + border-radius: 50px; + color: var(--cpd-color-icon-primary); + } + } +} + +.container { + 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; + + &.inline { + flex-direction: row; + gap: var(--cpd-space-4x); + padding: var(--cpd-space-3x) var(--cpd-space-4x); + + .title { + margin: 0; + } + + .siteAvatar { + margin: auto 0; + } + + .siteName { + margin: 0; + } + } + + .textContent { + padding: var(--cpd-space-3x) var(--cpd-space-4x); + &.inline { + padding: 0; + } + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); + } + + .caption { + display: inline-flex; + flex-direction: column; + min-width: 0; /* Prevent blowout */ + } + .caption { + flex: 1; + overflow: hidden; /* cause it to wrap rather than clip */ + } + + .title, + .description { + display: inline-block; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + } + + .title { + display: inline-block; + line-clamp: 2; + -webkit-line-clamp: 2; + margin: var(--cpd-space-1x) 0; + font-weight: var(--cpd-font-weight-semibold); + color: var(--cpd-color-text-primary); + text-decoration-line: none; + } + + .description { + font-size: var(--cpd-font-size-body-lg); + margin: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } + + .siteName { + margin-top: var(--cpd-space-2x); + vertical-align: middle; + display: flex; + gap: var(--cpd-space-1-5x); + > * { + /* Center everything */ + margin: auto 0; + } + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx new file mode 100644 index 00000000000..dceaecc1df4 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.stories.tsx @@ -0,0 +1,169 @@ +/* + * 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 React from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { LinkPreview } from "./LinkPreview"; +import { LinkedTextContext } from "../../../../../core/utils/LinkedText"; +import imageFile from "../../../../../../static/element.png"; +import imageFileWide from "../../../../../../static/wideImage.png"; + +export default { + title: "Event/UrlPreviewGroupView/LinkPreview", + component: LinkPreview, + tags: ["autodocs"], + args: { + onImageClick: fn(), + }, + argTypes: { + siteName: { + control: "text", + }, + author: { + control: "text", + }, + siteIcon: { control: { type: "file", accept: ".png" } }, + image: {}, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/sI9A2kV2K4xeiyqJsL7Ey3/Link-Previews?node-id=87-7920", + }, + }, +} satisfies Meta; + +const Template: StoryFn = (args) => ( + + + +); + +export const Default = Template.bind({}); +Default.args = { + title: "A simple title", + description: "A simple description", + link: "https://matrix.org", + siteName: "Site name", + image: { + imageThumb: imageFile, + imageFull: imageFile, + alt: "Element logo", + playable: false, + }, +}; + +export const Title = Template.bind({}); +Title.args = { + title: "A simple title", + link: "https://matrix.org", + siteName: "matrix.org", +}; + +export const TitleAndDescription = Template.bind({}); +TitleAndDescription.args = { + title: "A simple title", + description: "A simple description with a link to https://matrix.org", + link: "https://matrix.org", + siteName: "matrix.org", +}; +export const WithSiteIcon = Template.bind({}); +WithSiteIcon.args = { + title: "A simple title", + link: "https://matrix.org", + siteName: "matrix.org", + siteIcon: imageFile, +}; + +export const WithSiteIconAndDescription = Template.bind({}); +WithSiteIconAndDescription.args = { + title: "A simple title", + description: "A simple description with a link to https://matrix.org", + link: "https://matrix.org", + siteName: "matrix.org", + siteIcon: imageFile, +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + title: "A simple title", + description: "A simple description", + showTooltipOnLink: true, + link: "https://matrix.org", + siteName: "matrix.org", +}; + +export const Article = Template.bind({}); +Article.args = { + title: "A linked article", + description: + "This is a basic description returned from the linked source, usually with a word or two about what the link contains.", + link: "https://matrix.org", + siteName: "blog.example.org", + image: { + imageThumb: imageFileWide, + imageFull: imageFileWide, + alt: "A dog", + playable: false, + }, +}; + +export const Video = Template.bind({}); +Video.args = { + title: "A linked video", + description: + "This is a link to a video. You cannot play the video inline yet, but you can click the play button to open the link", + link: "https://matrix.org", + siteName: "blog.example.org", + image: { + imageThumb: imageFileWide, + imageFull: imageFileWide, + alt: "A dog", + playable: true, + }, +}; + +export const Social = Template.bind({}); +Social.args = { + description: "Sending a small message", + link: "https://matrix.org", + siteName: "socialsite.example.org", + title: "Test user (@test)", + author: "@test", +}; + +export const SocialWithImage = Template.bind({}); +SocialWithImage.args = { + description: "Sending a message with an attached image.", + title: "Test user (@test)", + link: "https://matrix.org", + siteName: "socialsite.example.org", + author: "@test", + image: { + imageThumb: imageFileWide, + imageFull: imageFileWide, + alt: "A dog", + playable: false, + }, +}; + +export const WithVeryLongText = Template.bind({}); +WithVeryLongText.args = { + title: "GitHub - element-hq/not-a-real-repo: A very very long PR title that should be rendered nicely", + description: + "This PR doesn't actually exist and neither does the repository. It might exist one day if we go into the business of making paradoxical repository names.", + link: "https://matrix.org", + siteName: "GitHub", + image: { + imageThumb: imageFile, + imageFull: imageFile, + alt: "Element logo", + playable: false, + }, +}; diff --git a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx similarity index 77% rename from packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx rename to packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx index 3309fe98882..1fa218b6ca8 100644 --- a/packages/shared-components/src/event-tiles/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/UrlPreviewGroupView/LinkPreview/LinkPreview.test.tsx @@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event"; import * as stories from "./LinkPreview.stories.tsx"; -const { Default, WithTooltip, Title, TitleAndDescription } = composeStories(stories); +const { Default, WithTooltip, Title, TitleAndDescription, Video } = composeStories(stories); describe("LinkPreview", () => { it("renders a preview", () => { @@ -36,4 +36,10 @@ describe("LinkPreview", () => { // Tooltip has the URL expect(await screen.findByText("https://matrix.org/")).toBeVisible(); }); + it("renders a playable preview that can be opened with a click", () => { + const { container } = render(