Port URL Preview components to MVVM (#32525)
* Port over linkifyJS to shared-components. * Drop rubbish * update lock * quickfix test * drop group id * Modernize tests * Remove stories that aren't in use. * Complete working version * Add copyright * tidy up * update lock * Update snaps * update snap * undo change * remove unused * More test updates * fix typo * fix margin on preview * move margin block * snapupdate * prettier * Port url preview logic to a view model. * More fiddling with VM logic * Note to self * Refactor away into a shared component. * Even more lovely lovely code that makes it look prettier * translation cleanup * Even more stuff that I need to fix yay * Remove .last-run.json * Update snaps * Ensure we set showUrlPreview * Cleanup tests * lint + add png support * Add a label * Cleanup * Add snaps * Update snaps * update playwright * Refactors * update snap * Add missing snap * Remove editing code (we check this in a better way in componentDidUpdate) * Add README * fix the one unused import * Style shuffling * Update vis tests * Finally fix the tooltip * Remove unused prop * Add some padding * fix lint issue * Design improvements * new screens * Update snaps * Fix CSS specificity * Remove stale screenshot * Rename function to match reality * Port viewmodel tests to snapshots * finish documenting types * Stop being dangerous * Use Linkify+decode for description * Remove ability for VM to do linkifying. * Port over linkifyJS to shared-components. * Drop rubbish * update lock * quickfix test * drop group id * Modernize tests * Remove stories that aren't in use. * Complete working version * Add copyright * tidy up * update lock * Update snaps * update snap * undo change * remove unused * More test updates * fix typo * fix margin on preview * move margin block * snapupdate * prettier * cleanup a test mistake * Fixup sonar issues * Don't expose linkifyjs to applications, just provide helper functions. * Add story for documentation. * remove $ * Use a const * typo * cleanup var name * remove console line * Changes checkpoint * Convert to context * Revert unrelated change. * more cleanup * Add a test to cover ignoring incoming data elements * Make tests happy * Update tests for LinkedText * Underlines! * fix lock * remove unused linkify packages * import move * Remove mod to remove underline * undo * fix snap * another snapshot fix * More cleanup * Tidy up based on review. * fix story * Pass in args * update snap * cleanup * use source image * oops * remove client peg * Remove unused state * tidy up code * Ensure we update the preview when the event content may have changed. * s/global/globalThis/ * Ensure we don't stretch images * Update screenshots * Cleanup
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
@@ -276,8 +276,6 @@
|
||||
@import "./views/rooms/_JumpToBottomButton.pcss";
|
||||
@import "./views/rooms/_LegacyRoomList.pcss";
|
||||
@import "./views/rooms/_LegacyRoomListHeader.pcss";
|
||||
@import "./views/rooms/_LinkPreviewGroup.pcss";
|
||||
@import "./views/rooms/_LinkPreviewWidget.pcss";
|
||||
@import "./views/rooms/_LiveContentSummary.pcss";
|
||||
@import "./views/rooms/_MemberListHeaderView.pcss";
|
||||
@import "./views/rooms/_MemberListView.pcss";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
.mx_LinkPreviewGroup {
|
||||
.mx_LinkPreviewGroup_hide {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
svg {
|
||||
flex: 0 0 40px;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mx_LinkPreviewGroup_hide svg,
|
||||
.mx_LinkPreviewGroup_hide:focus-visible:focus svg {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
> .mx_AccessibleButton {
|
||||
color: $accent;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015, 2016 OpenMarket 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.
|
||||
*/
|
||||
|
||||
.mx_LinkPreviewWidget {
|
||||
margin: $spacing-16 0 $spacing-16 auto;
|
||||
display: flex;
|
||||
column-gap: $spacing-4;
|
||||
border-inline-start: 2px solid $preview-widget-bar-color;
|
||||
border-radius: 2px;
|
||||
color: $info-plinth-fg-color;
|
||||
|
||||
.mx_MatrixChat_useCompactLayout & {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Exclude mx_LinkPreviewGroup_hide from wrapping */
|
||||
.mx_LinkPreviewWidget_wrapImageCaption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: $spacing-8;
|
||||
flex: 1;
|
||||
|
||||
.mx_LinkPreviewWidget_image,
|
||||
.mx_LinkPreviewWidget_caption {
|
||||
margin-inline-start: $spacing-16;
|
||||
min-width: 0; /* Prevent blowout */
|
||||
}
|
||||
|
||||
.mx_LinkPreviewWidget_image {
|
||||
flex: 0 0 100px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mx_LinkPreviewWidget_caption {
|
||||
flex: 1;
|
||||
overflow: hidden; /* cause it to wrap rather than clip */
|
||||
}
|
||||
|
||||
.mx_LinkPreviewWidget_title,
|
||||
.mx_LinkPreviewWidget_description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.mx_LinkPreviewWidget_title {
|
||||
font-weight: bold;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
.mx_LinkPreviewWidget_siteName {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LinkPreviewWidget_description {
|
||||
margin-top: $spacing-8;
|
||||
word-wrap: break-word;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ export interface IRoomState {
|
||||
showAvatarChanges: boolean;
|
||||
showDisplaynameChanges: boolean;
|
||||
matrixClientIsReady: boolean;
|
||||
showUrlPreview?: boolean;
|
||||
showUrlPreview: boolean;
|
||||
e2eStatus?: E2EStatus;
|
||||
rejecting?: boolean;
|
||||
hasPinnedWidgets?: boolean;
|
||||
@@ -499,6 +499,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
showJoinLeaves: true,
|
||||
showAvatarChanges: true,
|
||||
showDisplaynameChanges: true,
|
||||
// Default to false to avoid any accidental leakage.
|
||||
showUrlPreview: false,
|
||||
matrixClientIsReady: context.client?.isInitialSyncComplete(),
|
||||
mainSplitContentType: MainSplitContentType.Timeline,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
|
||||
@@ -6,9 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent } from "react";
|
||||
import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent, useCallback, useEffect } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { EventContentBodyView, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
|
||||
import {
|
||||
UrlPreviewGroupView,
|
||||
type UrlPreviewViewSnapshotPreview,
|
||||
useCreateAutoDisposedViewModel,
|
||||
EventContentBodyView,
|
||||
LINKIFIED_DATA_ATTRIBUTE,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
@@ -17,28 +24,27 @@ import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||
import EditMessageComposer from "../rooms/EditMessageComposer";
|
||||
import LinkPreviewGroup from "../rooms/LinkPreviewGroup";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { getParentEventId } from "../../../utils/Reply";
|
||||
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
|
||||
import { type IEventTileOps } from "../rooms/EventTile";
|
||||
import { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
|
||||
import ImageView from "../elements/ImageView.tsx";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||
|
||||
interface IState {
|
||||
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
||||
links: string[];
|
||||
const logger = rootLogger.getChild("TextualBody");
|
||||
|
||||
// track whether the preview widget is hidden
|
||||
widgetHidden: boolean;
|
||||
}
|
||||
type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewViewModel };
|
||||
|
||||
export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
class InnerTextualBody extends React.Component<Props> {
|
||||
private readonly contentRef = createRef<HTMLDivElement>();
|
||||
|
||||
public static contextType = RoomContext;
|
||||
@@ -46,12 +52,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
|
||||
private EventContentBodyViewModel: EventContentBodyViewModel;
|
||||
|
||||
public state = {
|
||||
links: [],
|
||||
widgetHidden: false,
|
||||
};
|
||||
|
||||
public constructor(props: IBodyProps, context: React.ContextType<typeof RoomContext>) {
|
||||
public constructor(props: Props, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
const mxEvent = props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
@@ -78,10 +79,18 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (!this.props.editState) {
|
||||
this.applyFormatting();
|
||||
public updateURLPreviewViewModel(): void {
|
||||
const content = this.contentRef.current;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
void this.props.urlPreviewViewModel.updateEventElement(content);
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to updateEventElement", ex);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
|
||||
@@ -113,125 +122,25 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
this.EventContentBodyViewModel.setHighlights(this.props.highlights);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle formatting updates
|
||||
if (!this.props.editState) {
|
||||
const stoppedEditing = prevProps.editState && !this.props.editState;
|
||||
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
|
||||
const urlPreviewChanged = prevProps.showUrlPreview !== this.props.showUrlPreview;
|
||||
if (messageWasEdited || stoppedEditing || urlPreviewChanged) {
|
||||
this.applyFormatting();
|
||||
}
|
||||
}
|
||||
this.updateURLPreviewViewModel();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.EventContentBodyViewModel.dispose();
|
||||
}
|
||||
|
||||
private applyFormatting(): void {
|
||||
this.calculateUrlPreview();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>, nextState: Readonly<IState>): boolean {
|
||||
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
||||
|
||||
public shouldComponentUpdate(nextProps: Readonly<IBodyProps>): boolean {
|
||||
// exploit that events are immutable :)
|
||||
return (
|
||||
nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
|
||||
nextProps.highlights !== this.props.highlights ||
|
||||
nextProps.replacingEventId !== this.props.replacingEventId ||
|
||||
nextProps.highlightLink !== this.props.highlightLink ||
|
||||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
|
||||
nextProps.editState !== this.props.editState ||
|
||||
nextState.links !== this.state.links ||
|
||||
nextState.widgetHidden !== this.state.widgetHidden ||
|
||||
nextProps.isSeeingThroughMessageHiddenForModeration !== this.props.isSeeingThroughMessageHiddenForModeration
|
||||
);
|
||||
}
|
||||
|
||||
private calculateUrlPreview(): void {
|
||||
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
|
||||
|
||||
if (this.props.showUrlPreview && this.contentRef.current) {
|
||||
// pass only the first child which is the event tile otherwise this recurses on edited events
|
||||
let links = this.findLinks([this.contentRef.current]);
|
||||
if (links.length) {
|
||||
// de-duplicate the links using a set here maintains the order
|
||||
links = Array.from(new Set(links));
|
||||
this.setState({ links });
|
||||
|
||||
// lazy-load the hidden state of the preview widget from localstorage
|
||||
if (window.localStorage) {
|
||||
const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
|
||||
this.setState({ widgetHidden: hidden });
|
||||
}
|
||||
} else if (this.state.links.length) {
|
||||
this.setState({ links: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findLinks(nodes: ArrayLike<Element>): string[] {
|
||||
let links: string[] = [];
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.tagName === "A" && node.getAttribute("href")) {
|
||||
if (this.isLinkPreviewable(node)) {
|
||||
links.push(node.getAttribute("href")!);
|
||||
}
|
||||
} else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") {
|
||||
continue;
|
||||
} else if (node.children && node.children.length) {
|
||||
links = links.concat(this.findLinks(node.children));
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private isLinkPreviewable(node: Element): boolean {
|
||||
// don't try to preview relative links
|
||||
const href = node.getAttribute("href") ?? "";
|
||||
if (!href.startsWith("http://") && !href.startsWith("https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = node.getAttribute("href");
|
||||
const host = url?.match(/^https?:\/\/(.*?)(\/|$)/)?.[1];
|
||||
|
||||
// never preview permalinks (if anything we should give a smart
|
||||
// preview of the room/user they point to: nobody needs to be reminded
|
||||
// what the matrix.to site looks like).
|
||||
if (!host || isPermalinkHost(host)) return false;
|
||||
|
||||
// as a random heuristic to avoid highlighting things like "foo.pl"
|
||||
// we require the linked text to either include a / (either from http://
|
||||
// or from a full foo.bar/baz style schemeless URL) - or be a markdown-style
|
||||
// link, in which case we check the target text differs from the link value.
|
||||
// TODO: make this configurable?
|
||||
if (node.textContent?.includes("/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.textContent?.toLowerCase().trim().startsWith(host.toLowerCase())) {
|
||||
// it's a "foo.pl" style link
|
||||
return false;
|
||||
} else {
|
||||
// it's a [foo bar](http://foo.com) style link
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private onCancelClick = (): void => {
|
||||
this.setState({ widgetHidden: true });
|
||||
// FIXME: persist this somewhere smarter than local storage
|
||||
if (global.localStorage) {
|
||||
global.localStorage.setItem("hide_preview_" + this.props.mxEvent.getId(), "1");
|
||||
}
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private onEmoteSenderClick = (): void => {
|
||||
const mxEvent = this.props.mxEvent;
|
||||
dis.dispatch({
|
||||
@@ -266,14 +175,18 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
|
||||
public getEventTileOps = (): IEventTileOps => ({
|
||||
isWidgetHidden: () => {
|
||||
return this.state.widgetHidden;
|
||||
// This controls whether the Show preview button is visibile.
|
||||
return this.props.urlPreviewViewModel.isPreviewHiddenByUser;
|
||||
},
|
||||
|
||||
unhideWidget: () => {
|
||||
this.setState({ widgetHidden: false });
|
||||
if (global.localStorage) {
|
||||
global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
await this.props.urlPreviewViewModel.onShowClick();
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to onShowClick", ex);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -365,6 +278,10 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
return <span className="mx_EventTile_pendingModeration">{`(${text})`}</span>;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.updateURLPreviewViewModel();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.props.editState) {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
@@ -424,16 +341,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
let widgets;
|
||||
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
|
||||
widgets = (
|
||||
<LinkPreviewGroup
|
||||
links={this.state.links}
|
||||
mxEvent={this.props.mxEvent}
|
||||
onCancelClick={this.onCancelClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const urlPreviewWidget = <UrlPreviewGroupView vm={this.props.urlPreviewViewModel} />;
|
||||
|
||||
if (isEmote) {
|
||||
return (
|
||||
@@ -449,7 +357,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
</span>
|
||||
|
||||
{body}
|
||||
{widgets}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -457,7 +365,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MNoticeBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{widgets}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -465,15 +373,59 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MTextBody mx_EventTile_caption" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{widgets}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id={this.props.id} className="mx_MTextBody mx_EventTile_content" onClick={this.onBodyLinkClick}>
|
||||
{body}
|
||||
{widgets}
|
||||
{urlPreviewWidget}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function TextualBody(props: IBodyProps): React.ReactElement {
|
||||
const [mediaVisible] = useMediaVisible(props.mxEvent);
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const onUrlPreviewImageClicked = useCallback((preview: UrlPreviewViewSnapshotPreview): void => {
|
||||
if (!preview.image?.imageFull) {
|
||||
// Should never get this far, but doesn't hurt to check.
|
||||
return;
|
||||
}
|
||||
const params = {
|
||||
src: preview.image.imageFull,
|
||||
width: preview.image.width,
|
||||
height: preview.image.height,
|
||||
name: preview.title,
|
||||
fileSize: preview.image.fileSize,
|
||||
link: preview.link,
|
||||
};
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}, []);
|
||||
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new UrlPreviewViewModel({
|
||||
client,
|
||||
mxEvent: props.mxEvent,
|
||||
mediaVisible: mediaVisible,
|
||||
onImageClicked: onUrlPreviewImageClicked,
|
||||
visible: props.showUrlPreview ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await vm.updateHidden(props.showUrlPreview ?? false, mediaVisible);
|
||||
} catch (ex) {
|
||||
logger.warn("UrlPreviewViewModel failed to updateHidden", ex);
|
||||
}
|
||||
})();
|
||||
}, [vm, props.showUrlPreview, mediaVisible]);
|
||||
|
||||
return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 JSX, useContext } from "react";
|
||||
import { type MatrixEvent, MatrixError, type IPreviewUrlResponse, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import LinkPreviewWidget from "./LinkPreviewWidget";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
|
||||
const INITIAL_NUM_PREVIEWS = 2;
|
||||
|
||||
interface IProps {
|
||||
links: string[]; // the URLs to be previewed
|
||||
mxEvent: MatrixEvent; // the Event associated with the preview
|
||||
onCancelClick(this: void): void; // called when the preview's cancel ('hide') button is clicked
|
||||
}
|
||||
|
||||
const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [expanded, toggleExpanded] = useStateToggle();
|
||||
const [mediaVisible] = useMediaVisible(mxEvent);
|
||||
|
||||
const ts = mxEvent.getTs();
|
||||
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(
|
||||
async () => {
|
||||
return fetchPreviews(cli, links, ts);
|
||||
},
|
||||
[links, ts],
|
||||
[],
|
||||
);
|
||||
|
||||
const showPreviews = expanded ? previews : previews.slice(0, INITIAL_NUM_PREVIEWS);
|
||||
|
||||
let toggleButton: JSX.Element | undefined;
|
||||
if (previews.length > INITIAL_NUM_PREVIEWS) {
|
||||
toggleButton = (
|
||||
<AccessibleButton onClick={toggleExpanded}>
|
||||
{expanded
|
||||
? _t("action|collapse")
|
||||
: _t("timeline|url_preview|show_n_more", { count: previews.length - showPreviews.length })}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_LinkPreviewGroup">
|
||||
{showPreviews.map(([link, preview], i) => (
|
||||
<LinkPreviewWidget
|
||||
mediaVisible={mediaVisible}
|
||||
key={link}
|
||||
link={link}
|
||||
preview={preview}
|
||||
mxEvent={mxEvent}
|
||||
>
|
||||
{i === 0 ? (
|
||||
<AccessibleButton
|
||||
className="mx_LinkPreviewGroup_hide"
|
||||
onClick={onCancelClick}
|
||||
aria-label={_t("timeline|url_preview|close")}
|
||||
>
|
||||
<CloseIcon width="20px" height="20px" />
|
||||
</AccessibleButton>
|
||||
) : undefined}
|
||||
</LinkPreviewWidget>
|
||||
))}
|
||||
{toggleButton}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fetchPreviews = (cli: MatrixClient, links: string[], ts: number): Promise<[string, IPreviewUrlResponse][]> => {
|
||||
return Promise.all<[string, IPreviewUrlResponse] | void>(
|
||||
links.map(async (link): Promise<[string, IPreviewUrlResponse] | undefined> => {
|
||||
try {
|
||||
const preview = await cli.getUrlPreview(link, ts);
|
||||
// Ensure at least one of the rendered fields is truthy
|
||||
if (
|
||||
preview?.["og:image"]?.startsWith("mxc://") ||
|
||||
!!preview?.["og:description"] ||
|
||||
!!preview?.["og:title"]
|
||||
) {
|
||||
return [link, preview];
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixError && error.httpStatus === 404) {
|
||||
// Quieten 404 Not found errors, not all URLs can have a preview generated
|
||||
logger.debug("Failed to get URL preview: ", error);
|
||||
} else {
|
||||
logger.error("Failed to get URL preview: ", error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
).then((a) => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
|
||||
};
|
||||
|
||||
export default LinkPreviewGroup;
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2016-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 JSX, type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { decode } from "html-entities";
|
||||
import { type MatrixEvent, type IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { LinkedText } from "@element-hq/web-shared-components";
|
||||
|
||||
import Modal from "../../../Modal";
|
||||
import * as ImageUtils from "../../../ImageUtils";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import LinkWithTooltip from "../elements/LinkWithTooltip";
|
||||
import PlatformPeg from "../../../PlatformPeg";
|
||||
|
||||
interface IProps {
|
||||
link: string;
|
||||
preview: IPreviewUrlResponse;
|
||||
mxEvent: MatrixEvent; // the Event associated with the preview
|
||||
children?: ReactNode;
|
||||
mediaVisible: boolean;
|
||||
}
|
||||
|
||||
export default class LinkPreviewWidget extends React.Component<IProps> {
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private onImageClick = (ev: React.MouseEvent): void => {
|
||||
const p = this.props.preview;
|
||||
if (ev.button != 0 || ev.metaKey) return;
|
||||
ev.preventDefault();
|
||||
|
||||
let src: string | null | undefined = p["og:image"];
|
||||
if (src?.startsWith("mxc://")) {
|
||||
src = mediaFromMxc(src).srcHttp;
|
||||
}
|
||||
|
||||
if (!src) return;
|
||||
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: src,
|
||||
width: p["og:image:width"],
|
||||
height: p["og:image:height"],
|
||||
name: p["og:title"] || p["og:description"] || this.props.link,
|
||||
fileSize: p["matrix:image:size"],
|
||||
link: this.props.link,
|
||||
};
|
||||
|
||||
if (this.image.current) {
|
||||
const clientRect = this.image.current.getBoundingClientRect();
|
||||
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const p = this.props.preview;
|
||||
|
||||
// FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing?
|
||||
let image: string | null = p["og:image"] ?? null;
|
||||
if (!this.props.mediaVisible) {
|
||||
image = null; // Don't render a button to show the image, just hide it outright
|
||||
}
|
||||
const imageMaxWidth = 100;
|
||||
const imageMaxHeight = 100;
|
||||
if (image && image.startsWith("mxc://")) {
|
||||
// We deliberately don't want a square here, so use the source HTTP thumbnail function
|
||||
image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, "scale");
|
||||
}
|
||||
|
||||
const thumbHeight =
|
||||
ImageUtils.thumbHeight(p["og:image:width"], p["og:image:height"], imageMaxWidth, imageMaxHeight) ??
|
||||
imageMaxHeight;
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
if (image) {
|
||||
img = (
|
||||
<div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
|
||||
<img
|
||||
ref={this.image}
|
||||
style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }}
|
||||
src={image}
|
||||
onClick={this.onImageClick}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The description includes &-encoded HTML entities, we decode those as React treats the thing as an
|
||||
// opaque string. This does not allow any HTML to be injected into the DOM.
|
||||
const description = decode(p["og:description"] || "");
|
||||
|
||||
const title = p["og:title"]?.trim() ?? "";
|
||||
const anchor = (
|
||||
<a href={this.props.link} target="_blank" rel="noreferrer noopener">
|
||||
{title}
|
||||
</a>
|
||||
);
|
||||
const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== title;
|
||||
|
||||
return (
|
||||
<div className="mx_LinkPreviewWidget">
|
||||
<div className="mx_LinkPreviewWidget_wrapImageCaption">
|
||||
{img}
|
||||
<div className="mx_LinkPreviewWidget_caption">
|
||||
<div className="mx_LinkPreviewWidget_title">
|
||||
{needsTooltip ? (
|
||||
<LinkWithTooltip tooltip={new URL(this.props.link, window.location.href).toString()}>
|
||||
{anchor}
|
||||
</LinkWithTooltip>
|
||||
) : (
|
||||
anchor
|
||||
)}
|
||||
{p["og:site_name"] && (
|
||||
<span className="mx_LinkPreviewWidget_siteName">{" - " + p["og:site_name"]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx_LinkPreviewWidget_description">
|
||||
<LinkedText>{description}</LinkedText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3740,14 +3740,7 @@
|
||||
"one_user": "%(displayName)s is typing …",
|
||||
"two_users": "%(names)s and %(lastPerson)s are typing …"
|
||||
},
|
||||
"undecryptable_tooltip": "This message could not be decrypted",
|
||||
"url_preview": {
|
||||
"close": "Close preview",
|
||||
"show_n_more": {
|
||||
"one": "Show %(count)s other preview",
|
||||
"other": "Show %(count)s other previews"
|
||||
}
|
||||
}
|
||||
"undecryptable_tooltip": "This message could not be decrypted"
|
||||
},
|
||||
"truncated_list_n_more": {
|
||||
"other": "And %(count)s more..."
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* 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,
|
||||
type UrlPreviewGroupViewSnapshot,
|
||||
type UrlPreviewGroupViewActions,
|
||||
type UrlPreviewViewSnapshotPreview,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { decode } from "html-entities";
|
||||
|
||||
import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { thumbHeight } from "../../ImageUtils";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
const logger = rootLogger.getChild("UrlPreviewViewModel");
|
||||
|
||||
export interface UrlPreviewViewModelProps {
|
||||
client: MatrixClient;
|
||||
mxEvent: MatrixEvent;
|
||||
mediaVisible: boolean;
|
||||
visible: boolean;
|
||||
onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void;
|
||||
}
|
||||
|
||||
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
|
||||
export const PREVIEW_WIDTH = 100;
|
||||
export const PREVIEW_HEIGHT = 100;
|
||||
|
||||
export enum PreviewVisibility {
|
||||
/**
|
||||
* Preview is entirely hidden from view and can not be changed.
|
||||
*/
|
||||
Hidden,
|
||||
/**
|
||||
* Preview is entirely hidden from view but the user may change this.
|
||||
*/
|
||||
UserHidden,
|
||||
/**
|
||||
* Preview is visible but media should not be rendered.
|
||||
*/
|
||||
MediaHidden,
|
||||
/**
|
||||
* Preview is visible and media should be rendered.
|
||||
*/
|
||||
Visible,
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for fetching and rendering URL previews for an individual event.
|
||||
*/
|
||||
export class UrlPreviewViewModel
|
||||
extends BaseViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewViewModelProps>
|
||||
implements UrlPreviewGroupViewActions
|
||||
{
|
||||
/**
|
||||
* Parse a numeric value from OpenGraph. The OpenGraph spec defines all values as strings
|
||||
* although Synapse may return these values as numbers. To be compatible, test strings
|
||||
* and numbers.
|
||||
* @param value The numeric value
|
||||
* @returns A number if the value parsed correctly, or undefined otherwise.
|
||||
*/
|
||||
private static getNumberFromOpenGraph(value: number | string | undefined): number | undefined {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
} else if (typeof value === "string" && value) {
|
||||
const i = parseInt(value, 10);
|
||||
if (!isNaN(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the best possible title from an opengraph response.
|
||||
* @param response The opengraph response
|
||||
* @param link The link being used to preview.
|
||||
* @returns The title value.
|
||||
*/
|
||||
private static getBaseMetadataFromResponse(
|
||||
response: IPreviewUrlResponse,
|
||||
link: string,
|
||||
): Pick<UrlPreviewViewSnapshotPreview, "title" | "description" | "siteName"> {
|
||||
let title =
|
||||
typeof response["og:title"] === "string" && response["og:title"].trim()
|
||||
? response["og:title"].trim()
|
||||
: undefined;
|
||||
let description =
|
||||
typeof response["og:description"] === "string" && response["og:description"].trim()
|
||||
? response["og:description"].trim()
|
||||
: undefined;
|
||||
let siteName =
|
||||
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
|
||||
? response["og:site_name"].trim()
|
||||
: undefined;
|
||||
|
||||
if (!title && description) {
|
||||
title = description;
|
||||
description = undefined;
|
||||
} else if (!title && siteName) {
|
||||
title = siteName;
|
||||
siteName = undefined;
|
||||
} else if (!title) {
|
||||
title = link;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description && decode(description),
|
||||
siteName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an anchor element can be rendered into a preview.
|
||||
* If it can, return the value of `href`
|
||||
* @param node The anchor element DOM node.
|
||||
* @returns The value of the `href` of the node, or null if this node cannot be previewed.
|
||||
*/
|
||||
private static getAnchorLink(node: HTMLAnchorElement): string | null {
|
||||
// don't try to preview relative links
|
||||
const href = node.getAttribute("href");
|
||||
if (!href || !URL.canParse(href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(href);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return null;
|
||||
}
|
||||
// never preview permalinks (if anything we should give a smart
|
||||
// preview of the room/user they point to: nobody needs to be reminded
|
||||
// what the matrix.to site looks like).
|
||||
if (isPermalinkHost(url.host)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// as a random heuristic to avoid highlighting things like "foo.pl"
|
||||
// we require the linked text to either include a / (either from http://
|
||||
// or from a full foo.bar/baz style schemeless URL) - or be a markdown-style
|
||||
// link, in which case we check the target text differs from the link value.
|
||||
if (node.textContent?.includes("/")) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (node.textContent?.toLowerCase().trim().startsWith(url.host.toLowerCase())) {
|
||||
// it's a "foo.pl" style link
|
||||
return null;
|
||||
}
|
||||
// it's a [foo bar](http://foo.com) style link
|
||||
return href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the set of links from a set of DOM nodes.
|
||||
* @param nodes An array of DOM elements that may be or contain anchor elements.
|
||||
* @returns A unique array of links that can be previewed, in order of discovery.
|
||||
*/
|
||||
private static findLinks(nodes: Iterable<Element>): string[] {
|
||||
let links = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.tagName === "A") {
|
||||
const href = this.getAnchorLink(node as HTMLAnchorElement);
|
||||
if (href) {
|
||||
links.add(href);
|
||||
}
|
||||
} else if (node.tagName === "PRE" || node.tagName === "CODE" || node.tagName === "BLOCKQUOTE") {
|
||||
continue;
|
||||
} else if (node.children && node.children.length) {
|
||||
links = new Set([...links, ...this.findLinks(node.children)]);
|
||||
}
|
||||
}
|
||||
return [...links];
|
||||
}
|
||||
|
||||
private readonly client: MatrixClient;
|
||||
private readonly storageKey: string;
|
||||
private readonly eventSendTime: number;
|
||||
private readonly useCompactLayoutSettingWatcher: string;
|
||||
|
||||
/**
|
||||
* Should the URL preview render according to the application.
|
||||
*/
|
||||
private urlPreviewVisible: boolean;
|
||||
/**
|
||||
* Should media be rendered in the preview.
|
||||
*/
|
||||
private mediaVisible: boolean;
|
||||
/**
|
||||
* Has the user opted to render this individual preview, or hide it.
|
||||
*/
|
||||
private urlPreviewEnabledByUser: boolean;
|
||||
|
||||
/**
|
||||
* Calculated set of links from the provided DOM element.
|
||||
*/
|
||||
private links: Array<string> = [];
|
||||
|
||||
/**
|
||||
* Should the preview limit how many links are rendered. If `false`, all
|
||||
* links will be rendered.
|
||||
*/
|
||||
private limitPreviews = true;
|
||||
|
||||
/**
|
||||
* A cache containing all previously calculated previews.
|
||||
*/
|
||||
private readonly previewCache = new Map<string, UrlPreviewViewSnapshotPreview>();
|
||||
|
||||
/**
|
||||
* Called when the user clicks on the preview thumbnail.
|
||||
*/
|
||||
public readonly onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void;
|
||||
|
||||
public constructor(props: UrlPreviewViewModelProps) {
|
||||
const storageKey = `hide_preview_${props.mxEvent.getId()}`;
|
||||
super(props, {
|
||||
previews: [],
|
||||
totalPreviewCount: 0,
|
||||
previewsLimited: true,
|
||||
overPreviewLimit: false,
|
||||
compactLayout: SettingsStore.getValue("useCompactLayout"),
|
||||
});
|
||||
this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1";
|
||||
this.urlPreviewVisible = props.visible;
|
||||
this.mediaVisible = props.mediaVisible;
|
||||
this.storageKey = storageKey;
|
||||
this.client = props.client;
|
||||
this.eventSendTime = props.mxEvent.getTs();
|
||||
this.onImageClick = props.onImageClicked;
|
||||
this.useCompactLayoutSettingWatcher = SettingsStore.watchSetting(
|
||||
"useCompactLayout",
|
||||
null,
|
||||
(_setting, _roomid, _level, compactLayout) => {
|
||||
this.snapshot.merge({
|
||||
compactLayout,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a complete preview of a given URL.
|
||||
* Will always return a cached response if it was previously calculated.
|
||||
* @param link A URL to be previewed.
|
||||
* @returns A Promise that returns the snapshot needed to render the preview, or null
|
||||
* if the resource could not be previewed.
|
||||
*/
|
||||
private async fetchPreview(link: string): Promise<UrlPreviewViewSnapshotPreview | null> {
|
||||
const cached = this.previewCache.get(link);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
let preview: IPreviewUrlResponse;
|
||||
|
||||
try {
|
||||
preview = await this.client.getUrlPreview(link, this.eventSendTime);
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixError && error.httpStatus === 404) {
|
||||
// Quieten 404 Not found errors, not all URLs can have a preview generated
|
||||
logger.debug("Failed to get URL preview: ", error);
|
||||
} else {
|
||||
logger.error("Failed to get URL preview: ", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, description, siteName } = UrlPreviewViewModel.getBaseMetadataFromResponse(preview, link);
|
||||
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
|
||||
// Ensure we have something relevant to render.
|
||||
// The title must not just be the link, or we must have an image.
|
||||
if (title === link && !hasImage) {
|
||||
return null;
|
||||
}
|
||||
let image: UrlPreviewViewSnapshotPreview["image"];
|
||||
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
|
||||
const media = mediaFromMxc(preview["og:image"], this.client);
|
||||
const declaredHeight = UrlPreviewViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
|
||||
const declaredWidth = UrlPreviewViewModel.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: UrlPreviewViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
link,
|
||||
title,
|
||||
description,
|
||||
siteName,
|
||||
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
|
||||
image,
|
||||
} satisfies UrlPreviewViewSnapshotPreview;
|
||||
this.previewCache.set(link, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
SettingsStore.unwatchSetting(this.useCompactLayoutSettingWatcher);
|
||||
}
|
||||
|
||||
private get visibility(): PreviewVisibility {
|
||||
if (!this.urlPreviewVisible) {
|
||||
return PreviewVisibility.Hidden;
|
||||
} else if (!this.urlPreviewEnabledByUser) {
|
||||
return PreviewVisibility.UserHidden;
|
||||
} else if (!this.mediaVisible) {
|
||||
return PreviewVisibility.MediaHidden;
|
||||
}
|
||||
return PreviewVisibility.Visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the snapshot for the view model, generating previews
|
||||
* for the previously-calculated links.
|
||||
*/
|
||||
private async computeSnapshot(): Promise<void> {
|
||||
const previews =
|
||||
this.visibility <= PreviewVisibility.UserHidden
|
||||
? []
|
||||
: await Promise.all(
|
||||
this.links
|
||||
.slice(0, this.limitPreviews ? MAX_PREVIEWS_WHEN_LIMITED : undefined)
|
||||
.map((link) => this.fetchPreview(link)),
|
||||
);
|
||||
this.snapshot.merge({
|
||||
previews: previews.filter((m) => !!m),
|
||||
totalPreviewCount: this.links.length,
|
||||
previewsLimited: this.limitPreviews,
|
||||
overPreviewLimit: this.links.length > MAX_PREVIEWS_WHEN_LIMITED,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a recalculation of the links in an event.
|
||||
* @param eventElement
|
||||
*/
|
||||
public async updateEventElement(eventElement: HTMLDivElement): Promise<void> {
|
||||
const newLinks = UrlPreviewViewModel.findLinks([eventElement]);
|
||||
// Only recalculate if the set of links has changed.
|
||||
if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) {
|
||||
this.links = newLinks;
|
||||
return this.computeSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the view model about the status of whether the event should be
|
||||
* viewable.
|
||||
* @param urlPreviewVisible Whether URL previews are hidden for this room.
|
||||
* @param mediaVisible Whether media is hidden for this room or event.
|
||||
*
|
||||
* @returns A promise that completes when the snapshot has been recomputed.
|
||||
*/
|
||||
public readonly updateHidden = (urlPreviewVisible: boolean, mediaVisible: boolean): Promise<void> => {
|
||||
this.urlPreviewVisible = urlPreviewVisible;
|
||||
this.mediaVisible = mediaVisible;
|
||||
// Changing the visibility here means we need to clear cache as we may need to load
|
||||
// the media again.
|
||||
this.previewCache.clear();
|
||||
return this.computeSnapshot();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the user has requsted previews be visible. The provided
|
||||
* props `urlPreviewVisible` state will always override this.
|
||||
*
|
||||
* @returns A promise that completes when the snapshot has been recomputed.
|
||||
*/
|
||||
public readonly onShowClick = (): Promise<void> => {
|
||||
// FIXME: persist this somewhere smarter than local storage
|
||||
this.urlPreviewEnabledByUser = true;
|
||||
globalThis.localStorage?.removeItem(this.storageKey);
|
||||
return this.computeSnapshot();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the user has requsted previews be hidden. Will take precedence
|
||||
* over other settings.
|
||||
*
|
||||
* @returns A promise that completes when the snapshot has been recomputed.
|
||||
*/
|
||||
public readonly onHideClick = (): Promise<void> => {
|
||||
// FIXME: persist this somewhere smarter than local storage
|
||||
globalThis.localStorage?.setItem(this.storageKey, "1");
|
||||
this.urlPreviewEnabledByUser = false;
|
||||
return this.computeSnapshot();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the user toggles the number of previews visible.
|
||||
*
|
||||
* @returns A promise that completes when the snapshot has been recomputed.
|
||||
*/
|
||||
public readonly onTogglePreviewLimit = (): Promise<void> => {
|
||||
this.limitPreviews = !this.limitPreviews;
|
||||
return this.computeSnapshot();
|
||||
};
|
||||
|
||||
/**
|
||||
* `true` only when the user has chosen to hide previews.
|
||||
*/
|
||||
public get isPreviewHiddenByUser(): boolean {
|
||||
return this.visibility === PreviewVisibility.UserHidden;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function getRoomContext(room: Room, override: Partial<RoomContextType>):
|
||||
showJoinLeaves: true,
|
||||
showAvatarChanges: true,
|
||||
showDisplaynameChanges: true,
|
||||
showUrlPreview: false,
|
||||
matrixClientIsReady: false,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
mainSplitContentType: MainSplitContentType.Timeline,
|
||||
|
||||
@@ -469,7 +469,10 @@ describe("<TextualBody />", () => {
|
||||
expect(container.querySelector(".mx_LinkPreviewGroup")).toBeNull();
|
||||
|
||||
getComponent({ mxEvent: ev, showUrlPreview: true }, matrixClient, rerender);
|
||||
expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy();
|
||||
waitFor(() => {
|
||||
// Asynchronous check since the VM needs to recalcuate.
|
||||
expect(container.querySelector(".mx_LinkPreviewGroup")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("<SendMessageComposer/>", () => {
|
||||
showJoinLeaves: true,
|
||||
showAvatarChanges: true,
|
||||
showDisplaynameChanges: true,
|
||||
showUrlPreview: false,
|
||||
matrixClientIsReady: false,
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
mainSplitContentType: MainSplitContentType.Timeline,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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 { expect } from "@jest/globals";
|
||||
|
||||
import type { MockedObject } from "jest-mock";
|
||||
import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { UrlPreviewViewModel } from "../../../src/viewmodels/message-body/UrlPreviewViewModel";
|
||||
import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components";
|
||||
import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils";
|
||||
|
||||
const IMAGE_MXC = "mxc://example.org/abc";
|
||||
const BASIC_PREVIEW_OGDATA = {
|
||||
"og:title": "This is an example!",
|
||||
"og:description": "This is a description",
|
||||
"og:type": "document",
|
||||
"og:url": "https://example.org",
|
||||
"og:site_name": "Example.org",
|
||||
};
|
||||
|
||||
function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): {
|
||||
vm: UrlPreviewViewModel;
|
||||
client: MockedObject<MatrixClient>;
|
||||
onImageClicked: jest.Mock<void, [UrlPreviewViewSnapshotPreview]>;
|
||||
} {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
getUrlPreview: jest.fn(),
|
||||
mxcUrlToHttp: jest.fn(),
|
||||
});
|
||||
const onImageClicked = jest.fn<void, [UrlPreviewViewSnapshotPreview]>();
|
||||
const vm = new UrlPreviewViewModel({
|
||||
client,
|
||||
mediaVisible,
|
||||
visible,
|
||||
onImageClicked,
|
||||
mxEvent: mkEvent({
|
||||
event: true,
|
||||
user: "@foo:bar",
|
||||
type: "m.room.message",
|
||||
content: {},
|
||||
id: "$id",
|
||||
}),
|
||||
});
|
||||
return { vm, client, onImageClicked };
|
||||
}
|
||||
|
||||
describe("UrlPreviewViewModel", () => {
|
||||
it("should return no previews by default", () => {
|
||||
expect(getViewModel().vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it("should preview a single valid URL", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it("should preview nested URLs but ignore some element types", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
vm.onTogglePreviewLimit();
|
||||
client.getUrlPreview.mockResolvedValue(BASIC_PREVIEW_OGDATA);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = `
|
||||
<ul>
|
||||
<a href="https://example.org/1">Test1</a>
|
||||
<li><a href="https://example.org/2">Test2</a></li>
|
||||
<li>
|
||||
<ol>
|
||||
<li><a href="https://example.org/3">Test3</a></li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
<pre><a href="https://example.org">Test4</a></pre>
|
||||
<code><a href="https://example.org">Test5</a></code>
|
||||
<blockquote><a href="https://example.org">Test6</a></blockquote>`;
|
||||
await vm.updateEventElement(msg);
|
||||
const { previews } = vm.getSnapshot();
|
||||
expect(previews).toHaveLength(3);
|
||||
expect(previews).toMatchObject([
|
||||
{
|
||||
link: "https://example.org/1",
|
||||
},
|
||||
{
|
||||
link: "https://example.org/2",
|
||||
},
|
||||
{
|
||||
link: "https://example.org/3",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should hide preview when invisible", async () => {
|
||||
const { vm, client } = getViewModel({ visible: false, mediaVisible: true });
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
expect(client.getUrlPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should preview a URL with media", async () => {
|
||||
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": 10000,
|
||||
});
|
||||
// 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";
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it("should ignore media when mediaVisible is false", async () => {
|
||||
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
|
||||
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": 10000,
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
expect(client.mxcUrlToHttp).not.toHaveBeenCalled();
|
||||
});
|
||||
it("should deduplicate multiple versions of the same URL", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML =
|
||||
'<a href="https://example.org">Test</a><a href="https://example.org">Test</a><a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
expect(client.getUrlPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should ignore failed previews", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockRejectedValue(new Error("Forced test failure"));
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it("should handle image clicks", async () => {
|
||||
const { vm, client, onImageClicked } = 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": 10000,
|
||||
});
|
||||
// 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";
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
const { previews } = vm.getSnapshot();
|
||||
vm.onImageClick(previews[0]);
|
||||
expect(onImageClicked).toHaveBeenCalled();
|
||||
});
|
||||
it("should handle being hidden and shown by the user", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
await vm.onHideClick();
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
|
||||
await vm.onShowClick();
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ text: "", href: "", hasPreview: false },
|
||||
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
|
||||
{ text: "matrix link", href: "https://matrix.to", hasPreview: false },
|
||||
{ text: "email", href: "mailto:example.org", hasPreview: false },
|
||||
{ text: "", href: "https://example.org", hasPreview: true },
|
||||
])("handles different kinds of links %s", async (item) => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce(BASIC_PREVIEW_OGDATA);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = `<a href="${item.href}">${item.text}</a>`;
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews).toHaveLength(item.hasPreview ? 1 : 0);
|
||||
});
|
||||
|
||||
// og:url, og:type are ignored.
|
||||
const baseOg = {
|
||||
"og:url": "https://example.org",
|
||||
"og:type": "document",
|
||||
};
|
||||
|
||||
it.each<IPreviewUrlResponse>([
|
||||
{ ...baseOg, "og:title": "Basic title" },
|
||||
{ ...baseOg, "og:site_name": "Site name", "og:title": "" },
|
||||
{ ...baseOg, "og:description": "A description", "og:title": "" },
|
||||
{ ...baseOg, "og:title": "Cool blog", "og:site_name": "Cool site" },
|
||||
{
|
||||
...baseOg,
|
||||
"og:title": "Media test",
|
||||
// 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,
|
||||
"og:image": IMAGE_MXC,
|
||||
},
|
||||
])("handles different kinds of opengraph responses %s", async (og) => {
|
||||
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(og);
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = `<a href="https://example.org">test</a>`;
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`UrlPreviewViewModel 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`] = `
|
||||
{
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "A description",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel 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`] = `
|
||||
{
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "Site name",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = `
|
||||
{
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "Basic title",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel 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`] = `
|
||||
{
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": "Cool site",
|
||||
"title": "Cool blog",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel 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`] = `
|
||||
{
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"fileSize": 1024,
|
||||
"height": 100,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"width": 100,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "Media test",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should deduplicate multiple versions of the same URL 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should handle being hidden and shown by the user 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should handle being hidden and shown by the user 2`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should hide preview when invisible 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should ignore failed previews 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should ignore media when mediaVisible is false 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should preview a URL with media 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"fileSize": 10000,
|
||||
"height": 100,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"width": 100,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should preview a single valid URL 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewViewModel should return no previews by default 1`] = `
|
||||
{
|
||||
"compactLayout": false,
|
||||
"overPreviewLimit": false,
|
||||
"previews": [],
|
||||
"previewsLimited": true,
|
||||
"totalPreviewCount": 0,
|
||||
}
|
||||
`;
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
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.
|
||||
@@ -12,3 +13,9 @@ declare module "*.md?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
// For importing PNGs for use in testing
|
||||
declare module "*.png" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 <button> styles */
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
/* Consistent image size */
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
> a {
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
word-wrap: break-word;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 imageFile from "../../../static/element.png";
|
||||
import { LinkPreview } from "./LinkPreview";
|
||||
import { LinkedTextContext } from "../../utils/LinkedText";
|
||||
|
||||
export default {
|
||||
title: "Event/UrlPreviewView",
|
||||
component: LinkPreview,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
onImageClick: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof LinkPreview>;
|
||||
|
||||
const Template: StoryFn<typeof LinkPreview> = (args) => (
|
||||
<LinkedTextContext.Provider value={{}}>
|
||||
<LinkPreview {...args} />
|
||||
</LinkedTextContext.Provider>
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
export const Title = Template.bind({});
|
||||
Title.args = {
|
||||
title: "A simple title",
|
||||
link: "https://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",
|
||||
};
|
||||
|
||||
export const WithTooltip = Template.bind({});
|
||||
WithTooltip.args = {
|
||||
title: "A simple title",
|
||||
description: "A simple description",
|
||||
showTooltipOnLink: true,
|
||||
link: "https://matrix.org",
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./LinkPreview.stories.tsx";
|
||||
|
||||
const { Default, WithTooltip, Title, TitleAndDescription } = composeStories(stories);
|
||||
|
||||
describe("LinkPreview", () => {
|
||||
it("renders a preview", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a preview with just a title", () => {
|
||||
const { container } = render(<Title />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a preview with just a title and description", () => {
|
||||
const { container } = render(<TitleAndDescription />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders a preview with a tooltip", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WithTooltip />);
|
||||
await user.tab();
|
||||
expect(screen.getByText("A simple title")).toHaveFocus();
|
||||
// Tooltip has the URL
|
||||
expect(await screen.findByText("https://matrix.org/")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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, { type MouseEventHandler, type JSX, useCallback, useMemo } from "react";
|
||||
import { Tooltip, Text } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useI18n } from "../../utils/i18nContext";
|
||||
import styles from "./LinkPreview.module.css";
|
||||
import type { UrlPreviewViewSnapshotPreview } from "./types";
|
||||
import { LinkedText } from "../../utils/LinkedText";
|
||||
|
||||
export interface LinkPreviewActions {
|
||||
onImageClick: () => void;
|
||||
}
|
||||
|
||||
export type LinkPreviewProps = UrlPreviewViewSnapshotPreview & LinkPreviewActions;
|
||||
|
||||
/**
|
||||
* LinkPreview renders a single preview component for a single link on an event. It is usually rendered as part of
|
||||
* a `UrlPreviewGroupView`.
|
||||
*/
|
||||
export function LinkPreview({ onImageClick, ...preview }: LinkPreviewProps): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
|
||||
const tooltipCaption = useMemo(() => {
|
||||
if (preview.showTooltipOnLink) {
|
||||
return new URL(preview.link, window.location.href).toString();
|
||||
}
|
||||
return null;
|
||||
}, [preview.link, preview.showTooltipOnLink]);
|
||||
|
||||
const onImageClickHandler = useCallback<MouseEventHandler>(
|
||||
(ev) => {
|
||||
if (ev.button != 0 || ev.metaKey) return;
|
||||
ev.preventDefault();
|
||||
|
||||
if (!preview.image?.imageFull) {
|
||||
return;
|
||||
}
|
||||
onImageClick();
|
||||
},
|
||||
[preview.image?.imageFull, onImageClick],
|
||||
);
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
// Don't render a button to show the image, just hide it outright
|
||||
if (preview.image?.imageThumb) {
|
||||
img = (
|
||||
<button
|
||||
aria-label={_t("timeline|url_preview|view_image")}
|
||||
className={styles.image}
|
||||
onClick={onImageClickHandler}
|
||||
>
|
||||
<img className={styles.thumbnail} src={preview.image.imageThumb} alt="" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a className={styles.link} href={preview.link} target="_blank" rel="noreferrer noopener">
|
||||
{preview.title}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<div className={classNames(styles.container)}>
|
||||
<div className={styles.wrapImageCaption}>
|
||||
{img}
|
||||
<div className={styles.caption}>
|
||||
<Text type="body" size="md" className={styles.title}>
|
||||
{tooltipCaption ? <Tooltip label={tooltipCaption}>{anchor}</Tooltip> : anchor}
|
||||
{preview.siteName && (
|
||||
<Text as="span" size="md" weight="regular">
|
||||
{" - " + preview.siteName}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
{preview.description && (
|
||||
<LinkedText className={styles.description}>{preview.description}</LinkedText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.previewGroup {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
margin-bottom: var(--cpd-space-4x);
|
||||
|
||||
&.compactLayout {
|
||||
gap: var(--cpd-space-2x);
|
||||
margin-bottom: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
.toggleButton[data-kind="tertiary"] {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-decoration: none;
|
||||
color: var(--cpd-color-icon-accent-primary);
|
||||
font-weight: var(--cpd-font-weight-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: var(--cpd-space-4x);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import imageFile from "../../../static/element.png";
|
||||
import tallImageFile from "../../../static/tallImage.png";
|
||||
import type { Meta, StoryFn } from "@storybook/react-vite";
|
||||
import {
|
||||
UrlPreviewGroupView,
|
||||
type UrlPreviewGroupViewActions,
|
||||
type UrlPreviewGroupViewSnapshot,
|
||||
} from "./UrlPreviewGroupView";
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { LinkedTextContext } from "../../utils/LinkedText";
|
||||
|
||||
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
|
||||
|
||||
const UrlPreviewGroupViewWrapper = ({
|
||||
onHideClick,
|
||||
onImageClick,
|
||||
onTogglePreviewLimit,
|
||||
...rest
|
||||
}: UrlPreviewGroupViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onHideClick,
|
||||
onImageClick,
|
||||
onTogglePreviewLimit,
|
||||
});
|
||||
return (
|
||||
<LinkedTextContext.Provider value={{}}>
|
||||
<UrlPreviewGroupView vm={vm} />
|
||||
</LinkedTextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Event/UrlPreviewGroupView",
|
||||
component: UrlPreviewGroupViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
onHideClick: fn(),
|
||||
onImageClick: fn(),
|
||||
onTogglePreviewLimit: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof UrlPreviewGroupViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof UrlPreviewGroupViewWrapper> = (args) => <UrlPreviewGroupViewWrapper {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
previews: [
|
||||
{
|
||||
title: "A simple title",
|
||||
description: "A simple description",
|
||||
link: "https://matrix.org",
|
||||
image: {
|
||||
imageThumb: imageFile,
|
||||
imageFull: imageFile,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MultiplePreviewsHidden = Template.bind({});
|
||||
MultiplePreviewsHidden.args = {
|
||||
previews: [
|
||||
{
|
||||
title: "A simple title",
|
||||
description: "A simple description",
|
||||
link: "https://matrix.org",
|
||||
image: {
|
||||
imageThumb: imageFile,
|
||||
imageFull: imageFile,
|
||||
},
|
||||
},
|
||||
],
|
||||
overPreviewLimit: true,
|
||||
previewsLimited: true,
|
||||
totalPreviewCount: 10,
|
||||
};
|
||||
|
||||
export const MultiplePreviewsVisible = Template.bind({});
|
||||
MultiplePreviewsVisible.args = {
|
||||
previews: [
|
||||
{
|
||||
title: "One",
|
||||
description: "A regular square image.",
|
||||
link: "https://matrix.org",
|
||||
image: {
|
||||
imageThumb: imageFile,
|
||||
imageFull: imageFile,
|
||||
},
|
||||
},
|
||||
// These images should appear the same size despite having different dimensions.
|
||||
{
|
||||
title: "Two",
|
||||
description: "This one has a taller image which should crop nicely.",
|
||||
link: "https://matrix.org",
|
||||
image: {
|
||||
imageThumb: tallImageFile,
|
||||
imageFull: tallImageFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Three",
|
||||
description: "One more description",
|
||||
link: "https://matrix.org",
|
||||
image: {
|
||||
imageThumb: imageFile,
|
||||
imageFull: imageFile,
|
||||
},
|
||||
},
|
||||
],
|
||||
overPreviewLimit: true,
|
||||
previewsLimited: false,
|
||||
totalPreviewCount: 10,
|
||||
};
|
||||
|
||||
export const WithCompactView = Template.bind({});
|
||||
WithCompactView.args = {
|
||||
...MultiplePreviewsVisible.args,
|
||||
compactLayout: true,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { render } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
import * as stories from "./UrlPreviewGroupView.stories.tsx";
|
||||
|
||||
const { Default, MultiplePreviewsHidden, MultiplePreviewsVisible, WithCompactView } = composeStories(stories);
|
||||
|
||||
describe("UrlPreviewGroupView", () => {
|
||||
it("renders a single preview", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders multiple previews", () => {
|
||||
const { container } = render(<MultiplePreviewsVisible />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders multiple previews which are hidden", () => {
|
||||
const { container } = render(<MultiplePreviewsHidden />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders with a compact view", () => {
|
||||
const { container } = render(<WithCompactView />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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, { type JSX } from "react";
|
||||
import { Button, IconButton } from "@vector-im/compound-web";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { useI18n } from "../../utils/i18nContext";
|
||||
import type { UrlPreviewViewSnapshotPreview } from "./types";
|
||||
import { LinkPreview } from "./LinkPreview";
|
||||
import styles from "./UrlPreviewGroupView.module.css";
|
||||
|
||||
export interface UrlPreviewGroupViewSnapshot {
|
||||
previews: Array<UrlPreviewViewSnapshotPreview>;
|
||||
totalPreviewCount: number;
|
||||
previewsLimited: boolean;
|
||||
overPreviewLimit: boolean;
|
||||
compactLayout: boolean;
|
||||
}
|
||||
|
||||
export interface UrlPreviewGroupViewProps {
|
||||
vm: ViewModel<UrlPreviewGroupViewSnapshot> & UrlPreviewGroupViewActions;
|
||||
}
|
||||
|
||||
export interface UrlPreviewGroupViewActions {
|
||||
onTogglePreviewLimit: () => void;
|
||||
onHideClick: () => Promise<void>;
|
||||
onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* UrlPreviewGroupView renders a list of URL previews for a single event.
|
||||
*/
|
||||
export function UrlPreviewGroupView({ vm }: UrlPreviewGroupViewProps): JSX.Element | null {
|
||||
const { translate: _t } = useI18n();
|
||||
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit, compactLayout } = useViewModel(vm);
|
||||
if (previews.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let toggleButton: JSX.Element | undefined;
|
||||
if (overPreviewLimit) {
|
||||
toggleButton = (
|
||||
<Button className={styles.toggleButton} kind="tertiary" size="sm" onClick={vm.onTogglePreviewLimit}>
|
||||
{previewsLimited
|
||||
? _t("timeline|url_preview|show_n_more", { count: totalPreviewCount - previews.length })
|
||||
: _t("action|collapse")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={classNames(styles.previewGroup, compactLayout && styles.compactLayout)}>
|
||||
{previews.map((preview) => (
|
||||
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
|
||||
))}
|
||||
{toggleButton}
|
||||
</div>
|
||||
<IconButton size="20px" onClick={vm.onHideClick} aria-label={_t("timeline|url_preview|close")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LinkPreview > renders a preview 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
|
||||
>
|
||||
- Site name
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A simple description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkPreview > renders a preview with just a title 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkPreview > renders a preview with just a title and description 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A simple description with a link to
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,492 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`UrlPreviewGroupView > renders a single preview 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="previewGroup"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A simple description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="previewGroup"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
One
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A regular square image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/tallImage.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Two
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
This one has a taller image which should crop nicely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Three
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
One more description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="_button_13vu4_8 toggleButton"
|
||||
data-kind="tertiary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="previewGroup"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
A simple title
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A simple description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="_button_13vu4_8 toggleButton"
|
||||
data-kind="tertiary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Show 9 other previews
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
<div
|
||||
class="previewGroup compactLayout"
|
||||
>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
One
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
A regular square image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/tallImage.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Two
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
This one has a taller image which should crop nicely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<div
|
||||
class="wrapImageCaption"
|
||||
>
|
||||
<button
|
||||
aria-label="View image"
|
||||
class="image"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="thumbnail"
|
||||
src="/static/element.png"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="caption"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 title"
|
||||
>
|
||||
<a
|
||||
class="link"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
Three
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container description"
|
||||
>
|
||||
One more description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="_button_13vu4_8 toggleButton"
|
||||
data-kind="tertiary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close preview"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 20px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
UrlPreviewGroupView,
|
||||
type UrlPreviewGroupViewSnapshot,
|
||||
type UrlPreviewGroupViewProps,
|
||||
type UrlPreviewGroupViewActions,
|
||||
} from "./UrlPreviewGroupView";
|
||||
|
||||
export { type UrlPreviewViewSnapshotPreview } from "./types";
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface UrlPreviewViewSnapshotPreview {
|
||||
/**
|
||||
* The URL for the preview.
|
||||
*/
|
||||
link: string;
|
||||
/**
|
||||
* Should the link have a tooltip. Should be `true` if the platform does not provide a tooltip.
|
||||
*/
|
||||
showTooltipOnLink?: boolean;
|
||||
/**
|
||||
* The title of the page being previewed.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The site name to be displayed alongside the title.
|
||||
*/
|
||||
siteName?: string;
|
||||
/**
|
||||
* Description of the site. May contain links.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Preview image to display.
|
||||
*/
|
||||
image?: {
|
||||
/**
|
||||
* The HTTP URI of the the thumbnail.
|
||||
*/
|
||||
imageThumb: string;
|
||||
/**
|
||||
* The HTTP URI of the full image.
|
||||
*/
|
||||
imageFull: string;
|
||||
/**
|
||||
* File size in bytes.
|
||||
*/
|
||||
fileSize?: number;
|
||||
/**
|
||||
* The width of the thumbnail. Must not exceed 100px.
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* The height of the thumbnail. Must not exceed 100px.
|
||||
*/
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
},
|
||||
"action": {
|
||||
"back": "Back",
|
||||
"collapse": "Collapse",
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"download": "Download",
|
||||
@@ -184,7 +185,15 @@
|
||||
"unsupported": "The encryption used by this room isn't supported."
|
||||
},
|
||||
"message_timestamp_received_at": "Received at: %(dateTime)s",
|
||||
"message_timestamp_sent_at": "Sent at: %(dateTime)s"
|
||||
"message_timestamp_sent_at": "Sent at: %(dateTime)s",
|
||||
"url_preview": {
|
||||
"close": "Close preview",
|
||||
"show_n_more": {
|
||||
"one": "Show %(count)s other preview",
|
||||
"other": "Show %(count)s other previews"
|
||||
},
|
||||
"view_image": "View image"
|
||||
}
|
||||
},
|
||||
"widget": {
|
||||
"context_menu": {
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./crypto/SasEmoji";
|
||||
export * from "./event-tiles/EncryptionEventView";
|
||||
export * from "./event-tiles/EventTileBubble";
|
||||
export * from "./event-tiles/TextualEventView";
|
||||
export * from "./event-tiles/UrlPreviewView";
|
||||
export * from "./message-body/EventContentBody";
|
||||
export * from "./message-body/MediaBody";
|
||||
export * from "./message-body/MessageTimestampView";
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# static
|
||||
|
||||
This directory contains resources to be used in component stories and tests to demonstate behaviours (e.g. images used in previews).
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 107 KiB |