mirror of
https://github.com/zulip/zulip.git
synced 2026-07-09 21:21:47 +08:00
This is the most simple loop, so sticking it at the top keeps it from getting lost among the more complex processing that follows.
305 lines
14 KiB
TypeScript
305 lines
14 KiB
TypeScript
import assert from "minimalistic-assert";
|
|
|
|
import {$t} from "./i18n.ts";
|
|
import * as thumbnail from "./thumbnail.ts";
|
|
import {user_settings} from "./user_settings.ts";
|
|
import * as util from "./util.ts";
|
|
|
|
let inertDocument: Document | undefined;
|
|
|
|
export function postprocess_content(html: string): string {
|
|
inertDocument ??= new DOMParser().parseFromString("", "text/html");
|
|
const template = inertDocument.createElement("template");
|
|
template.innerHTML = html;
|
|
|
|
for (const ol of template.content.querySelectorAll("ol")) {
|
|
const list_start = Number(ol.getAttribute("start") ?? 1);
|
|
// We don't count the first item in the list, as it
|
|
// will be identical to the start value
|
|
const list_length = ol.children.length - 1;
|
|
const max_list_counter = list_start + list_length;
|
|
// We count the characters in the longest list counter,
|
|
// and use that to offset the list accordingly in CSS
|
|
const max_list_counter_string_length = max_list_counter.toString().length;
|
|
ol.classList.add(`counter-length-${max_list_counter_string_length}`);
|
|
// We subtract 1 from list_start, as `count 0` displays 1.
|
|
ol.style.setProperty("counter-reset", `count ${list_start - 1}`);
|
|
}
|
|
|
|
for (const elt of template.content.querySelectorAll("a")) {
|
|
// Ensure that all external links have target="_blank"
|
|
// rel="opener noreferrer". This ensures that external links
|
|
// never replace the Zulip web app while also protecting
|
|
// against reverse tabnapping attacks, without relying on the
|
|
// correctness of how Zulip's Markdown processor generates links.
|
|
//
|
|
// Fragment links, which we intend to only open within the
|
|
// Zulip web app using our hashchange system, do not require
|
|
// these attributes.
|
|
const href = elt.getAttribute("href");
|
|
if (href === null) {
|
|
continue;
|
|
}
|
|
let url;
|
|
try {
|
|
url = new URL(href, window.location.href);
|
|
} catch {
|
|
elt.removeAttribute("href");
|
|
elt.removeAttribute("title");
|
|
continue;
|
|
}
|
|
|
|
// eslint-disable-next-line no-script-url
|
|
if (["data:", "javascript:", "vbscript:"].includes(url.protocol)) {
|
|
// Remove unsafe links completely.
|
|
elt.removeAttribute("href");
|
|
elt.removeAttribute("title");
|
|
continue;
|
|
}
|
|
|
|
// We detect URLs that are just fragments by comparing the URL
|
|
// against a new URL generated using only the hash.
|
|
if (url.hash === "" || url.href !== new URL(url.hash, window.location.href).href) {
|
|
elt.setAttribute("target", "_blank");
|
|
elt.setAttribute("rel", "noopener noreferrer");
|
|
} else {
|
|
elt.removeAttribute("target");
|
|
}
|
|
|
|
if (elt.querySelector("img") || elt.querySelector("video")) {
|
|
// We want a class to refer to media links
|
|
elt.classList.add("media-anchor-element");
|
|
|
|
// Replace the legacy .message_inline_image class, whose
|
|
// name would add confusion when Zulip supports inline
|
|
// images via standard Markdown, with dedicated classes
|
|
// for video and image previews.
|
|
if (elt.querySelector("video")) {
|
|
elt.parentElement?.classList.replace(
|
|
"message_inline_image",
|
|
"message-media-preview-video",
|
|
);
|
|
elt
|
|
.querySelector("video")
|
|
?.classList.add("media-video-element", "media-image-element");
|
|
} else {
|
|
elt.parentElement?.classList.replace(
|
|
"message_inline_image",
|
|
"message-media-preview-image",
|
|
);
|
|
}
|
|
|
|
// This code path adds the media-image-element class to
|
|
// both image preview elements and video thumbnails.
|
|
if (elt.querySelector("img")) {
|
|
elt.querySelector("img")?.classList.add("media-image-element");
|
|
}
|
|
}
|
|
|
|
// Update older, smaller default.jpg YouTube preview images
|
|
// with higher-quality preview images (320px wide)
|
|
if (elt.parentElement?.classList.contains("youtube-video")) {
|
|
const img = elt.querySelector("img");
|
|
assert(img instanceof HTMLImageElement);
|
|
const img_src = img.src;
|
|
if (img_src.endsWith("/default.jpg")) {
|
|
const mq_src = img_src.replace(/\/default.jpg$/, "/mqdefault.jpg");
|
|
img.src = mq_src;
|
|
}
|
|
}
|
|
|
|
// Add a class to the anchor tag on
|
|
if (elt.parentElement?.classList.contains("message_embed_title")) {
|
|
elt.classList.add("message-embed-title-link");
|
|
}
|
|
|
|
if (
|
|
elt.parentElement?.classList.contains("message-media-preview-image") ||
|
|
elt.parentElement?.classList.contains("message-media-preview-video")
|
|
) {
|
|
// For inline images we want to handle the tooltips explicitly, and disable
|
|
// the browser's built in handling of the title attribute.
|
|
const title = elt.getAttribute("title");
|
|
if (title !== null) {
|
|
elt.setAttribute("aria-label", title);
|
|
elt.removeAttribute("title");
|
|
}
|
|
// To prevent layout shifts and flexibly size image previews,
|
|
// we read the image's original dimensions, when present, and
|
|
// set those values as `height` and `width` attributes on the
|
|
// image source.
|
|
const inline_image = elt.querySelector("img");
|
|
if (inline_image?.hasAttribute("data-original-dimensions")) {
|
|
// TODO: Modify eslint config, if we wish to avoid dataset
|
|
//
|
|
/* eslint unicorn/prefer-dom-node-dataset: "off" */
|
|
const original_dimensions_attribute = inline_image.getAttribute(
|
|
"data-original-dimensions",
|
|
);
|
|
assert(original_dimensions_attribute);
|
|
const original_dimensions: string[] = original_dimensions_attribute.split("x");
|
|
assert(
|
|
original_dimensions.length === 2 &&
|
|
typeof original_dimensions[0] === "string" &&
|
|
typeof original_dimensions[1] === "string",
|
|
);
|
|
|
|
const original_width = Number(original_dimensions[0]);
|
|
const original_height = Number(original_dimensions[1]);
|
|
const font_size_in_use = user_settings.web_font_size_px;
|
|
// At 20px/1em, image boxes are 200px by 80px in either
|
|
// horizontal or vertical orientation; 80 / 200 = 0.4
|
|
// We need to show more of the background color behind
|
|
// these extremely tall or extremely wide images, and
|
|
// use a subtler background color than on other images
|
|
const image_min_aspect_ratio = 0.4;
|
|
// "Dinky" images are those that are shorter than the
|
|
// 10em height reserved for thumbnails
|
|
const image_box_em = 10;
|
|
const is_dinky_image = original_height / font_size_in_use <= image_box_em;
|
|
const has_extreme_aspect_ratio =
|
|
original_width / original_height <= image_min_aspect_ratio ||
|
|
original_height / original_width <= image_min_aspect_ratio;
|
|
const is_portrait_image = original_width <= original_height;
|
|
|
|
inline_image.setAttribute("width", `${original_width}`);
|
|
inline_image.setAttribute("height", `${original_height}`);
|
|
|
|
// Despite setting `width` and `height` values above, the
|
|
// flexbox gallery collapses until images have loaded. We
|
|
// therefore have to avoid the layout shift that would
|
|
// otherwise cause by setting the only the width attribute
|
|
// here. And by setting this value in ems, we ensure that
|
|
// images scale as users adjust the information-density
|
|
// settings.
|
|
inline_image.style.setProperty(
|
|
"width",
|
|
`${(image_box_em * font_size_in_use * original_width) / original_height / font_size_in_use}em`,
|
|
);
|
|
|
|
if (is_dinky_image) {
|
|
inline_image.classList.add("dinky-thumbnail");
|
|
// For dinky images, we just set the original width
|
|
inline_image.style.setProperty("width", `${original_width}px`);
|
|
}
|
|
|
|
if (has_extreme_aspect_ratio) {
|
|
inline_image.classList.add("extreme-aspect-ratio");
|
|
}
|
|
|
|
if (is_portrait_image) {
|
|
inline_image.classList.add("portrait-thumbnail");
|
|
} else {
|
|
inline_image.classList.add("landscape-thumbnail");
|
|
}
|
|
}
|
|
} else {
|
|
// For non-image user uploads, the following block ensures that the title
|
|
// attribute always displays the filename as a security measure.
|
|
let title: string;
|
|
let legacy_title: string;
|
|
if (
|
|
url.origin === window.location.origin &&
|
|
url.pathname.startsWith("/user_uploads/")
|
|
) {
|
|
// We add the word "download" to make clear what will
|
|
// happen when clicking the file. This is particularly
|
|
// important in the desktop app, where hovering a URL does
|
|
// not display the URL like it does in the web app.
|
|
title = legacy_title = $t(
|
|
{defaultMessage: "Download {filename}"},
|
|
{
|
|
filename: decodeURIComponent(
|
|
url.pathname.slice(url.pathname.lastIndexOf("/") + 1),
|
|
),
|
|
},
|
|
);
|
|
} else {
|
|
title = url.toString();
|
|
legacy_title = href;
|
|
}
|
|
elt.setAttribute(
|
|
"title",
|
|
["", legacy_title].includes(elt.title) ? title : `${title}\n${elt.title}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const inline_img of template.content.querySelectorAll<HTMLImageElement>(
|
|
".message-media-preview-image img",
|
|
)) {
|
|
inline_img.setAttribute("loading", "lazy");
|
|
// We can't just check whether `inline_image.src` starts with
|
|
// `/user_uploads/thumbnail`, even though that's what the
|
|
// server writes in the markup, because Firefox will have
|
|
// already prepended the origin to the source of an image.
|
|
let image_url;
|
|
try {
|
|
image_url = new URL(inline_img.src, window.location.origin);
|
|
} catch {
|
|
// If the image source URL can't be parsed, likely due to
|
|
// some historical bug in the Markdown processor, just
|
|
// drop the invalid image element.
|
|
inline_img.closest(".message-media-preview-image")!.remove();
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
image_url.origin === window.location.origin &&
|
|
image_url.pathname.startsWith("/user_uploads/thumbnail/")
|
|
) {
|
|
let thumbnail_name = thumbnail.preferred_format.name;
|
|
if (inline_img.dataset.animated === "true") {
|
|
if (
|
|
user_settings.web_animate_image_previews === "always" ||
|
|
// Treat on_hover as "always" on mobile web, where
|
|
// hovering is impossible and there's much less on
|
|
// the screen.
|
|
(user_settings.web_animate_image_previews === "on_hover" && util.is_mobile())
|
|
) {
|
|
thumbnail_name = thumbnail.animated_format.name;
|
|
} else {
|
|
// If we're showing a still thumbnail, show a play
|
|
// button so that users that it can be played.
|
|
inline_img
|
|
.closest(".message-media-preview-image")!
|
|
.classList.add("message_inline_animated_image_still");
|
|
}
|
|
}
|
|
inline_img.src = inline_img.src.replace(/\/[^/]+$/, "/" + thumbnail_name);
|
|
}
|
|
}
|
|
|
|
// After all other processing on images has been done, we look for
|
|
// adjacent images and videos, and tuck them structurally into galleries.
|
|
for (const elt of template.content.querySelectorAll(
|
|
".message-media-preview-image, .message-media-preview-video",
|
|
)) {
|
|
let gallery_element;
|
|
|
|
const is_part_of_open_gallery = elt.previousElementSibling?.classList.contains(
|
|
"message-thumbnail-gallery",
|
|
);
|
|
|
|
if (is_part_of_open_gallery) {
|
|
// If the the current media element's previous sibling is a gallery,
|
|
// it should be kept with the other media in that gallery.
|
|
gallery_element = elt.previousElementSibling;
|
|
} else {
|
|
// Otherwise, we've found an image element that follows some other
|
|
// content (or is the first in the message) and need to create a
|
|
// gallery for it, and perhaps other adjacent sibling media elements,
|
|
// if they exist.
|
|
gallery_element = inertDocument.createElement("div");
|
|
gallery_element.classList.add("message-thumbnail-gallery");
|
|
// We insert the gallery just before the media element we've found
|
|
elt.before(gallery_element);
|
|
}
|
|
|
|
// Finally, the media element gets moved into the current gallery
|
|
gallery_element?.append(elt);
|
|
}
|
|
|
|
return template.innerHTML;
|
|
}
|