Redesign link previews (#33061)
* Commit design update * Add figma links * Check in other changes * revert accidental change * Iterative update * linting n test fiddles * linting * Cleanup * update snaps * Move URL previews to new home * Fix paths * compress img * Add back all the stories * Improved rendering * Fixup * Update previews again * lint * update stories * Update snaps again * More screenshots * Also these * Update snaps * include site name * Update snaps again * Use a scale so the images don't go blur * update snaps again * Update snaps * remove mistaken playwright cfg * update pw snaps * update snap * update previews * Update with new designs * Update screenshots
This commit is contained in:
@@ -252,6 +252,7 @@ test.describe("Message url previews", () => {
|
||||
"og:title": "A simple site",
|
||||
"og:description": "And with a brief description",
|
||||
"og:image": mxc,
|
||||
"og:image:alt": "The riot logo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -13,7 +13,6 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even
|
||||
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
|
||||
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
|
||||
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
|
||||
import { type UrlPreview } from "@element-hq/web-shared-components";
|
||||
|
||||
import PageType from "./PageTypes";
|
||||
import Views from "./Views";
|
||||
@@ -151,7 +150,7 @@ export default class PosthogTrackers {
|
||||
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
|
||||
* @param previews The previews generated from the event.
|
||||
*/
|
||||
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
|
||||
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: { image?: unknown }[]): void {
|
||||
// Discount any previews that we have already tracked.
|
||||
if (this.previewedEventIds.get(eventId)) {
|
||||
return;
|
||||
|
||||
@@ -34,8 +34,10 @@ export interface UrlPreviewGroupViewModelProps {
|
||||
}
|
||||
|
||||
export const MAX_PREVIEWS_WHEN_LIMITED = 2;
|
||||
export const PREVIEW_WIDTH = 100;
|
||||
export const PREVIEW_HEIGHT = 100;
|
||||
export const PREVIEW_WIDTH_PX = 478;
|
||||
export const PREVIEW_HEIGHT_PX = 200;
|
||||
export const MIN_PREVIEW_PX = 96;
|
||||
export const MIN_IMAGE_SIZE_BYTES = 8192;
|
||||
|
||||
export enum PreviewVisibility {
|
||||
/**
|
||||
@@ -100,21 +102,26 @@ export class UrlPreviewGroupViewModel
|
||||
typeof response["og:description"] === "string" && response["og:description"].trim()
|
||||
? response["og:description"].trim()
|
||||
: undefined;
|
||||
let siteName =
|
||||
const siteName =
|
||||
typeof response["og:site_name"] === "string" && response["og:site_name"].trim()
|
||||
? response["og:site_name"].trim()
|
||||
: undefined;
|
||||
: new URL(link).hostname;
|
||||
|
||||
// If there is no title, use the description as the title.
|
||||
if (!title && description) {
|
||||
title = description;
|
||||
description = undefined;
|
||||
} else if (!title && siteName) {
|
||||
title = siteName;
|
||||
siteName = undefined;
|
||||
} else if (!title) {
|
||||
title = link;
|
||||
}
|
||||
|
||||
// If the description matches the site name, don't bother with a description.
|
||||
if (description && description.toLowerCase() === siteName.toLowerCase()) {
|
||||
description = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description && decode(description),
|
||||
@@ -122,6 +129,50 @@ export class UrlPreviewGroupViewModel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the best possible author from an opengraph response.
|
||||
* @param response The opengraph response
|
||||
* @returns The author value, or undefined if no valid author could be found.
|
||||
*/
|
||||
private static getAuthorFromResponse(response: IPreviewUrlResponse): UrlPreview["author"] {
|
||||
let calculatedAuthor: string | undefined;
|
||||
if (response["og:type"] === "article") {
|
||||
if (typeof response["article:author"] === "string" && response["article:author"]) {
|
||||
calculatedAuthor = response["article:author"];
|
||||
}
|
||||
// Otherwise fall through to check the profile.
|
||||
}
|
||||
if (typeof response["profile:username"] === "string" && response["profile:username"]) {
|
||||
calculatedAuthor = response["profile:username"];
|
||||
}
|
||||
if (calculatedAuthor && URL.canParse(calculatedAuthor)) {
|
||||
// Some sites return URLs as authors which doesn't look good in Element, so discard it.
|
||||
return;
|
||||
}
|
||||
return calculatedAuthor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate whether the provided image from the preview response is an full size preview or
|
||||
* a site icon.
|
||||
* @returns `true` if the image should be used as a preview, otherwise `false`
|
||||
*/
|
||||
private static isImagePreview(width?: number, height?: number, bytes?: number): boolean {
|
||||
// We can't currently distinguish from a preview image and a favicon. Neither OpenGraph nor Matrix
|
||||
// have a clear distinction, so we're using a heuristic here to check the dimensions & size of the file and
|
||||
// deciding whether to render it as a full preview or icon.
|
||||
if (width && width < MIN_PREVIEW_PX) {
|
||||
return false;
|
||||
}
|
||||
if (height && height < MIN_PREVIEW_PX) {
|
||||
return false;
|
||||
}
|
||||
if (bytes && bytes < MIN_IMAGE_SIZE_BYTES) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an anchor element can be rendered into a preview.
|
||||
* If it can, return the value of `href`
|
||||
@@ -278,6 +329,7 @@ export class UrlPreviewGroupViewModel
|
||||
}
|
||||
|
||||
const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
|
||||
const author = UrlPreviewGroupViewModel.getAuthorFromResponse(preview);
|
||||
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
|
||||
// Ensure we have something relevant to render.
|
||||
// The title must not just be the link, or we must have an image.
|
||||
@@ -285,31 +337,46 @@ export class UrlPreviewGroupViewModel
|
||||
return null;
|
||||
}
|
||||
let image: UrlPreview["image"];
|
||||
let siteIcon: string | undefined;
|
||||
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
|
||||
const media = mediaFromMxc(preview["og:image"], this.client);
|
||||
const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
|
||||
const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
|
||||
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
};
|
||||
const imageSize = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]);
|
||||
const alt = typeof preview["og:image:alt"] === "string" ? preview["og:image:alt"] : undefined;
|
||||
|
||||
const isImagePreview = UrlPreviewGroupViewModel.isImagePreview(declaredWidth, declaredHeight, imageSize);
|
||||
if (isImagePreview) {
|
||||
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX);
|
||||
const height =
|
||||
thumbHeight(width, declaredHeight, PREVIEW_WIDTH_PX, PREVIEW_WIDTH_PX) ?? PREVIEW_WIDTH_PX;
|
||||
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH_PX, PREVIEW_HEIGHT_PX, "scale");
|
||||
const playable = !!preview["og:video"] || !!preview["og:video:type"] || !!preview["og:audio"];
|
||||
// No thumb, no preview.
|
||||
if (thumb) {
|
||||
image = {
|
||||
imageThumb: thumb,
|
||||
imageFull: media.srcHttp ?? thumb,
|
||||
width,
|
||||
height,
|
||||
fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
|
||||
alt,
|
||||
playable,
|
||||
};
|
||||
}
|
||||
} else if (media.srcHttp) {
|
||||
siteIcon = media.srcHttp;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
link,
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
siteName,
|
||||
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
|
||||
siteIcon,
|
||||
showTooltipOnLink: !!(link !== title && PlatformPeg.get()?.needsUrlTooltips()),
|
||||
image,
|
||||
} satisfies UrlPreview;
|
||||
this.previewCache.set(link, result);
|
||||
|
||||
@@ -18,20 +18,10 @@ describe("PosthogTrackers", () => {
|
||||
const tracker = new PosthogTrackers();
|
||||
tracker.trackUrlPreview("$123456", false, [
|
||||
{
|
||||
title: "A preview",
|
||||
image: {
|
||||
imageThumb: "abc",
|
||||
imageFull: "abc",
|
||||
},
|
||||
link: "a-link",
|
||||
},
|
||||
]);
|
||||
tracker.trackUrlPreview("$123456", false, [
|
||||
{
|
||||
title: "A second preview",
|
||||
link: "a-link",
|
||||
image: {},
|
||||
},
|
||||
]);
|
||||
tracker.trackUrlPreview("$123456", false, [{}]);
|
||||
// Ignores subsequent calls.
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "UrlPreviewRendered",
|
||||
|
||||
@@ -125,6 +125,32 @@ describe("UrlPreviewGroupViewModel", () => {
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
it.each<Partial<IPreviewUrlResponse>>([
|
||||
{ "matrix:image:size": 8191 },
|
||||
{ "og:image:width": 95 },
|
||||
{ "og:image:height": 95 },
|
||||
])("should preview a URL with a site icon", async (extraResp) => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce({
|
||||
"og:title": "This is an example!",
|
||||
"og:type": "document",
|
||||
"og:url": "https://example.org",
|
||||
"og:image": IMAGE_MXC,
|
||||
"og:image:height": 128,
|
||||
"og:image:width": 128,
|
||||
"matrix:image:size": 8193,
|
||||
...extraResp,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
client.mxcUrlToHttp.mockImplementation((url) => {
|
||||
expect(url).toEqual(IMAGE_MXC);
|
||||
return "https://example.org/image/src";
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].siteIcon).toBeTruthy();
|
||||
});
|
||||
it("should ignore media when mediaVisible is false", async () => {
|
||||
const { vm, client } = getViewModel({ mediaVisible: false, visible: true });
|
||||
client.getUrlPreview.mockResolvedValueOnce({
|
||||
@@ -200,6 +226,41 @@ describe("UrlPreviewGroupViewModel", () => {
|
||||
expect(vm.getSnapshot()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("calculates author", () => {
|
||||
it("should use the profile:username if provided", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce({ ...BASIC_PREVIEW_OGDATA, "profile:username": "my username" });
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].author).toEqual("my username");
|
||||
});
|
||||
it("should use author if the og:type is an article", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce({
|
||||
...BASIC_PREVIEW_OGDATA,
|
||||
"og:type": "article",
|
||||
"article:author": "my name",
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].author).toEqual("my name");
|
||||
});
|
||||
it("should NOT use author if the author is a URL", async () => {
|
||||
const { vm, client } = getViewModel();
|
||||
client.getUrlPreview.mockResolvedValueOnce({
|
||||
...BASIC_PREVIEW_OGDATA,
|
||||
"og:type": "article",
|
||||
"article:author": "https://junk.example.org/foo",
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = '<a href="https://example.org">Test</a>';
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].author).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ text: "", href: "", hasPreview: false },
|
||||
{ text: "test", href: "noprotocol.example.org", hasPreview: false },
|
||||
@@ -232,7 +293,7 @@ describe("UrlPreviewGroupViewModel", () => {
|
||||
// API *may* return a string, so check we parse correctly.
|
||||
"og:image:height": "500" as unknown as number,
|
||||
"og:image:width": 500,
|
||||
"matrix:image:size": 1024,
|
||||
"matrix:image:size": 10000,
|
||||
"og:image": IMAGE_MXC,
|
||||
},
|
||||
])("handles different kinds of opengraph responses %s", async (og) => {
|
||||
@@ -251,4 +312,25 @@ describe("UrlPreviewGroupViewModel", () => {
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each<string>(["og:video", "og:video:type", "og:audio"])("detects playable links via %s", async (property) => {
|
||||
const { vm, client } = getViewModel();
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
client.mxcUrlToHttp.mockImplementation((url, width) => {
|
||||
expect(url).toEqual(IMAGE_MXC);
|
||||
if (width) {
|
||||
return "https://example.org/image/thumb";
|
||||
}
|
||||
return "https://example.org/image/src";
|
||||
});
|
||||
client.getUrlPreview.mockResolvedValueOnce({
|
||||
...BASIC_PREVIEW_OGDATA,
|
||||
"og:image": IMAGE_MXC,
|
||||
[property]: "anything",
|
||||
});
|
||||
const msg = document.createElement("div");
|
||||
msg.innerHTML = `<a href="https://example.org">test</a>`;
|
||||
await vm.updateEventElement(msg);
|
||||
expect(vm.getSnapshot().previews[0].image?.playable).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
+46
-22
@@ -2,61 +2,73 @@
|
||||
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = `
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "A description",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = `
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Site name",
|
||||
"title": "Site name",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = `
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "Basic title",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = `
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Cool site",
|
||||
"title": "Cool blog",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
|
||||
exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 10000,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"fileSize": 1024,
|
||||
"height": 100,
|
||||
"alt": undefined,
|
||||
"fileSize": 10000,
|
||||
"height": 478,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"width": 100,
|
||||
"playable": false,
|
||||
"width": 478,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "Media test",
|
||||
}
|
||||
`;
|
||||
@@ -67,10 +79,12 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
@@ -96,10 +110,12 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
@@ -135,11 +151,13 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
@@ -154,17 +172,21 @@ exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": undefined,
|
||||
"image": {
|
||||
"alt": undefined,
|
||||
"fileSize": 10000,
|
||||
"height": 100,
|
||||
"height": 128,
|
||||
"imageFull": "https://example.org/image/src",
|
||||
"imageThumb": "https://example.org/image/thumb",
|
||||
"width": 100,
|
||||
"playable": false,
|
||||
"width": 128,
|
||||
},
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"siteName": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
],
|
||||
@@ -179,10 +201,12 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
|
||||
"overPreviewLimit": false,
|
||||
"previews": [
|
||||
{
|
||||
"author": undefined,
|
||||
"description": "This is a description",
|
||||
"image": undefined,
|
||||
"link": "https://example.org",
|
||||
"showTooltipOnLink": undefined,
|
||||
"showTooltipOnLink": false,
|
||||
"siteIcon": undefined,
|
||||
"siteName": "Example.org",
|
||||
"title": "This is an example!",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user