Merge remote-tracking branch 'upstream/develop' into feature/collapse-pinned-mels/17938

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-08-06 07:43:19 +02:00
commit 5f68ad92d1
No known key found for this signature in database
GPG key ID: CC823428E9B582FB
341 changed files with 9959 additions and 3829 deletions

103
src/utils/FileDownloader.ts Normal file
View file

@ -0,0 +1,103 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type getIframeFn = () => HTMLIFrameElement; // eslint-disable-line @typescript-eslint/naming-convention
export const DEFAULT_STYLES = {
imgSrc: "",
imgStyle: null, // css props
style: "",
textContent: "",
};
type DownloadOptions = {
blob: Blob;
name: string;
autoDownload?: boolean;
opts?: typeof DEFAULT_STYLES;
};
// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
let managedIframe: HTMLIFrameElement;
let onLoadPromise: Promise<void>;
function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise<void> } {
if (managedIframe) return { iframe: managedIframe, onLoadPromise };
managedIframe = document.createElement("iframe");
// Need to append the iframe in order for the browser to load it.
document.body.appendChild(managedIframe);
// Dev note: the reassignment warnings are entirely incorrect here.
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.style = { display: "none" };
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";
onLoadPromise = new Promise(resolve => {
managedIframe.onload = () => {
resolve();
};
managedIframe.src = "usercontent/"; // XXX: Should come from the skin
});
return { iframe: managedIframe, onLoadPromise };
}
// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
/**
* Helper to handle safe file downloads. This operates off an iframe for reasons described
* by the blob helpers. By default, this will use a hidden iframe to manage the download
* through a user content wrapper, but can be given an iframe reference if the caller needs
* additional control over the styling/position of the iframe itself.
*/
export class FileDownloader {
private onLoadPromise: Promise<void>;
/**
* Creates a new file downloader
* @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
* use a generic, hidden, iframe.
*/
constructor(private iframeFn: getIframeFn = null) {
}
private get iframe(): HTMLIFrameElement {
const iframe = this.iframeFn?.();
if (!iframe) {
const managed = getManagedIframe();
this.onLoadPromise = managed.onLoadPromise;
return managed.iframe;
}
this.onLoadPromise = null;
return iframe;
}
public async download({ blob, name, autoDownload = true, opts = DEFAULT_STYLES }: DownloadOptions) {
const iframe = this.iframe; // get the iframe first just in case we need to await onload
if (this.onLoadPromise) await this.onLoadPromise;
iframe.contentWindow.postMessage({
...opts,
blob: blob,
download: name,
auto: autoDownload,
}, '*');
}
}

View file

@ -26,12 +26,14 @@ import { _t } from '../languageHandler';
* @param {IMediaEventContent} content The "content" key of the matrix event.
* @param {string} fallbackText The fallback text
* @param {boolean} withSize Whether to include size information. Default true.
* @param {boolean} shortened Ensure the extension of the file name is visible. Default false.
* @return {string} the human readable link text for the attachment.
*/
export function presentableTextForFile(
content: IMediaEventContent,
fallbackText = _t("Attachment"),
withSize = true,
shortened = false,
): string {
let text = fallbackText;
if (content.body && content.body.length > 0) {
@ -40,6 +42,21 @@ export function presentableTextForFile(
text = content.body;
}
// We shorten to 15 characters somewhat arbitrarily, and assume most files
// will have a 3 character (plus full stop) extension. The goal is to knock
// the label down to 15-25 characters, not perfect accuracy.
if (shortened && text.length > 19) {
const parts = text.split('.');
let fileName = parts.slice(0, parts.length - 1).join('.').substring(0, 15);
const extension = parts[parts.length - 1];
// Trim off any full stops from the file name to avoid a case where we
// add an ellipsis that looks really funky.
fileName = fileName.replace(/\.*$/g, '');
text = `${fileName}...${extension}`;
}
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how

View file

@ -67,6 +67,7 @@ export class MediaEventHelper implements IDestroyable {
private prepareThumbnailUrl = async () => {
if (this.media.isEncrypted) {
const blob = await this.thumbnailBlob.value;
if (blob === null) return null;
return URL.createObjectURL(blob);
} else {
return this.media.thumbnailHttp;

93
src/utils/RoomUpgrade.ts Normal file
View file

@ -0,0 +1,93 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { inviteUsersToRoom } from "../RoomInvite";
import Modal from "../Modal";
import { _t } from "../languageHandler";
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
import SpaceStore from "../stores/SpaceStore";
export async function upgradeRoom(
room: Room,
targetVersion: string,
inviteUsers = false,
handleError = true,
updateSpaces = true,
): Promise<string> {
const cli = room.client;
let newRoomId: string;
try {
({ replacement_room: newRoomId } = await cli.upgradeRoom(room.roomId, targetVersion));
} catch (e) {
if (!handleError) throw e;
console.error(e);
Modal.createTrackedDialog("Room Upgrade Error", "", ErrorDialog, {
title: _t('Error upgrading room'),
description: _t('Double check that your server supports the room version chosen and try again.'),
});
throw e;
}
// We have to wait for the js-sdk to give us the room back so
// we can more effectively abuse the MultiInviter behaviour
// which heavily relies on the Room object being available.
if (inviteUsers) {
const checkForUpgradeFn = async (newRoom: Room): Promise<void> => {
// The upgradePromise should be done by the time we await it here.
if (newRoom.roomId !== newRoomId) return;
const toInvite = [
...room.getMembersWithMembership("join"),
...room.getMembersWithMembership("invite"),
].map(m => m.userId).filter(m => m !== cli.getUserId());
if (toInvite.length > 0) {
// Errors are handled internally to this function
await inviteUsersToRoom(newRoomId, toInvite);
}
cli.removeListener('Room', checkForUpgradeFn);
};
cli.on('Room', checkForUpgradeFn);
}
if (updateSpaces) {
const parents = SpaceStore.instance.getKnownParents(room.roomId);
try {
for (const parentId of parents) {
const parent = cli.getRoom(parentId);
if (!parent?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId())) continue;
const currentEv = parent.currentState.getStateEvents(EventType.SpaceChild, room.roomId);
await cli.sendStateEvent(parentId, EventType.SpaceChild, {
...(currentEv?.getContent() || {}), // copy existing attributes like suggested
via: [cli.getDomain()],
}, newRoomId);
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, room.roomId);
}
} catch (e) {
// These errors are not critical to the room upgrade itself
console.warn("Failed to update parent spaces during room upgrade", e);
}
}
return newRoomId;
}

View file

@ -141,21 +141,3 @@ export function objectKeyChanges<O extends {}>(a: O, b: O): (keyof O)[] {
export function objectClone<O extends {}>(obj: O): O {
return JSON.parse(JSON.stringify(obj));
}
/**
* Converts a series of entries to an object.
* @param entries The entries to convert.
* @returns The converted object.
*/
// NOTE: Deprecated once we have Object.fromEntries() support.
// @ts-ignore - return type is complaining about non-string keys, but we know better
export function objectFromEntries<K, V>(entries: Iterable<[K, V]>): {[k: K]: V} {
const obj: {
// @ts-ignore - same as return type
[k: K]: V;} = {};
for (const e of entries) {
// @ts-ignore - same as return type
obj[e[0]] = e[1];
}
return obj;
}

View file

@ -16,10 +16,9 @@ limitations under the License.
import React from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { calculateRoomVia } from "../utils/permalinks/Permalinks";
import { calculateRoomVia } from "./permalinks/Permalinks";
import Modal from "../Modal";
import SpaceSettingsDialog from "../components/views/dialogs/SpaceSettingsDialog";
import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToSpaceDialog";
@ -29,9 +28,18 @@ import { _t } from "../languageHandler";
import SpacePublicShare from "../components/views/spaces/SpacePublicShare";
import InfoDialog from "../components/views/dialogs/InfoDialog";
import { showRoomInviteDialog } from "../RoomInvite";
import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog";
import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog";
import defaultDispatcher from "../dispatcher/dispatcher";
import RoomViewStore from "../stores/RoomViewStore";
import { Action } from "../dispatcher/actions";
import { leaveRoomBehaviour } from "./membership";
import Spinner from "../components/views/elements/Spinner";
import dis from "../dispatcher/dispatcher";
import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog";
export const shouldShowSpaceSettings = (cli: MatrixClient, space: Room) => {
const userId = cli.getUserId();
export const shouldShowSpaceSettings = (space: Room) => {
const userId = space.client.getUserId();
return space.getMyMembership() === "join"
&& (space.currentState.maySendStateEvent(EventType.RoomAvatar, userId)
|| space.currentState.maySendStateEvent(EventType.RoomName, userId)
@ -48,28 +56,33 @@ export const makeSpaceParentEvent = (room: Room, canonical = false) => ({
state_key: room.roomId,
});
export const showSpaceSettings = (cli: MatrixClient, space: Room) => {
export const showSpaceSettings = (space: Room) => {
Modal.createTrackedDialog("Space Settings", "", SpaceSettingsDialog, {
matrixClient: cli,
matrixClient: space.client,
space,
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
};
export const showAddExistingRooms = async (cli: MatrixClient, space: Room) => {
return Modal.createTrackedDialog(
export const showAddExistingRooms = (space: Room): void => {
Modal.createTrackedDialog(
"Space Landing",
"Add Existing",
AddExistingToSpaceDialog,
{
matrixClient: cli,
onCreateRoomClick: showCreateNewRoom,
onCreateRoomClick: () => showCreateNewRoom(space),
onAddSubspaceClick: () => showAddExistingSubspace(space),
space,
onFinished: (added: boolean) => {
if (added && RoomViewStore.getRoomId() === space.roomId) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
},
},
"mx_AddExistingToSpaceDialog_wrapper",
).finished;
);
};
export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => {
export const showCreateNewRoom = async (space: Room): Promise<boolean> => {
const modal = Modal.createTrackedDialog<[boolean, IOpts]>(
"Space Landing",
"Create Room",
@ -86,7 +99,7 @@ export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => {
return shouldCreate;
};
export const showSpaceInvite = (space: Room, initialText = "") => {
export const showSpaceInvite = (space: Room, initialText = ""): void => {
if (space.getJoinRule() === "public") {
const modal = Modal.createTrackedDialog("Space Invite", "User Menu", InfoDialog, {
title: _t("Invite to %(spaceName)s", { spaceName: space.name }),
@ -103,3 +116,60 @@ export const showSpaceInvite = (space: Room, initialText = "") => {
showRoomInviteDialog(space.roomId, initialText);
}
};
export const showAddExistingSubspace = (space: Room): void => {
Modal.createTrackedDialog(
"Space Landing",
"Create Subspace",
AddExistingSubspaceDialog,
{
space,
onCreateSubspaceClick: () => showCreateNewSubspace(space),
onFinished: (added: boolean) => {
if (added && RoomViewStore.getRoomId() === space.roomId) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
},
},
"mx_AddExistingToSpaceDialog_wrapper",
);
};
export const showCreateNewSubspace = (space: Room): void => {
Modal.createTrackedDialog(
"Space Landing",
"Create Subspace",
CreateSubspaceDialog,
{
space,
onAddExistingSpaceClick: () => showAddExistingSubspace(space),
onFinished: (added: boolean) => {
if (added && RoomViewStore.getRoomId() === space.roomId) {
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
},
},
"mx_CreateSubspaceDialog_wrapper",
);
};
export const leaveSpace = (space: Room) => {
Modal.createTrackedDialog("Leave Space", "", LeaveSpaceDialog, {
space,
onFinished: async (leave: boolean, rooms: Room[]) => {
if (!leave) return;
const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner");
try {
await Promise.all(rooms.map(r => leaveRoomBehaviour(r.roomId)));
await leaveRoomBehaviour(space.roomId);
} finally {
modal.close();
}
dis.dispatch({
action: "after_leave_room",
room_id: space.roomId,
});
},
}, "mx_LeaveSpaceDialog_wrapper");
};