Merge branch 'develop' of https://github.com/vector-im/element-web into dbkr/stateafter

# Conflicts:
#	test/unit-tests/components/structures/RoomView-test.tsx
#	test/unit-tests/components/structures/TimelinePanel-test.tsx
This commit is contained in:
Michael Telatynski
2024-11-27 10:47:35 +00:00
438 changed files with 7829 additions and 4692 deletions

View File

@@ -1,124 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Based on...
* ChromaCheck 1.16
* author Roel Nieskens, https://pixelambacht.nl
* MIT license
*/
import { logger } from "matrix-js-sdk/src/logger";
function safariVersionCheck(ua: string): boolean {
logger.log("Browser is Safari - checking version for COLR support");
try {
const safariVersionMatch = ua.match(/Mac OS X ([\d|_]+).*Version\/([\d|.]+).*Safari/);
if (safariVersionMatch) {
const macOSVersionStr = safariVersionMatch[1];
const safariVersionStr = safariVersionMatch[2];
const macOSVersion = macOSVersionStr.split("_").map((n) => parseInt(n, 10));
const safariVersion = safariVersionStr.split(".").map((n) => parseInt(n, 10));
const colrFontSupported =
macOSVersion[0] >= 10 && macOSVersion[1] >= 14 && safariVersion[0] >= 12 && safariVersion[0] < 17;
// https://www.colorfonts.wtf/ states Safari supports COLR fonts from this version on but Safari 17 breaks it
logger.log(
`COLR support on Safari requires macOS 10.14 and Safari 12-16, ` +
`detected Safari ${safariVersionStr} on macOS ${macOSVersionStr}, ` +
`COLR supported: ${colrFontSupported}`,
);
return colrFontSupported;
}
} catch (err) {
logger.error("Error in Safari COLR version check", err);
}
logger.warn("Couldn't determine Safari version to check COLR font support, assuming no.");
return false;
}
async function isColrFontSupported(): Promise<boolean> {
logger.log("Checking for COLR support");
const { userAgent } = navigator;
// Firefox has supported COLR fonts since version 26
// but doesn't support the check below without
// "Extract canvas data" permissions
// when content blocking is enabled.
if (userAgent.includes("Firefox")) {
logger.log("Browser is Firefox - assuming COLR is supported");
return true;
}
// Safari doesn't wait for the font to load (if it doesn't have it in cache)
// to emit the load event on the image, so there is no way to not make the check
// reliable. Instead sniff the version.
// Excluding "Chrome", as it's user agent unhelpfully also contains Safari...
if (!userAgent.includes("Chrome") && userAgent.includes("Safari")) {
return safariVersionCheck(userAgent);
}
try {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
const img = new Image();
// eslint-disable-next-line
const fontCOLR =
"d09GRgABAAAAAAKAAAwAAAAAAowAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDT0xSAAACVAAAABYAAAAYAAIAJUNQQUwAAAJsAAAAEgAAABLJAAAQT1MvMgAAAYAAAAA6AAAAYBfxJ0pjbWFwAAABxAAAACcAAAAsAAzpM2dseWYAAAH0AAAAGgAAABoNIh0kaGVhZAAAARwAAAAvAAAANgxLumdoaGVhAAABTAAAABUAAAAkCAEEAmhtdHgAAAG8AAAABgAAAAYEAAAAbG9jYQAAAewAAAAGAAAABgANAABtYXhwAAABZAAAABsAAAAgAg4AHW5hbWUAAAIQAAAAOAAAAD4C5wsecG9zdAAAAkgAAAAMAAAAIAADAAB4AWNgZGAAYQ5+qdB4fpuvDNIsDCBwaQGTAIi+VlscBaJZGMDiHAxMIAoAtjIF/QB4AWNgZGBgYQACOAkUQQWMAAGRABAAAAB4AWNgZGBgYGJgAdMMUJILJMQgAWICAAH3AC4AeAFjYGFhYJzAwMrAwDST6QwDA0M/hGZ8zWDMyMmAChgFkDgKQMBw4CXDSwYWEBdIYgAFBgYA/8sIdAAABAAAAAAAAAB4AWNgYGBkYAZiBgYeBhYGBSDNAoRA/kuG//8hpDgjWJ4BAFVMBiYAAAAAAAANAAAAAQAAAAAEAAQAAAMAABEhESEEAPwABAD8AAAAeAEtxgUNgAAAAMHHIQTShTlOAty9/4bf7AARCwlBNhBw4L/43qXjYGUmf19TMuLcj/BJL3XfBg54AWNgZsALAAB9AAR4AWNgYGAEYj4gFgGygGwICQACOwAoAAAAAAABAAEAAQAAAA4AAAAAyP8AAA==";
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="100" style="background:#fff;fill:#000;">
<style type="text/css">
@font-face {
font-family: "chromacheck-colr";
src: url(data:application/x-font-woff;base64,${fontCOLR}) format("woff");
}
</style>
<text x="0" y="0" font-size="20">
<tspan font-family="chromacheck-colr" x="0" dy="20">&#xe900;</tspan>
</text>
</svg>`;
canvas.width = 20;
canvas.height = 100;
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
logger.log("Waiting for COLR SVG to load");
await new Promise((resolve) => (img.onload = resolve));
logger.log("Drawing canvas to detect COLR support");
context.drawImage(img, 0, 0);
const colrFontSupported = context.getImageData(10, 10, 1, 1).data[0] === 200;
logger.log("Canvas check revealed COLR is supported? " + colrFontSupported);
return colrFontSupported;
} catch (e) {
logger.error("Couldn't load COLR font", e);
return false;
}
}
let colrFontCheckStarted = false;
export async function fixupColorFonts(): Promise<void> {
if (colrFontCheckStarted) {
return;
}
colrFontCheckStarted = true;
if (await isColrFontSupported()) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2")}')`;
document.fonts.add(new FontFace("Twemoji", path, {}));
// For at least Chrome on Windows 10, we have to explictly add extra
// weights for the emoji to appear in bold messages, etc.
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
} else {
// fall back to SBIX, generated via https://github.com/matrix-org/twemoji-colr/tree/matthew/sbix
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = `url('${require("../../res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2")}')`;
document.fonts.add(new FontFace("Twemoji", path, {}));
document.fonts.add(new FontFace("Twemoji", path, { weight: "600" }));
document.fonts.add(new FontFace("Twemoji", path, { weight: "700" }));
}
// ...and if SBIX is not supported, the browser will fall back to one of the native fonts specified.
}

View File

@@ -7,23 +7,10 @@
* Please see LICENSE files in the repository root for full details.
*/
import {
IContent,
IEventRelation,
MatrixEvent,
MsgType,
THREAD_RELATION_TYPE,
M_BEACON_INFO,
M_POLL_END,
M_POLL_START,
} from "matrix-js-sdk/src/matrix";
import { IContent, IEventRelation, MatrixEvent, THREAD_RELATION_TYPE } from "matrix-js-sdk/src/matrix";
import sanitizeHtml from "sanitize-html";
import escapeHtml from "escape-html";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PERMITTED_URL_SCHEMES } from "./UrlUtils";
import { makeUserPermalink, RoomPermalinkCreator } from "./permalinks/Permalinks";
import { isSelfLocation } from "./location";
export function getParentEventId(ev?: MatrixEvent): string | undefined {
if (!ev || ev.isRedacted()) return;
@@ -62,137 +49,6 @@ export function stripHTMLReply(html: string): string {
});
}
// Part of Replies fallback support
export function getNestedReplyText(
ev: MatrixEvent,
permalinkCreator?: RoomPermalinkCreator,
): { body: string; html: string } | null {
if (!ev) return null;
let {
body,
formatted_body: html,
msgtype,
} = ev.getContent<{
body: string;
msgtype?: string;
formatted_body?: string;
}>();
if (getParentEventId(ev)) {
if (body) body = stripPlainReply(body);
}
if (!body) body = ""; // Always ensure we have a body, for reasons.
if (html) {
// sanitize the HTML before we put it in an <mx-reply>
html = stripHTMLReply(html);
} else {
// Escape the body to use as HTML below.
// We also run a nl2br over the result to fix the fallback representation. We do this
// after converting the text to safe HTML to avoid user-provided BR's from being converted.
html = escapeHtml(body).replace(/\n/g, "<br/>");
}
// dev note: do not rely on `body` being safe for HTML usage below.
const evLink = permalinkCreator?.forEvent(ev.getId()!);
const userLink = makeUserPermalink(ev.getSender()!);
const mxid = ev.getSender();
if (M_BEACON_INFO.matches(ev.getType())) {
const aTheir = isSelfLocation(ev.getContent()) ? "their" : "a";
return {
html:
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>shared ${aTheir} live location.</blockquote></mx-reply>`,
body: `> <${mxid}> shared ${aTheir} live location.\n\n`,
};
}
if (M_POLL_START.matches(ev.getType())) {
const extensibleEvent = ev.unstableExtensibleEvent as PollStartEvent;
const question = extensibleEvent?.question?.text;
return {
html:
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>Poll: ${question}</blockquote></mx-reply>`,
body: `> <${mxid}> started poll: ${question}\n\n`,
};
}
if (M_POLL_END.matches(ev.getType())) {
return {
html:
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>Ended poll</blockquote></mx-reply>`,
body: `> <${mxid}>Ended poll\n\n`,
};
}
// This fallback contains text that is explicitly EN.
switch (msgtype) {
case MsgType.Text:
case MsgType.Notice: {
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split("\n");
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join("\n") + "\n\n";
}
break;
}
case MsgType.Image:
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>sent an image.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an image.\n\n`;
break;
case MsgType.Video:
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>sent a video.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a video.\n\n`;
break;
case MsgType.Audio:
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>sent an audio file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an audio file.\n\n`;
break;
case MsgType.File:
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>sent a file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a file.\n\n`;
break;
case MsgType.Location: {
const aTheir = isSelfLocation(ev.getContent()) ? "their" : "a";
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>` +
`<br>shared ${aTheir} location.</blockquote></mx-reply>`;
body = `> <${mxid}> shared ${aTheir} location.\n\n`;
break;
}
case MsgType.Emote: {
html =
`<mx-reply><blockquote><a href="${evLink}">In reply to</a> * ` +
`<a href="${userLink}">${mxid}</a><br>${html}</blockquote></mx-reply>`;
const lines = body.trim().split("\n");
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;
body = lines.map((line) => `> ${line}`).join("\n") + "\n\n";
}
break;
}
default:
return null;
}
return { body, html };
}
export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation {
if (!ev) return {};
@@ -227,34 +83,9 @@ export function shouldDisplayReply(event: MatrixEvent): boolean {
return !!inReplyTo.event_id;
}
interface AddReplyOpts {
permalinkCreator?: RoomPermalinkCreator;
includeLegacyFallback: false;
}
interface IncludeLegacyFeedbackOpts {
permalinkCreator?: RoomPermalinkCreator;
includeLegacyFallback: true;
}
export function addReplyToMessageContent(
content: IContent,
replyToEvent: MatrixEvent,
opts: AddReplyOpts | IncludeLegacyFeedbackOpts,
): void {
export function addReplyToMessageContent(content: IContent, replyToEvent: MatrixEvent): void {
content["m.relates_to"] = {
...(content["m.relates_to"] || {}),
...makeReplyMixIn(replyToEvent),
};
if (opts.includeLegacyFallback) {
// Part of Replies fallback support - prepend the text we're sending with the text we're replying to
const nestedReply = getNestedReplyText(replyToEvent, opts.permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
}
content.body = nestedReply.body + content.body;
}
}
}

View File

@@ -328,6 +328,39 @@ export async function asyncSome<T>(values: Iterable<T>, predicate: (value: T) =>
return false;
}
/**
* Async version of Array.some that runs all promises in parallel.
* @param values
* @param predicate
*/
export async function asyncSomeParallel<T>(
values: Array<T>,
predicate: (value: T) => Promise<boolean>,
): Promise<boolean> {
try {
return await Promise.any<boolean>(
values.map((value) =>
predicate(value).then((result) => (result ? Promise.resolve(true) : Promise.reject(false))),
),
);
} catch (e) {
// If the array is empty or all the promises are false, Promise.any will reject an AggregateError
if (e instanceof AggregateError) return false;
throw e;
}
}
/**
* Async version of Array.filter.
* If one of the promises rejects, the whole operation will reject.
* @param values
* @param predicate
*/
export async function asyncFilter<T>(values: Array<T>, predicate: (value: T) => Promise<boolean>): Promise<Array<T>> {
const results = await Promise.all(values.map(predicate));
return values.filter((_, i) => results[i]);
}
export function filterBoolean<T>(values: Array<T | null | undefined>): T[] {
return values.filter(Boolean) as T[];
}

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption";
import { asyncSomeParallel } from "../arrays.ts";
/**
* If encryption is force disabled AND the user is not in any encrypted rooms
@@ -16,7 +17,13 @@ import { shouldForceDisableEncryption } from "./shouldForceDisableEncryption";
* @param client
* @returns {boolean} true when we can skip settings up encryption
*/
export const shouldSkipSetupEncryption = (client: MatrixClient): boolean => {
export const shouldSkipSetupEncryption = async (client: MatrixClient): Promise<boolean> => {
const isEncryptionForceDisabled = shouldForceDisableEncryption(client);
return isEncryptionForceDisabled && !client.getRooms().some((r) => client.isRoomEncrypted(r.roomId));
const crypto = client.getCrypto();
if (!crypto) return true;
return (
isEncryptionForceDisabled &&
!(await asyncSomeParallel(client.getRooms(), ({ roomId }) => crypto.isEncryptionEnabledInRoom(roomId)))
);
};

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EncryptedFile } from "matrix-js-sdk/src/types";
import { ImageInfo } from "matrix-js-sdk/src/types";
import { BlurhashEncoder } from "../BlurhashEncoder";
@@ -15,19 +15,7 @@ type ThumbnailableElement = HTMLImageElement | HTMLVideoElement;
export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448
interface IThumbnail {
info: {
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
w: number;
h: number;
[BLURHASH_FIELD]?: string;
thumbnail_url?: string;
thumbnail_file?: EncryptedFile;
};
info: ImageInfo;
thumbnail: Blob;
}

View File

@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import type MatrixChat from "../components/structures/MatrixChat";
import Views from "../Views";
export function isLoggedIn(): boolean {
@@ -14,6 +13,5 @@ export function isLoggedIn(): boolean {
// `element-web` and into this file? Better yet, we should probably create a
// store to hold this state.
// See also https://github.com/vector-im/element-web/issues/15034.
const app = window.matrixChat;
return (app as MatrixChat)?.state.view === Views.LOGGED_IN;
return window.matrixChat?.state.view === Views.LOGGED_IN;
}

View File

@@ -151,10 +151,7 @@ export async function setMarkedUnreadState(room: Room, client: MatrixClient, unr
const currentState = getMarkedUnreadState(room);
if (Boolean(currentState) !== unread) {
// Assuming MSC2867 passes FCP with no changes, we should update to start writing
// the flag to the stable prefix (or both) and then ultimately use only the
// stable prefix.
await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_UNSTABLE, { unread });
await client.setRoomAccountData(room.roomId, MARKED_UNREAD_TYPE_STABLE, { unread });
}
}