diff --git a/CHANGELOG.md b/CHANGELOG.md index c31eedf93b..38b1a2572f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,126 @@ +Changes in [3.16.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0) (2021-03-15) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.2...v3.16.0) + + * Upgrade to JS SDK 9.9.0 + * [Release] Change read receipt drift to be non-fractional + [\#5746](https://github.com/matrix-org/matrix-react-sdk/pull/5746) + * [Release] Properly gate SpaceRoomView behind labs + [\#5750](https://github.com/matrix-org/matrix-react-sdk/pull/5750) + +Changes in [3.16.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.2) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.16.0-rc.1...v3.16.0-rc.2) + + * Fixed incorrect build output in rc.1 + +Changes in [3.16.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.16.0-rc.1) (2021-03-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0...v3.16.0-rc.1) + + * Upgrade to JS SDK 9.9.0-rc.1 + * Translations update from Weblate + [\#5743](https://github.com/matrix-org/matrix-react-sdk/pull/5743) + * Document behaviour of showReadReceipts=false for sent receipts + [\#5739](https://github.com/matrix-org/matrix-react-sdk/pull/5739) + * Tweak sent marker code style + [\#5741](https://github.com/matrix-org/matrix-react-sdk/pull/5741) + * Fix sent markers disappearing for edits/reactions + [\#5737](https://github.com/matrix-org/matrix-react-sdk/pull/5737) + * Ignore to-device decryption in the room list store + [\#5740](https://github.com/matrix-org/matrix-react-sdk/pull/5740) + * Spaces suggested rooms support + [\#5736](https://github.com/matrix-org/matrix-react-sdk/pull/5736) + * Add tooltips to sent/sending receipts + [\#5738](https://github.com/matrix-org/matrix-react-sdk/pull/5738) + * Remove a bunch of useless 'use strict' definitions + [\#5735](https://github.com/matrix-org/matrix-react-sdk/pull/5735) + * [SK-1] Fix types for replaceableComponent + [\#5732](https://github.com/matrix-org/matrix-react-sdk/pull/5732) + * [SK-2] Make debugging skinning problems easier + [\#5733](https://github.com/matrix-org/matrix-react-sdk/pull/5733) + * Support sending invite reasons with /invite command + [\#5695](https://github.com/matrix-org/matrix-react-sdk/pull/5695) + * Fix clicking on the avatar for opening member info requires pixel-perfect + accuracy + [\#5717](https://github.com/matrix-org/matrix-react-sdk/pull/5717) + * Display decrypted and encrypted event source on the same dialog + [\#5713](https://github.com/matrix-org/matrix-react-sdk/pull/5713) + * Fix units of TURN server expiry time + [\#5730](https://github.com/matrix-org/matrix-react-sdk/pull/5730) + * Display room name in pills instead of address + [\#5624](https://github.com/matrix-org/matrix-react-sdk/pull/5624) + * Refresh UI for file uploads + [\#5723](https://github.com/matrix-org/matrix-react-sdk/pull/5723) + * UI refresh for uploaded files + [\#5719](https://github.com/matrix-org/matrix-react-sdk/pull/5719) + * Improve message sending states to match new designs + [\#5699](https://github.com/matrix-org/matrix-react-sdk/pull/5699) + * Add clipboard write permission for widgets + [\#5725](https://github.com/matrix-org/matrix-react-sdk/pull/5725) + * Fix widget resizing + [\#5722](https://github.com/matrix-org/matrix-react-sdk/pull/5722) + * Option for audio streaming + [\#5707](https://github.com/matrix-org/matrix-react-sdk/pull/5707) + * Show a specific error for hs_disabled + [\#5576](https://github.com/matrix-org/matrix-react-sdk/pull/5576) + * Add Edge to the targets list + [\#5721](https://github.com/matrix-org/matrix-react-sdk/pull/5721) + * File drop UI fixes and improvements + [\#5505](https://github.com/matrix-org/matrix-react-sdk/pull/5505) + * Fix Bottom border of state counters is white on the dark theme + [\#5715](https://github.com/matrix-org/matrix-react-sdk/pull/5715) + * Trim spurious whitespace of nicknames + [\#5332](https://github.com/matrix-org/matrix-react-sdk/pull/5332) + * Ensure HostSignupDialog border colour matches light theme + [\#5716](https://github.com/matrix-org/matrix-react-sdk/pull/5716) + * Don't place another call if there's already one ongoing + [\#5712](https://github.com/matrix-org/matrix-react-sdk/pull/5712) + * Space room hierarchies + [\#5706](https://github.com/matrix-org/matrix-react-sdk/pull/5706) + * Iterate Space view and right panel + [\#5705](https://github.com/matrix-org/matrix-react-sdk/pull/5705) + * Add a scroll to bottom on message sent setting + [\#5692](https://github.com/matrix-org/matrix-react-sdk/pull/5692) + * Add .tmp files to gitignore + [\#5708](https://github.com/matrix-org/matrix-react-sdk/pull/5708) + * Initial Space Room View and Creation UX + [\#5704](https://github.com/matrix-org/matrix-react-sdk/pull/5704) + * Add multi language spell check + [\#5452](https://github.com/matrix-org/matrix-react-sdk/pull/5452) + * Fix tetris effect (holes) in read receipts + [\#5697](https://github.com/matrix-org/matrix-react-sdk/pull/5697) + * Fixed edit for markdown images + [\#5703](https://github.com/matrix-org/matrix-react-sdk/pull/5703) + * Iterate Space Panel + [\#5702](https://github.com/matrix-org/matrix-react-sdk/pull/5702) + * Fix read receipts for compact layout + [\#5700](https://github.com/matrix-org/matrix-react-sdk/pull/5700) + * Space Store and Space Panel for Room List filtering + [\#5689](https://github.com/matrix-org/matrix-react-sdk/pull/5689) + * Log when turn creds expire + [\#5691](https://github.com/matrix-org/matrix-react-sdk/pull/5691) + * Null check for maxHeight in call view + [\#5690](https://github.com/matrix-org/matrix-react-sdk/pull/5690) + * Autocomplete invited users + [\#5687](https://github.com/matrix-org/matrix-react-sdk/pull/5687) + * Add send message button + [\#5535](https://github.com/matrix-org/matrix-react-sdk/pull/5535) + * Move call buttons to the room header + [\#5693](https://github.com/matrix-org/matrix-react-sdk/pull/5693) + * Use the default SSSS key if the default is set + [\#5638](https://github.com/matrix-org/matrix-react-sdk/pull/5638) + * Initial Spaces feature flag + [\#5668](https://github.com/matrix-org/matrix-react-sdk/pull/5668) + * Clean up code edge cases and add helpers + [\#5667](https://github.com/matrix-org/matrix-react-sdk/pull/5667) + * Clean up widgets when leaving the room + [\#5684](https://github.com/matrix-org/matrix-react-sdk/pull/5684) + * Fix read receipts? + [\#5567](https://github.com/matrix-org/matrix-react-sdk/pull/5567) + * Fix MAU usage alerts + [\#5678](https://github.com/matrix-org/matrix-react-sdk/pull/5678) + Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0) (2021-03-01) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0-rc.1...v3.15.0) diff --git a/docs/media-handling.md b/docs/media-handling.md new file mode 100644 index 0000000000..a4307fb7d4 --- /dev/null +++ b/docs/media-handling.md @@ -0,0 +1,19 @@ +# Media handling + +Surely media should be as easy as just putting a URL into an `img` and calling it good, right? +Not quite. Matrix uses something called a Matrix Content URI (better known as MXC URI) to identify +content, which is then converted to a regular HTTPS URL on the homeserver. However, sometimes that +URL can change depending on deployment considerations. + +The react-sdk features a [customisation endpoint](https://github.com/vector-im/element-web/blob/develop/docs/customisations.md) +for media handling where all conversions from MXC URI to HTTPS URL happen. This is to ensure that +those obscure deployments can route all their media to the right place. + +For development, there are currently two functions available: `mediaFromMxc` and `mediaFromContent`. +The `mediaFromMxc` function should be self-explanatory. `mediaFromContent` takes an event content as +a parameter and will automatically parse out the source media and thumbnail. Both functions return +a `Media` object with a number of options on it, such as getting various common HTTPS URLs for the +media. + +**It is extremely important that all media calls are put through this customisation endpoint.** So +much so it's a lint rule to avoid accidental use of the wrong functions. diff --git a/package.json b/package.json index 7ed1b272da..f8b4287197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.15.0", + "version": "3.16.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -157,6 +157,7 @@ "jest": "^26.6.3", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", + "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 9937117086..d3e7d7efee 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -16,9 +16,8 @@ limitations under the License. $topLevelHeight: 32px; $nestedHeight: 24px; -$gutterSize: 17px; -$activeStripeSize: 4px; -$activeBorderTransparentGap: 2px; +$gutterSize: 16px; +$activeBorderTransparentGap: 1px; $activeBackgroundColor: $roomtile-selected-bg-color; $activeBorderColor: $secondary-fg-color; @@ -36,6 +35,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_spaceTreeWrapper { flex: 1; + overflow-y: scroll; } .mx_SpacePanel_toggleCollapse { @@ -63,21 +63,26 @@ $activeBorderColor: $secondary-fg-color; } .mx_AutoHideScrollbar { - padding: 16px 12px 16px 0; + padding: 16px 0; } .mx_SpaceButton_toggleCollapse { cursor: pointer; } - .mx_SpaceItem.collapsed { - .mx_SpaceButton { - .mx_NotificationBadge { - right: -4px; - top: -4px; - } - } + .mx_SpaceTreeLevel { + display: flex; + flex-direction: column; + max-width: 250px; + flex-grow: 1; + } + .mx_SpaceItem { + display: inline-flex; + flex-flow: wrap; + } + + .mx_SpaceItem.collapsed { & > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse { transform: rotate(-90deg); } @@ -89,34 +94,43 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { margin-left: $gutterSize; + min-width: 40px; } .mx_SpaceButton { border-radius: 8px; - position: relative; margin-bottom: 2px; display: flex; align-items: center; - padding: 4px; + padding: 4px 4px 4px 0; + width: 100%; &.mx_SpaceButton_active { &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { background-color: $activeBackgroundColor; - border-radius: 8px; } - &.mx_SpaceButton_narrow { - .mx_BaseAvatar, .mx_SpaceButton_avatarPlaceholder { - border: 2px $activeBorderColor solid; - border-radius: 11px; - } + &.mx_SpaceButton_narrow .mx_SpaceButton_selectionWrapper { + padding: $activeBorderTransparentGap; + border: 3px $activeBorderColor solid; } } .mx_SpaceButton_selectionWrapper { + position: relative; display: flex; flex: 1; align-items: center; + border-radius: 12px; + padding: 4px; + } + + &:not(.mx_SpaceButton_narrow) { + .mx_SpaceButton_selectionWrapper { + width: 100%; + padding-right: 16px; + overflow: hidden; + } } .mx_SpaceButton_name { @@ -124,7 +138,6 @@ $activeBorderColor: $secondary-fg-color; margin-left: 8px; white-space: nowrap; display: block; - max-width: 150px; text-overflow: ellipsis; overflow: hidden; padding-right: 8px; @@ -133,8 +146,10 @@ $activeBorderColor: $secondary-fg-color; } .mx_SpaceButton_toggleCollapse { - width: calc($gutterSize - $activeStripeSize); - margin-left: 1px; + width: $gutterSize; + // negative margin to place it correctly even with the complex + // 4px selection border each space button has when active + margin-right: -4px; height: 20px; mask-position: center; mask-size: 20px; @@ -172,11 +187,6 @@ $activeBorderColor: $secondary-fg-color; } } - .mx_SpaceButton_avatarPlaceholder { - border: $activeBorderTransparentGap transparent solid; - padding: $activeBorderTransparentGap; - } - &.mx_SpaceButton_new .mx_SpaceButton_icon { background-color: $accent-color; transition: all .1s ease-in-out; // TODO transition @@ -196,21 +206,8 @@ $activeBorderColor: $secondary-fg-color; } } - .mx_BaseAvatar { - /* moving the border-radius to this element from _image - element so we can add a border to it without the initials being displaced */ - overflow: hidden; - border: 2px transparent solid; - padding: $activeBorderTransparentGap; - - .mx_BaseAvatar_initial { - top: $activeBorderTransparentGap; - left: $activeBorderTransparentGap; - } - - .mx_BaseAvatar_image { - border-radius: 8px; - } + .mx_BaseAvatar_image { + border-radius: 8px; } .mx_SpaceButton_menuButton { @@ -219,8 +216,9 @@ $activeBorderColor: $secondary-fg-color; height: 20px; margin-top: auto; margin-bottom: auto; - position: relative; display: none; + position: absolute; + right: 4px; &::before { top: 2px; @@ -239,9 +237,8 @@ $activeBorderColor: $secondary-fg-color; } .mx_SpacePanel_badgeContainer { + position: absolute; height: 16px; - // don't set width so that it takes no space when there is no badge to show - margin: auto 0; // vertically align // Create a flexbox to make aligning dot badges easier display: flex; @@ -261,14 +258,25 @@ $activeBorderColor: $secondary-fg-color; &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { - position: absolute; - right: 0px; - top: 2px; + right: -3px; + top: -3px; + } + + &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { + // when we draw the selection border we move the relative bounds of our parent + // so update our position within the bounds of the parent to maintain position overall + right: -6px; + top: -6px; } } } &:not(.collapsed) { + .mx_SpacePanel_badgeContainer { + position: absolute; + right: 4px; + } + .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index c96398594f..b14e92a1af 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -132,6 +132,7 @@ limitations under the License. height: min-content; margin-left: auto; margin-right: 16px; + display: inline-flex; } } diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index f742be70e4..80e7aaada0 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -26,50 +26,6 @@ limitations under the License. position: relative; } -.mx_CompleteSecurity_clients { - width: max-content; - margin: 36px auto 0; - - .mx_CompleteSecurity_clients_desktop, .mx_CompleteSecurity_clients_mobile { - position: relative; - width: 160px; - text-align: center; - padding-top: 64px; - display: inline-block; - - &::before { - content: ''; - position: absolute; - height: 48px; - width: 48px; - left: 56px; - top: 0; - background-color: $muted-fg-color; - mask-repeat: no-repeat; - mask-size: contain; - } - } - - .mx_CompleteSecurity_clients_desktop { - margin-right: 56px; - } - - .mx_CompleteSecurity_clients_desktop::before { - mask-image: url('$(res)/img/feather-customised/monitor.svg'); - } - - .mx_CompleteSecurity_clients_mobile::before { - mask-image: url('$(res)/img/feather-customised/smartphone.svg'); - } - - p { - margin-top: 16px; - font-size: $font-12px; - color: $muted-fg-color; - text-align: center; - } -} - .mx_CompleteSecurity_heroIcon { width: 128px; height: 128px; diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index e219c0c5e4..b45126acf8 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 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. @@ -16,6 +16,19 @@ limitations under the License. .mx_MFileBody_download { color: $accent-color; + + .mx_MFileBody_download_icon { + // 12px instead of 14px to better match surrounding font size + width: 12px; + height: 12px; + mask-size: 12px; + + mask-position: center; + mask-repeat: no-repeat; + mask-image: url("$(res)/img/download.svg"); + background-color: $accent-color; + display: inline-block; + } } .mx_MFileBody_download a { diff --git a/res/img/feather-customised/monitor.svg b/res/img/feather-customised/monitor.svg deleted file mode 100644 index 231811d5a6..0000000000 --- a/res/img/feather-customised/monitor.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/res/img/feather-customised/smartphone.svg b/res/img/feather-customised/smartphone.svg deleted file mode 100644 index fde78c82e2..0000000000 --- a/res/img/feather-customised/smartphone.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/Avatar.ts b/src/Avatar.ts index e2557e21a8..76c88faa1c 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -14,27 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {User} from "matrix-js-sdk/src/models/user"; import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixClientPeg} from './MatrixClientPeg'; import DMRoomMap from './utils/DMRoomMap'; +import {mediaFromMxc} from "./customisations/Media"; export type ResizeMethod = "crop" | "scale"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { let url: string; - if (member && member.getAvatarUrl) { - url = member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), + if (member?.getMxcAvatarUrl()) { + url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), resizeMethod, - false, - false, ); } if (!url) { @@ -47,16 +43,12 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu } export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { - const url = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, + if (!user.avatarUrl) return null; + return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( Math.floor(width * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio), resizeMethod, ); - if (!url || url.length === 0) { - return null; - } - return url; } function isValidHexColor(color: string): boolean { @@ -154,15 +146,8 @@ export function getInitialLetter(name: string): string { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { if (!room) return null; // null-guard - const explicitRoomAvatar = room.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); - if (explicitRoomAvatar) { - return explicitRoomAvatar; + if (room.getMxcAvatarUrl()) { + return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } // space rooms cannot be DMs so skip the rest @@ -177,14 +162,8 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // then still try to show any avatar (pref. other member) otherMember = room.getAvatarFallbackMember(); } - if (otherMember) { - return otherMember.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - width, - height, - resizeMethod, - false, - ); + if (otherMember?.getMxcAvatarUrl()) { + return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } return null; } diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index d2564e637b..ce779f12a5 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -788,6 +788,11 @@ export default class CallHandler { // don't remove the call yet: let the hangup event handler do it (otherwise it will throw // the hangup event away) break; + case 'hangup_all': + for (const call of this.calls.values()) { + call.hangup(CallErrorCode.UserHangup, false); + } + break; case 'answer': { if (!this.calls.has(payload.room_id)) { return; // no call to answer diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 7d6b049914..1dc342fac5 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -32,10 +32,10 @@ import { AllHtmlEntities } from 'html-entities'; import SettingsStore from './settings/SettingsStore'; import cheerio from 'cheerio'; -import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; +import {mediaFromMxc} from "./customisations/Media"; linkifyMatrix(linkify); @@ -181,11 +181,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { return { tagName, attribs: {}}; } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); + const width = Number(attribs.width) || 800; + const height = Number(attribs.height) || 600; + attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height); return { tagName, attribs }; }, 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { @@ -239,6 +237,7 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', + 'details', 'summary', ], allowedAttributes: { // custom ones first: diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 98ca446532..de1d573d40 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -261,7 +261,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } public getHomeserverName(): string { - const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId); + const matches = /^@[^:]+:(.+)$/.exec(this.matrixClient.credentials.userId); if (matches === null || matches.length < 1) { throw new Error("Failed to derive homeserver name from user ID!"); } diff --git a/src/Notifier.ts b/src/Notifier.ts index 6460be20ad..f68bfabc18 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -36,6 +36,7 @@ import {SettingLevel} from "./settings/SettingLevel"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; +import {mediaFromMxc} from "./customisations/Media"; /* * Dispatches: @@ -150,7 +151,7 @@ export const Notifier = { // Ideally in here we could use MSC1310 to detect the type of file, and reject it. return { - url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + url: mediaFromMxc(content.url).srcHttp, name: content.name, type: content.type, size: content.size, diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index ebf5d536ec..b7a4e0960e 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -27,6 +27,7 @@ import {sortBy} from "lodash"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; +import {mediaFromMxc} from "../customisations/Media"; const COMMUNITY_REGEX = /\B\+\S*/g; @@ -95,7 +96,7 @@ export default class CommunityProvider extends AutocompleteProvider { name={name || groupId} width={24} height={24} - url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} /> + url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} /> ), range, diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index b4b871a0b4..f05d8d0758 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -39,6 +39,7 @@ import {Group} from "matrix-js-sdk"; import {allSettled, sleep} from "../../utils/promise"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import {mediaFromMxc} from "../../customisations/Media"; import {replaceableComponent} from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( @@ -368,8 +369,7 @@ class FeaturedUser extends React.Component { const permalink = makeUserPermalink(this.props.summaryInfo.user_id); const userNameNode = { name }; - const httpUrl = MatrixClientPeg.get() - .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); + const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64); const deleteButton = this.props.editing ? ; } - const httpInviterAvatar = this.state.inviterProfile ? - this._matrixClient.mxcUrlToHttp( - this.state.inviterProfile.avatarUrl, 36, 36, - ) : null; + const httpInviterAvatar = this.state.inviterProfile + ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36) + : null; const inviter = group.inviter || {}; let inviterName = inviter.userId; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 88c7a71b35..9a1ce63785 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -36,11 +36,11 @@ import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; import LeftPanelWidget from "./LeftPanelWidget"; import SpacePanel from "../views/spaces/SpacePanel"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../customisations/Media"; interface IProps { isMinimized: boolean; @@ -121,7 +121,7 @@ export default class LeftPanel extends React.Component { let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); if (settingBgMxc) { - avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); + avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); } const avatarUrlProp = `url(${avatarUrl})`; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 15e90a383a..936eb819ba 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -240,10 +240,10 @@ class LoggedInView extends React.Component { onCollapsed: (_collapsed) => { collapsed = _collapsed; if (_collapsed) { - dis.dispatch({action: "hide_left_panel"}, true); + dis.dispatch({action: "hide_left_panel"}); window.localStorage.setItem("mx_lhs_size", '0'); } else { - dis.dispatch({action: "show_left_panel"}, true); + dis.dispatch({action: "show_left_panel"}); } }, onResized: (_size) => { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 0272633e8f..f0a3778a2b 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -582,6 +582,7 @@ export default class MatrixChat extends React.PureComponent { } break; case 'logout': + dis.dispatch({action: "hangup_all"}); Lifecycle.logout(); break; case 'require_registration': diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 363c67262b..3613261da6 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -27,7 +27,6 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; @@ -35,6 +34,7 @@ import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../customisations/Media"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -521,10 +521,9 @@ export default class RoomDirectory extends React.Component { topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; } topic = linkifyAndSanitizeHtml(topic); - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - room.avatar_url, 32, 32, "crop", - ); + let avatarUrl = null; + if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32); + return [
this.onRoomClicked(room, ev)} diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 72e52678b6..9ee16558d3 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -34,6 +34,7 @@ import {EnhancedMap} from "../../utils/maps"; import StyledCheckbox from "../views/elements/StyledCheckbox"; import AutoHideScrollbar from "./AutoHideScrollbar"; import BaseAvatar from "../views/avatars/BaseAvatar"; +import {mediaFromMxc} from "../../customisations/Media"; interface IProps { space: Room; @@ -158,12 +159,7 @@ const SubSpace: React.FC = ({ let url: string; if (space.avatar_url) { - url = MatrixClientPeg.get().mxcUrlToHttp( - space.avatar_url, - Math.floor(24 * window.devicePixelRatio), - Math.floor(24 * window.devicePixelRatio), - "crop", - ); + url = mediaFromMxc(space.avatar_url).getSquareThumbnailHttp(Math.floor(24 * window.devicePixelRatio)); } return
@@ -265,12 +261,7 @@ const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinCli let url: string; if (room.avatar_url) { - url = cli.mxcUrlToHttp( - room.avatar_url, - Math.floor(32 * window.devicePixelRatio), - Math.floor(32 * window.devicePixelRatio), - "crop", - ); + url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(32 * window.devicePixelRatio)); } const content = diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index b762ad3fca..be9be4db81 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -16,65 +16,176 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import SyntaxHighlight from '../views/elements/SyntaxHighlight'; -import {_t} from "../../languageHandler"; +import React from "react"; +import PropTypes from "prop-types"; +import SyntaxHighlight from "../views/elements/SyntaxHighlight"; +import { _t } from "../../languageHandler"; import * as sdk from "../../index"; -import {replaceableComponent} from "../../utils/replaceableComponent"; - +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { SendCustomEvent } from "../views/dialogs/DevtoolsDialog"; +import { canEditContent } from "../../utils/EventUtils"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; +import { replaceableComponent } from "../../utils/replaceableComponent"; @replaceableComponent("structures.ViewSource") export default class ViewSource extends React.Component { static propTypes = { - content: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - roomId: PropTypes.string.isRequired, - eventId: PropTypes.string.isRequired, - isEncrypted: PropTypes.bool.isRequired, - decryptedContent: PropTypes.object, + mxEvent: PropTypes.object.isRequired, // the MatrixEvent associated with the context menu }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + constructor(props) { + super(props); - let content; - if (this.props.isEncrypted) { - content = <> -
- - {_t("Decrypted event source")} - - - { JSON.stringify(this.props.decryptedContent, null, 2) } - -
-
- - {_t("Original event source")} - - - { JSON.stringify(this.props.content, null, 2) } - -
- ; + this.state = { + isEditing: false, + }; + } + + onBack() { + // TODO: refresh the "Event ID:" modal header + this.setState({ isEditing: false }); + } + + onEdit() { + this.setState({ isEditing: true }); + } + + // returns the dialog body for viewing the event source + viewSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const decryptedEventSource = mxEvent._clearEvent; // FIXME: _clearEvent is private + const originalEventSource = mxEvent.event; + + if (isEncrypted) { + return ( + <> +
+ + {_t("Decrypted event source")} + + {JSON.stringify(decryptedEventSource, null, 2)} +
+
+ + {_t("Original event source")} + + {JSON.stringify(originalEventSource, null, 2)} +
+ + ); } else { - content = <> -
{_t("Original event source")}
- - { JSON.stringify(this.props.content, null, 2) } - - ; + return ( + <> +
{_t("Original event source")}
+ {JSON.stringify(originalEventSource, null, 2)} + + ); } + } + // returns the id of the initial message, not the id of the previous edit + getBaseEventId() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + const isEncrypted = mxEvent.isEncrypted(); + const baseMxEvent = this.props.mxEvent; + + if (isEncrypted) { + // `relates_to` field is inside the encrypted event + return mxEvent.event.content["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } else { + return mxEvent.getContent()["m.relates_to"]?.event_id ?? baseMxEvent.getId(); + } + } + + // returns the SendCustomEvent component prefilled with the correct details + editSourceContent() { + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isStateEvent = mxEvent.isState(); + const roomId = mxEvent.getRoomId(); + const originalContent = mxEvent.getContent(); + + if (isStateEvent) { + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(originalContent, null, "\t"), + stateKey: mxEvent.getStateKey(), + }} + /> + )} + + ); + } else { + // prefill an edit-message event + // keep only the `body` and `msgtype` fields of originalContent + const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there + const newContent = { + "body": ` * ${bodyToStartFrom}`, + "msgtype": originalContent.msgtype, + "m.new_content": { + body: bodyToStartFrom, + msgtype: originalContent.msgtype, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: this.getBaseEventId(), + }, + }; + return ( + + {(cli) => ( + this.onBack()} + inputs={{ + eventType: mxEvent.getType(), + evContent: JSON.stringify(newContent, null, "\t"), + }} + /> + )} + + ); + } + } + + canSendStateEvent(mxEvent) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(mxEvent.getRoomId()); + return room.currentState.mayClientSendStateEvent(mxEvent.getType(), cli); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + + const isEditing = this.state.isEditing; + const roomId = mxEvent.getRoomId(); + const eventId = mxEvent.getId(); + const canEdit = mxEvent.isState() ? this.canSendStateEvent(mxEvent) : canEditContent(this.props.mxEvent); return ( - -
-
Room ID: { this.props.roomId }
-
Event ID: { this.props.eventId }
+ +
+
Room ID: {roomId}
+
Event ID: {eventId}
- { content } + {isEditing ? this.editSourceContent() : this.viewSourceContent()}
+ {!isEditing && canEdit && ( +
+ +
+ )} ); } diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 799a559263..e623439174 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {toPx} from "../../../utils/units"; +import {ResizeMethod} from "../../../Avatar"; interface IProps { name: string; // The name (first initial used as default) @@ -35,7 +36,7 @@ interface IProps { width?: number; height?: number; // XXX: resizeMethod not actually used. - resizeMethod?: string; + resizeMethod?: ResizeMethod; defaultToInitialLetter?: boolean; // true to add default url onClick?: React.MouseEventHandler; inputRef?: React.RefObject; diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index a033257871..3734ba9504 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017, 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. @@ -15,9 +15,10 @@ limitations under the License. */ import React from 'react'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import BaseAvatar from './BaseAvatar'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; export interface IProps { groupId?: string; @@ -25,7 +26,7 @@ export interface IProps { groupAvatarUrl?: string; width?: number; height?: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; onClick?: React.MouseEventHandler; } @@ -38,8 +39,8 @@ export default class GroupAvatar extends React.Component { }; getGroupAvatarUrl() { - return MatrixClientPeg.get().mxcUrlToHttp( - this.props.groupAvatarUrl, + if (!this.props.groupAvatarUrl) return null; + return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp( this.props.width, this.props.height, this.props.resizeMethod, diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 641046aa55..c79cbc0d32 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -20,16 +20,17 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import dis from "../../../dispatcher/dispatcher"; import {Action} from "../../../dispatcher/actions"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; +import {ResizeMethod} from "../../../Avatar"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember; fallbackUserId?: string; width: number; height: number; - resizeMethod?: string; + resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` @@ -63,18 +64,19 @@ export default class MemberAvatar extends React.Component { } private static getState(props: IProps): IState { - if (props.member && props.member.name) { - return { - name: props.member.name, - title: props.title || props.member.userId, - imageUrl: props.member.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), + if (props.member?.name) { + let imageUrl = null; + if (props.member.getMxcAvatarUrl()) { + imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( Math.floor(props.width * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, - false, - false, - ), + ); + } + return { + name: props.member.name, + title: props.title || props.member.userId, + imageUrl: imageUrl, }; } else if (props.fallbackUserId) { return { diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 0a59f6e36a..31245b44b7 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, {ComponentProps} from 'react'; import Room from 'matrix-js-sdk/src/models/room'; -import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; import BaseAvatar from './BaseAvatar'; import ImageView from '../elements/ImageView'; @@ -24,6 +23,7 @@ import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; import {ResizeMethod} from "../../../Avatar"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, @@ -90,16 +90,16 @@ export default class RoomAvatar extends React.Component { }; private static getImageUrls(props: IProps): string[] { - return [ - getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - // Default props don't play nicely with getDerivedStateFromProps - //props.oobData !== undefined ? props.oobData.avatarUrl : {}, - props.oobData.avatarUrl, + let oobAvatar = null; + if (props.oobData.avatarUrl) { + oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( Math.floor(props.width * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, - ), // highest priority + ); + } + return [ + oobAvatar, // highest priority RoomAvatar.getRoomAvatarUrl(props), ].filter(function(url) { return (url !== null && url !== ""); diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index 04cfce7670..cca158269e 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -14,21 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ComponentProps, useContext} from 'react'; +import React, {ComponentProps} from 'react'; import classNames from 'classnames'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {IApp} from "../../../stores/WidgetStore"; import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { app: IApp; } const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { - const cli = useContext(MatrixClientContext); - let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; // heuristics for some better icons until Widgets support their own icons if (app.type.includes("jitsi")) { @@ -47,7 +44,7 @@ const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 2 name={app.id} className={classNames("mx_WidgetAvatar", className)} // MSC2765 - url={app.avatar_url ? getHttpUriForMxc(cli.getHomeserverUrl(), app.avatar_url, 20, 20, "crop") : undefined} + url={app.avatar_url ? mediaFromMxc(app.avatar_url).getSquareThumbnailHttp(20) : undefined} urls={iconUrls} width={width} height={height} diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index f97b429abc..daabf224dc 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -126,15 +126,9 @@ export default class MessageContextMenu extends React.Component { }; onViewSourceClick = () => { - const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', '', ViewSource, { - roomId: ev.getRoomId(), - eventId: ev.getId(), - content: ev.event, - isEncrypted: ev.isEncrypted(), - // FIXME: _clearEvent is private - decryptedContent: ev._clearEvent, + mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); }; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index d1080566ac..2635f95bb7 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -26,12 +26,12 @@ import SdkConfig from "../../../SdkConfig"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import InviteDialog from "./InviteDialog"; import BaseAvatar from "../avatars/BaseAvatar"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; import StyledCheckbox from "../elements/StyledCheckbox"; import Modal from "../../../Modal"; import ErrorDialog from "./ErrorDialog"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { roomId: string; @@ -142,12 +142,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< private renderPerson(person: IPerson, key: any) { const avatarSize = 36; + let avatarUrl = null; + if (person.user.getMxcAvatarUrl()) { + avatarUrl = mediaFromMxc(person.user.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize); + } return (
; diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 9b24aaa571..dfcc5d6dfb 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -74,13 +74,14 @@ class GenericEditor extends React.PureComponent { } } -class SendCustomEvent extends GenericEditor { +export class SendCustomEvent extends GenericEditor { static getLabel() { return _t('Send Custom Event'); } static propTypes = { onBack: PropTypes.func.isRequired, room: PropTypes.instanceOf(Room).isRequired, forceStateEvent: PropTypes.bool, + forceGeneralEvent: PropTypes.bool, inputs: PropTypes.object, }; @@ -141,6 +142,8 @@ class SendCustomEvent extends GenericEditor {
; } + const showTglFlip = !this.state.message && !this.props.forceStateEvent && !this.props.forceGeneralEvent; + return
@@ -156,7 +159,7 @@ class SendCustomEvent extends GenericEditor {
{ !this.state.message && } - { !this.state.message && !this.props.forceStateEvent &&
+ { showTglFlip &&
} diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx index 504d563bd9..ee3696b427 100644 --- a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -24,6 +24,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import FlairStore from "../../../stores/FlairStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps extends IDialogProps { communityId: string; @@ -118,7 +119,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent; if (!this.state.avatarPreview) { if (this.state.currentAvatarUrl) { - const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + const url = mediaFromMxc(this.state.currentAvatarUrl).srcHttp; preview = ; } else { preview =
diff --git a/src/components/views/dialogs/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js index cbe26af6cc..d80a935573 100644 --- a/src/components/views/dialogs/FeedbackDialog.js +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -100,6 +100,20 @@ export default (props) => { ); } + let bugReports = null; + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

{ + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

+ ); + } + return ( { }, }) }

-

{ - _t("PRO TIP: If you start a bug, please submit debug logs " + - "to help us track down the problem.", {}, { - debugLogsLink: sub => ( - {sub} - ), - }) - }

+ {bugReports}
{ countlyFeedbackSection } } diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index d65ec7563f..f18b7a9d0c 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -20,6 +20,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; const PHASE_START = 0; const PHASE_SHOW_SAS = 1; @@ -123,22 +124,21 @@ export default class IncomingSasDialog extends React.Component { const Spinner = sdk.getComponent("views.elements.Spinner"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - const isSelf = this.props.verifier.userId == MatrixClientPeg.get().getUserId(); + const isSelf = this.props.verifier.userId === MatrixClientPeg.get().getUserId(); let profile; - if (this.state.opponentProfile) { + const oppProfile = this.state.opponentProfile; + if (oppProfile) { + const url = oppProfile.avatar_url + ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio)) + : null; profile =
- -

{this.state.opponentProfile.displayname}

+

{oppProfile.displayname}

; } else if (this.state.opponentProfileError) { profile =
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index b28cc1bf41..9aef421d5a 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -22,7 +22,6 @@ import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Pe import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import SdkConfig from "../../../SdkConfig"; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import * as Email from "../../../email"; import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; import {abbreviateUrl} from "../../../utils/UrlUtils"; @@ -43,6 +42,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; import {Room} from "matrix-js-sdk/src/models/room"; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -160,9 +160,9 @@ class DMUserTile extends React.PureComponent { width={avatarSize} height={avatarSize} /> : { src={require("../../../../res/img/icon-email-pill-avatar.svg")} width={avatarSize} height={avatarSize} /> : + {_t( + "This usually only affects how the room is processed on the server. If you're " + + "having problems with your %(brand)s, please report a bug.", {brand}, + )} +

+ ); + if (SdkConfig.get().bug_report_endpoint_url) { + bugReports = ( +

+ {_t( + "This usually only affects how the room is processed on the server. If you're " + + "having problems with your %(brand)s, please report a bug.", + { + brand, + }, + { + "a": (sub) => { + return {sub}; + }, + }, + )} +

+ ); + } + return ( -

- {_t( - "This usually only affects how the room is processed on the server. If you're " + - "having problems with your %(brand)s, please report a bug.", - { - brand, - }, - { - "a": (sub) => { - return {sub}; - }, - }, - )} -

+ {bugReports}

{_t( "You'll upgrade this room from to .", diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 4a216dbae4..df66d10a71 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -19,10 +19,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import * as sdk from "../../../index"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import { _t } from '../../../languageHandler'; import { UserAddressType } from '../../../UserAddress.js'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.elements.AddressTile") export default class AddressTile extends React.Component { @@ -47,9 +47,7 @@ export default class AddressTile extends React.Component { const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType); if (isMatrixAddress && address.avatarMxc) { - imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( - address.avatarMxc, 25, 25, 'crop', - )); + imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25)); } else if (address.addressType === 'email') { imgUrls.push(require("../../../../res/img/icon-email-user.svg")); } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index c539f2be1c..5fd73f974d 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -70,9 +70,7 @@ export default class EventTilePreview extends React.Component { const client = MatrixClientPeg.get(); const userId = client.getUserId(); const profileInfo = await client.getProfileInfo(userId); - const avatarUrl = Avatar.avatarUrlForUser( - {avatarUrl: profileInfo.avatar_url}, - AVATAR_SIZE, AVATAR_SIZE, "crop"); + const avatarUrl = profileInfo.avatar_url; this.setState({ userId, @@ -113,8 +111,9 @@ export default class EventTilePreview extends React.Component { name: displayname, userId: userId, getAvatarUrl: (..._) => { - return avatarUrl; + return Avatar.avatarUrlForUser({avatarUrl}, AVATAR_SIZE, AVATAR_SIZE, "crop"); }, + getMxcAvatarUrl: () => avatarUrl, }; return event; diff --git a/src/components/views/elements/Flair.js b/src/components/views/elements/Flair.js index 75998cb721..73d5b91511 100644 --- a/src/components/views/elements/Flair.js +++ b/src/components/views/elements/Flair.js @@ -20,6 +20,7 @@ import FlairStore from '../../../stores/FlairStore'; import dis from '../../../dispatcher/dispatcher'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; class FlairAvatar extends React.Component { @@ -39,8 +40,7 @@ class FlairAvatar extends React.Component { } render() { - const httpUrl = this.context.mxcUrlToHttp( - this.props.groupProfile.avatarUrl, 16, 16, 'scale', false); + const httpUrl = mediaFromMxc(this.props.groupProfile.avatarUrl).getSquareThumbnailHttp(16); const tooltip = this.props.groupProfile.name ? `${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`: this.props.groupProfile.groupId; diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index b0d4fc7fa2..e61d312305 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019, 2021 The Matrix.org Foundation C.I.C. +Copyright 2017 - 2019, 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. @@ -26,6 +24,7 @@ import FlairStore from "../../../stores/FlairStore"; import {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; +import {mediaFromMxc} from "../../../customisations/Media"; import Tooltip from './Tooltip'; import {replaceableComponent} from "../../../utils/replaceableComponent"; @@ -254,12 +253,12 @@ class Pill extends React.Component { case Pill.TYPE_GROUP_MENTION: { if (this.state.group) { const {avatarUrl, groupId, name} = this.state.group; - const cli = MatrixClientPeg.get(); linkText = groupId; if (this.props.shouldShowPillAvatar) { - avatar =

); + const httpUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(800); + avatarElement =
; } const groupRoomName = this.state.groupRoom.displayname; diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js index 8b25437f71..7edfc1a376 100644 --- a/src/components/views/groups/GroupRoomTile.js +++ b/src/components/views/groups/GroupRoomTile.js @@ -21,6 +21,7 @@ import dis from '../../../dispatcher/dispatcher'; import { GroupRoomType } from '../../../groups'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.groups.GroupRoomTile") class GroupRoomTile extends React.Component { @@ -42,10 +43,9 @@ class GroupRoomTile extends React.Component { render() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const avatarUrl = this.context.mxcUrlToHttp( - this.props.groupRoom.avatarUrl, - 36, 36, 'crop', - ); + const avatarUrl = this.props.groupRoom.avatarUrl + ? mediaFromMxc(this.props.groupRoom.avatarUrl).getSquareThumbnailHttp(36) + : null; const av = ( { profile.shortDescription }
:
; - const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp( - profile.avatarUrl, avatarHeight, avatarHeight, "crop") : null; + const httpUrl = profile.avatarUrl + ? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight) + : null; let avatarElement = (
diff --git a/src/components/views/messages/EditHistoryMessage.js b/src/components/views/messages/EditHistoryMessage.js index c5002b3308..a298f7eb68 100644 --- a/src/components/views/messages/EditHistoryMessage.js +++ b/src/components/views/messages/EditHistoryMessage.js @@ -76,12 +76,7 @@ export default class EditHistoryMessage extends React.PureComponent { _onViewSourceClick = () => { const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', 'Edit history', ViewSource, { - roomId: this.props.mxEvent.getRoomId(), - eventId: this.props.mxEvent.getId(), - content: this.props.mxEvent.event, - isEncrypted: this.props.mxEvent.isEncrypted(), - // FIXME: _clearEvent is private - decryptedContent: this.props.mxEvent._clearEvent, + mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); }; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 498e2db12a..0d5e449fc0 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -17,11 +17,11 @@ import React from 'react'; import MFileBody from './MFileBody'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; @replaceableComponent("views.messages.MAudioBody") export default class MAudioBody extends React.Component { @@ -41,11 +41,11 @@ export default class MAudioBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); + return media.srcHttp; } } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index e9893f99b6..8f464e08bd 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd +Copyright 2015, 2016, 2018, 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. @@ -18,52 +17,24 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import filesize from 'filesize'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import {decryptFile} from '../../../utils/DecryptFile'; -import Tinter from '../../../Tinter'; -import request from 'browser-request'; import Modal from '../../../Modal'; import AccessibleButton from "../elements/AccessibleButton"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; +import ErrorDialog from "../dialogs/ErrorDialog"; +let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on -// A cached tinted copy of require("../../../../res/img/download.svg") -let tintedDownloadImageURL; -// Track a list of mounted MFileBody instances so that we can update -// the require("../../../../res/img/download.svg") when the tint changes. -let nextMountId = 0; -const mounts = {}; - -/** - * Updates the tinted copy of require("../../../../res/img/download.svg") when the tint changes. - */ -function updateTintedDownloadImage() { - // Download the svg as an XML document. - // We could cache the XML response here, but since the tint rarely changes - // it's probably not worth it. - // Also note that we can't use fetch here because fetch doesn't support - // file URLs, which the download image will be if we're running from - // the filesystem (like in an Electron wrapper). - request({uri: require("../../../../res/img/download.svg")}, (err, response, body) => { - if (err) return; - - const svg = new DOMParser().parseFromString(body, "image/svg+xml"); - // Apply the fixups to the XML. - const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]); - Tinter.applySvgFixups(fixups); - // Encoded the fixed up SVG as a data URL. - const svgString = new XMLSerializer().serializeToString(svg); - tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString); - // Notify each mounted MFileBody that the URL has changed. - Object.keys(mounts).forEach(function(id) { - mounts[id].tint(); - }); - }); +async function cacheDownloadIcon() { + if (downloadIconUrl) return; // cached already + const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text()); + downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg); } -Tinter.registerTintable(updateTintedDownloadImage); +// Cache the asset immediately +cacheDownloadIcon(); // User supplied content can contain scripts, we have to be careful that // we don't accidentally run those script within the same origin as the @@ -106,6 +77,7 @@ function computedStyle(element) { } const style = window.getComputedStyle(element, null); let cssText = style.cssText; + // noinspection EqualityComparisonWithCoercionJS if (cssText == "") { // Firefox doesn't implement ".cssText" for computed styles. // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 @@ -145,7 +117,6 @@ export default class MFileBody extends React.Component { this._iframe = createRef(); this._dummyLink = createRef(); - this._downloadImage = createRef(); } /** @@ -178,16 +149,8 @@ export default class MFileBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - return MatrixClientPeg.get().mxcUrlToHttp(content.url); - } - - componentDidMount() { - // Add this to the list of mounted components to receive notifications - // when the tint changes. - this.id = nextMountId++; - mounts[this.id] = this; - this.tint(); + const media = mediaFromContent(this.props.mxEvent.getContent()); + return media.srcHttp; } componentDidUpdate(prevProps, prevState) { @@ -196,34 +159,12 @@ export default class MFileBody extends React.Component { } } - componentWillUnmount() { - // Remove this from the list of mounted components - delete mounts[this.id]; - } - - tint = () => { - // Update our tinted copy of require("../../../../res/img/download.svg") - if (this._downloadImage.current) { - this._downloadImage.current.src = tintedDownloadImageURL; - } - if (this._iframe.current) { - // If the attachment is encrypted then the download image - // will be inside the iframe so we wont be able to update - // it directly. - this._iframe.current.contentWindow.postMessage({ - imgSrc: tintedDownloadImageURL, - style: computedStyle(this._dummyLink.current), - }, "*"); - } - }; - render() { const content = this.props.mxEvent.getContent(); const text = this.presentableTextForFile(content); const isEncrypted = content.file !== undefined; const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const contentUrl = this._getContentUrl(); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const fileSize = content.info ? content.info.size : null; const fileType = content.info ? content.info.mimetype : "application/octet-stream"; @@ -280,7 +221,8 @@ export default class MFileBody extends React.Component { // When the iframe loads we tell it to render a download link const onIframeLoad = (ev) => { ev.target.contentWindow.postMessage({ - imgSrc: tintedDownloadImageURL, + imgSrc: downloadIconUrl, + imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon. style: computedStyle(this._dummyLink.current), blob: this.state.decryptedBlob, // Set a download attribute for encrypted files so that the file @@ -384,7 +326,7 @@ export default class MFileBody extends React.Component { {placeholder} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 59c5b4e66b..3683818027 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -28,6 +28,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; @replaceableComponent("views.messages.MImageBody") export default class MImageBody extends React.Component { @@ -70,7 +71,7 @@ export default class MImageBody extends React.Component { this._image = createRef(); } - // FIXME: factor this out and aplpy it to MVideoBody and MAudioBody too! + // FIXME: factor this out and apply it to MVideoBody and MAudioBody too! onClientSync(syncState, prevState) { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. @@ -167,16 +168,16 @@ export default class MImageBody extends React.Component { } _getContentUrl() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return this.context.mxcUrlToHttp(content.url); + return media.srcHttp; } } _getThumbUrl() { - // FIXME: the dharma skin lets images grow as wide as you like, rather than capped to 800x600. + // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. // custom timeline widths seems preferable. @@ -185,21 +186,19 @@ export default class MImageBody extends React.Component { const thumbHeight = Math.round(600 * pixelRatio); const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(content); + + if (media.isEncrypted) { // Don't use the thumbnail for clients wishing to autoplay gifs. if (this.state.decryptedThumbnailUrl) { return this.state.decryptedThumbnailUrl; } return this.state.decryptedUrl; - } else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) { + } else if (content.info && content.info.mimetype === "image/svg+xml" && media.hasThumbnail) { // special case to return clientside sender-generated thumbnails for SVGs, if any, // given we deliberately don't thumbnail them serverside to prevent // billion lol attacks and similar - return this.context.mxcUrlToHttp( - content.info.thumbnail_url, - thumbWidth, - thumbHeight, - ); + return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale'); } else { // we try to download the correct resolution // for hi-res images (like retina screenshots). @@ -218,7 +217,7 @@ export default class MImageBody extends React.Component { pixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { - return this.context.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } else { // we should only request thumbnails if the image is bigger than 800x600 // (or 1600x1200 on retina) otherwise the image in the timeline will just @@ -233,24 +232,17 @@ export default class MImageBody extends React.Component { info.w > thumbWidth || info.h > thumbHeight ); - const isLargeFileSize = info.size > 1*1024*1024; + const isLargeFileSize = info.size > 1*1024*1024; // 1mb if (isLargeFileSize && isLargerThanThumbnail) { // image is too large physically and bytewise to clutter our timeline so // we ask for a thumbnail, despite knowing that it will be max 800x600 // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). - return this.context.mxcUrlToHttp( - content.url, - thumbWidth, - thumbHeight, - ); + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); } else { // download the original image otherwise, so we can scale it client side // to take pixelRatio into account. - // ( no width/height means we want the original image) - return this.context.mxcUrlToHttp( - content.url, - ); + return media.srcHttp; } } } diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 89985dee7d..89e661cb2f 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -17,12 +17,12 @@ limitations under the License. import React from 'react'; import MFileBody from './MFileBody'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromContent} from "../../../customisations/Media"; interface IProps { /* the MatrixEvent to show */ @@ -76,11 +76,11 @@ export default class MVideoBody extends React.PureComponent { } private getContentUrl(): string|null { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(this.props.mxEvent.getContent()); + if (media.isEncrypted) { return this.state.decryptedUrl; } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); + return media.srcHttp; } } @@ -91,10 +91,11 @@ export default class MVideoBody extends React.PureComponent { private getThumbUrl(): string|null { const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { + const media = mediaFromContent(content); + if (media.isEncrypted) { return this.state.decryptedThumbnailUrl; - } else if (content.info && content.info.thumbnail_url) { - return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); + } else if (media.hasThumbnail) { + return media.thumbnailHttp; } else { return null; } diff --git a/src/components/views/messages/RoomAvatarEvent.js b/src/components/views/messages/RoomAvatarEvent.js index ba860216f0..00aaf9bfda 100644 --- a/src/components/views/messages/RoomAvatarEvent.js +++ b/src/components/views/messages/RoomAvatarEvent.js @@ -24,6 +24,7 @@ import * as sdk from '../../../index'; import Modal from '../../../Modal'; import AccessibleButton from '../elements/AccessibleButton'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.messages.RoomAvatarEvent") export default class RoomAvatarEvent extends React.Component { @@ -35,7 +36,7 @@ export default class RoomAvatarEvent extends React.Component { onAvatarClick = () => { const cli = MatrixClientPeg.get(); const ev = this.props.mxEvent; - const httpUrl = cli.mxcUrlToHttp(ev.getContent().url); + const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp; const room = cli.getRoom(this.props.mxEvent.getRoomId()); const text = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', { diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 5fbfd03bd1..12a6a2a311 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -64,6 +64,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IDevice { deviceId: string; @@ -1424,14 +1425,14 @@ const UserInfoHeader: React.FC<{ const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl; if (!avatarUrl) return; - const httpUrl = cli.mxcUrlToHttp(avatarUrl); + const httpUrl = mediaFromMxc(avatarUrl).srcHttp; const params = { src: httpUrl, name: member.name, }; Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); - }, [cli, member]); + }, [member]); const avatarElement = (
diff --git a/src/components/views/room_settings/RoomProfileSettings.js b/src/components/views/room_settings/RoomProfileSettings.js index 563368384b..3dbe2b2b7f 100644 --- a/src/components/views/room_settings/RoomProfileSettings.js +++ b/src/components/views/room_settings/RoomProfileSettings.js @@ -21,6 +21,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Field from "../elements/Field"; import * as sdk from "../../../index"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; // TODO: Merge with ProfileSettings? @replaceableComponent("views.room_settings.RoomProfileSettings") @@ -38,7 +39,7 @@ export default class RoomProfileSettings extends React.Component { const avatarEvent = room.currentState.getStateEvents("m.room.avatar", ""); let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null; - if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); + if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); const topicEvent = room.currentState.getStateEvents("m.room.topic", ""); const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()['topic'] : ''; @@ -112,7 +113,7 @@ export default class RoomProfileSettings extends React.Component { if (this.state.avatarFile) { const uri = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: uri}, ''); - newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); + newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; newState.avatarFile = null; } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 39c9f0bcf7..536abf57fc 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -26,6 +26,7 @@ import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; import { _t } from "../../../languageHandler"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; @replaceableComponent("views.rooms.LinkPreviewWidget") export default class LinkPreviewWidget extends React.Component { @@ -83,7 +84,7 @@ export default class LinkPreviewWidget extends React.Component { let src = p["og:image"]; if (src && src.startsWith("mxc://")) { - src = MatrixClientPeg.get().mxcUrlToHttp(src); + src = mediaFromMxc(src).srcHttp; } const params = { @@ -109,9 +110,11 @@ export default class LinkPreviewWidget extends React.Component { if (!SettingsStore.getValue("showImages")) { image = null; // Don't render a button to show the image, just hide it outright } - const imageMaxWidth = 100; const imageMaxHeight = 100; + const imageMaxWidth = 100; + const imageMaxHeight = 100; if (image && image.startsWith("mxc://")) { - image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight); + // We deliberately don't want a square here, so use the source HTTP thumbnail function + image = mediaFromMxc(image).getThumbnailOfSourceHttp(imageMaxWidth, imageMaxHeight, 'scale'); } let thumbHeight = imageMaxHeight; diff --git a/src/components/views/rooms/RoomDetailRow.js b/src/components/views/rooms/RoomDetailRow.js index e7c259cd98..62960930f2 100644 --- a/src/components/views/rooms/RoomDetailRow.js +++ b/src/components/views/rooms/RoomDetailRow.js @@ -18,10 +18,9 @@ import * as sdk from '../../../index'; import React, {createRef} from 'react'; import { _t } from '../../../languageHandler'; import { linkifyElement } from '../../../HtmlUtils'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; import PropTypes from 'prop-types'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; export function getDisplayAliasForRoom(room) { return room.canonicalAlias || (room.aliases ? room.aliases[0] : ""); @@ -100,13 +99,14 @@ export default class RoomDetailRow extends React.Component { { guestJoin }
) :
; + let avatarUrl = null; + if (room.avatarUrl) avatarUrl = mediaFromMxc(room.avatarUrl).getSquareThumbnailHttp(24); + return + url={avatarUrl} />
{ name }
  diff --git a/src/components/views/settings/BridgeTile.tsx b/src/components/views/settings/BridgeTile.tsx index b33219ad4a..3565d1ba2e 100644 --- a/src/components/views/settings/BridgeTile.tsx +++ b/src/components/views/settings/BridgeTile.tsx @@ -16,9 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {_t} from "../../../languageHandler"; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; import Pill from "../elements/Pill"; import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import BaseAvatar from "../avatars/BaseAvatar"; @@ -27,6 +25,7 @@ import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { isUrlPermitted } from '../../../HtmlUtils'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {mediaFromMxc} from "../../../customisations/Media"; interface IProps { ev: MatrixEvent; @@ -114,10 +113,7 @@ export default class BridgeTile extends React.PureComponent { let networkIcon; if (protocol.avatar_url) { - const avatarUrl = getHttpUriForMxc( - MatrixClientPeg.get().getHomeserverUrl(), - protocol.avatar_url, 64, 64, "crop", - ); + const avatarUrl = mediaFromMxc(protocol.avatar_url).getSquareThumbnailHttp(64); networkIcon = ; } interface IItemState { @@ -299,7 +300,8 @@ export class SpaceItem extends React.PureComponent { const isNarrow = this.props.isPanelCollapsed; const collapsed = this.state.collapsed || forceCollapsed; - const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId); + const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId) + .filter(s => !this.props.parents?.has(s.roomId)); const isActive = activeSpaces.includes(space); const itemClasses = classNames({ "mx_SpaceItem": true, @@ -312,11 +314,17 @@ export class SpaceItem extends React.PureComponent { mx_SpaceButton_narrow: isNarrow, }); const notificationState = SpaceStore.instance.getNotificationState(space.roomId); - const childItems = childSpaces && !collapsed ? : null; + + let childItems; + if (childSpaces && !collapsed) { + childItems = ; + } + let notifBadge; if (notificationState) { notifBadge =
@@ -383,12 +391,14 @@ interface ITreeLevelProps { spaces: Room[]; activeSpaces: Room[]; isNested?: boolean; + parents: Set; } const SpaceTreeLevel: React.FC = ({ spaces, activeSpaces, isNested, + parents, }) => { return
    {spaces.map(s => { @@ -397,6 +407,7 @@ const SpaceTreeLevel: React.FC = ({ activeSpaces={activeSpaces} space={s} isNested={isNested} + parents={parents} />); })}
; diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts new file mode 100644 index 0000000000..f262179f3d --- /dev/null +++ b/src/customisations/Media.ts @@ -0,0 +1,144 @@ +/* + * 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 {MatrixClientPeg} from "../MatrixClientPeg"; +import {IMediaEventContent, IPreparedMedia, prepEventContentAsMedia} from "./models/IMediaEventContent"; +import {ResizeMethod} from "../Avatar"; + +// Populate this class with the details of your customisations when copying it. + +// Implementation note: The Media class must complete the contract as shown here, though +// the constructor can be whatever is relevant to your implementation. The mediaForX +// functions below create an instance of the Media class and are used throughout the +// project. + +/** + * A media object is a representation of a "source media" and an optional + * "thumbnail media", derived from event contents or external sources. + */ +export class Media { + // Per above, this constructor signature can be whatever is helpful for you. + constructor(private prepared: IPreparedMedia) { + } + + /** + * True if the media appears to be encrypted. Actual file contents may vary. + */ + public get isEncrypted(): boolean { + return !!this.prepared.file; + } + + /** + * The MXC URI of the source media. + */ + public get srcMxc(): string { + return this.prepared.mxc; + } + + /** + * The MXC URI of the thumbnail media, if a thumbnail is recorded. Null/undefined + * otherwise. + */ + public get thumbnailMxc(): string | undefined | null { + return this.prepared.thumbnail?.mxc; + } + + /** + * Whether or not a thumbnail is recorded for this media. + */ + public get hasThumbnail(): boolean { + return !!this.thumbnailMxc; + } + + /** + * The HTTP URL for the source media. + */ + public get srcHttp(): string { + return MatrixClientPeg.get().mxcUrlToHttp(this.srcMxc); + } + + /** + * The HTTP URL for the thumbnail media (without any specified width, height, etc). Null/undefined + * if no thumbnail media recorded. + */ + public get thumbnailHttp(): string | undefined | null { + if (!this.hasThumbnail) return null; + return MatrixClientPeg.get().mxcUrlToHttp(this.thumbnailMxc); + } + + /** + * Gets the HTTP URL for the thumbnail media with the requested characteristics, if a thumbnail + * is recorded for this media. Returns null/undefined otherwise. + * @param {number} width The desired width of the thumbnail. + * @param {number} height The desired height of the thumbnail. + * @param {"scale"|"crop"} mode The desired thumbnailing mode. Defaults to scale. + * @returns {string} The HTTP URL which points to the thumbnail. + */ + public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined { + if (!this.hasThumbnail) return null; + return MatrixClientPeg.get().mxcUrlToHttp(this.thumbnailMxc, width, height, mode); + } + + /** + * Gets the HTTP URL for a thumbnail of the source media with the requested characteristics. + * @param {number} width The desired width of the thumbnail. + * @param {number} height The desired height of the thumbnail. + * @param {"scale"|"crop"} mode The desired thumbnailing mode. Defaults to scale. + * @returns {string} The HTTP URL which points to the thumbnail. + */ + public getThumbnailOfSourceHttp(width: number, height: number, mode: ResizeMethod = "scale"): string { + return MatrixClientPeg.get().mxcUrlToHttp(this.srcMxc, width, height, mode); + } + + /** + * Creates a square thumbnail of the media. If the media has a thumbnail recorded, that MXC will + * be used, otherwise the source media will be used. + * @param {number} dim The desired width and height. + * @returns {string} An HTTP URL for the thumbnail. + */ + public getSquareThumbnailHttp(dim: number): string { + if (this.hasThumbnail) { + return this.getThumbnailHttp(dim, dim, 'crop'); + } + return this.getThumbnailOfSourceHttp(dim, dim, 'crop'); + } + + /** + * Downloads the source media. + * @returns {Promise} Resolves to the server's response for chaining. + */ + public downloadSource(): Promise { + return fetch(this.srcHttp); + } +} + +/** + * Creates a media object from event content. + * @param {IMediaEventContent} content The event content. + * @returns {Media} The media object. + */ +export function mediaFromContent(content: IMediaEventContent): Media { + return new Media(prepEventContentAsMedia(content)); +} + +/** + * Creates a media object from an MXC URI. + * @param {string} mxc The MXC URI. + * @returns {Media} The media object. + */ +export function mediaFromMxc(mxc: string): Media { + return mediaFromContent({url: mxc}); +} diff --git a/src/customisations/models/IMediaEventContent.ts b/src/customisations/models/IMediaEventContent.ts new file mode 100644 index 0000000000..fb05d76a4d --- /dev/null +++ b/src/customisations/models/IMediaEventContent.ts @@ -0,0 +1,88 @@ +/* + * 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. + */ + +// TODO: These types should be elsewhere. + +export interface IEncryptedFile { + url: string; + mimetype?: string; + key: { + alg: string; + key_ops: string[]; // eslint-disable-line camelcase + kty: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: {[alg: string]: string}; + v: string; +} + +export interface IMediaEventContent { + url?: string; // required on unencrypted media + file?: IEncryptedFile; // required for *encrypted* media + info?: { + thumbnail_url?: string; // eslint-disable-line camelcase + thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase + }; +} + +export interface IPreparedMedia extends IMediaObject { + thumbnail?: IMediaObject; +} + +export interface IMediaObject { + mxc: string; + file?: IEncryptedFile; +} + +/** + * Parses an event content body into a prepared media object. This prepared media object + * can be used with other functions to manipulate the media. + * @param {IMediaEventContent} content Unredacted media event content. See interface. + * @returns {IPreparedMedia} A prepared media object. + * @throws Throws if the given content cannot be packaged into a prepared media object. + */ +export function prepEventContentAsMedia(content: IMediaEventContent): IPreparedMedia { + let thumbnail: IMediaObject = null; + if (content?.info?.thumbnail_url) { + thumbnail = { + mxc: content.info.thumbnail_url, + file: content.info.thumbnail_file, + }; + } else if (content?.info?.thumbnail_file?.url) { + thumbnail = { + mxc: content.info.thumbnail_file.url, + file: content.info.thumbnail_file, + }; + } + + if (content?.url) { + return { + thumbnail, + mxc: content.url, + file: content.file, + }; + } else if (content?.file?.url) { + return { + thumbnail, + mxc: content.file.url, + file: content.file, + }; + } + + throw new Error("Invalid file provided: cannot determine MXC URI. Has it been redacted?"); +} diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index c1f4da306b..d43cb4f38d 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -34,6 +34,10 @@ export function mdSerialize(model: EditorModel) { case "at-room-pill": return html + part.text; case "room-pill": + // Here we use the resourceId for compatibility with non-rich text clients + // See https://github.com/vector-im/element-web/issues/16660 + return html + + `[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; case "user-pill": return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; @@ -97,6 +101,9 @@ export function textSerialize(model: EditorModel) { case "at-room-pill": return text + part.text; case "room-pill": + // Here we use the resourceId for compatibility with non-rich text clients + // See https://github.com/vector-im/element-web/issues/16660 + return text + `${part.resourceId}`; case "user-pill": return text + `${part.text}`; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 63449eb99f..07d292a0e7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2150,10 +2150,10 @@ "Add comment": "Add comment", "Comment": "Comment", "There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", "Feedback": "Feedback", "Report a bug": "Report a bug", "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", "Send feedback": "Send feedback", "Confirm abort of host creation": "Confirm abort of host creation", "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.", @@ -2269,8 +2269,9 @@ "Automatically invite users": "Automatically invite users", "Upgrade private room": "Upgrade private room", "Upgrade public room": "Upgrade public room", - "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.", "You'll upgrade this room from to .": "You'll upgrade this room from to .", "Resend": "Resend", "You're all caught up.": "You're all caught up.", diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index 15884b8ad6..6927ecc153 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -22,6 +22,7 @@ import { User } from "matrix-js-sdk/src/models/user"; import { throttle } from "lodash"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { _t } from "../languageHandler"; +import {mediaFromMxc} from "../customisations/Media"; interface IState { displayName?: string; @@ -81,12 +82,13 @@ export class OwnProfileStore extends AsyncStoreWithClient { */ public getHttpAvatarUrl(size = 0): string { if (!this.avatarMxc) return null; - const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through - // XXX: We use MatrixClientPeg here rather than this.matrixClient because otherwise we'll - // race because our data stores aren't set up consistently enough that this will all be - // initialised with a store before these getter methods are called. - return MatrixClientPeg.get().mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); + const media = mediaFromMxc(this.avatarMxc); + if (!size || size <= 0) { + return media.srcHttp; + } else { + return media.getSquareThumbnailHttp(size); + } } protected async onNotReady() { diff --git a/src/usercontent/index.js b/src/usercontent/index.js index 6ecd17dcd7..13f38cc31a 100644 --- a/src/usercontent/index.js +++ b/src/usercontent/index.js @@ -1,10 +1,8 @@ function remoteRender(event) { const data = event.data; - const img = document.createElement("img"); + const img = document.createElement("span"); // we'll mask it as an image img.id = "img"; - img.src = data.imgSrc; - img.style = data.imgStyle; const a = document.createElement("a"); a.id = "a"; @@ -16,6 +14,23 @@ function remoteRender(event) { a.appendChild(img); a.appendChild(document.createTextNode(data.textContent)); + // Apply image style after so we can steal the anchor's colour. + // Style copied from a rendered version of mx_MFileBody_download_icon + img.style = (data.imgStyle || "" + + "width: 12px; height: 12px;" + + "-webkit-mask-size: 12px;" + + "mask-size: 12px;" + + "-webkit-mask-position: center;" + + "mask-position: center;" + + "-webkit-mask-repeat: no-repeat;" + + "mask-repeat: no-repeat;" + + "display: inline-block;") + "" + + + // Always add these styles + `-webkit-mask-image: url('${data.imgSrc}');` + + `mask-image: url('${data.imgSrc}');` + + `background-color: ${a.style.color};`; + const body = document.body; // Don't display scrollbars if the link takes more than one line to display. body.style = "margin: 0px; overflow: hidden"; @@ -26,20 +41,8 @@ function remoteRender(event) { } } -function remoteSetTint(event) { - const data = event.data; - - const img = document.getElementById("img"); - img.src = data.imgSrc; - img.style = data.imgStyle; - - const a = document.getElementById("a"); - a.style = data.style; -} - window.onmessage = function(e) { if (e.origin === window.location.origin) { if (e.data.blob) remoteRender(e); - else remoteSetTint(e); } }; diff --git a/src/utils/DecryptFile.js b/src/utils/DecryptFile.ts similarity index 74% rename from src/utils/DecryptFile.js rename to src/utils/DecryptFile.ts index d3625d614a..93cedbc707 100644 --- a/src/utils/DecryptFile.js +++ b/src/utils/DecryptFile.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd +Copyright 2016, 2018, 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. @@ -17,8 +16,8 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; -// Grab the client so that we can turn mxc:// URLs into https:// URLS. -import {MatrixClientPeg} from '../MatrixClientPeg'; +import {mediaFromContent} from "../customisations/Media"; +import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; // WARNING: We have to be very careful about what mime-types we allow into blobs, // as for performance reasons these are now rendered via URL.createObjectURL() @@ -54,48 +53,46 @@ import {MatrixClientPeg} from '../MatrixClientPeg'; // For the record, mime-types which must NEVER enter this list below include: // text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. -const ALLOWED_BLOB_MIMETYPES = { - 'image/jpeg': true, - 'image/gif': true, - 'image/png': true, +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', - 'video/mp4': true, - 'video/webm': true, - 'video/ogg': true, + 'video/mp4', + 'video/webm', + 'video/ogg', - 'audio/mp4': true, - 'audio/webm': true, - 'audio/aac': true, - 'audio/mpeg': true, - 'audio/ogg': true, - 'audio/wave': true, - 'audio/wav': true, - 'audio/x-wav': true, - 'audio/x-pn-wav': true, - 'audio/flac': true, - 'audio/x-flac': true, -}; + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; /** * Decrypt a file attached to a matrix event. - * @param {Object} file The json taken from the matrix event. + * @param {IEncryptedFile} file The json taken from the matrix event. * This passed to [link]{@link https://github.com/matrix-org/browser-encrypt-attachments} * as the encryption info object, so will also have the those keys in addition to * the keys below. - * @param {string} file.url An mxc:// URL for the encrypted file. - * @param {string} file.mimetype The MIME-type of the plaintext file. - * @returns {Promise} + * @returns {Promise} Resolves to a Blob of the file. */ -export function decryptFile(file) { - const url = MatrixClientPeg.get().mxcUrlToHttp(file.url); +export function decryptFile(file: IEncryptedFile): Promise { + const media = mediaFromContent({file}); // Download the encrypted file as an array buffer. - return Promise.resolve(fetch(url)).then(function(response) { + return media.downloadSource().then((response) => { return response.arrayBuffer(); - }).then(function(responseData) { + }).then((responseData) => { // Decrypt the array buffer using the information taken from // the event content. return encrypt.decryptAttachment(responseData, file); - }).then(function(dataArray) { + }).then((dataArray) => { // Turn the array into a Blob and give it the correct MIME-type. // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise @@ -103,11 +100,10 @@ export function decryptFile(file) { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { mimetype = 'application/octet-stream'; } - const blob = new Blob([dataArray], {type: mimetype}); - return blob; + return new Blob([dataArray], {type: mimetype}); }); } diff --git a/test/components/structures/GroupView-test.js b/test/components/structures/GroupView-test.js index fb942d2f7c..ee5d1b6912 100644 --- a/test/components/structures/GroupView-test.js +++ b/test/components/structures/GroupView-test.js @@ -262,7 +262,8 @@ describe('GroupView', function() { expect(longDescElement.innerHTML).toContain('
    '); expect(longDescElement.innerHTML).toContain('
  • And lists!
  • '); - const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl?width=800&height=600"; + const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl" + + "?width=800&height=600&method=scale"; expect(longDescElement.innerHTML).toContain(''); }); diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 2fd5bd6ad1..7347ff2658 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -116,6 +116,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + i*1000, mship: 'join', @@ -148,6 +149,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + i*1000, mship: 'join', @@ -193,6 +195,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + 1, mship: 'join', @@ -239,6 +242,7 @@ describe('MessagePanel', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, ts: ts0 + 5, mship: 'invite', diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js index 6d26fa36e9..dd6febc7d7 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -50,6 +50,7 @@ describe('MemberEventListSummary', function() { getAvatarUrl: () => { return "avatar.jpeg"; }, + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, }); // Override random event ID to allow for equality tests against tiles from diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index a596825c09..0a6d47a72b 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -37,6 +37,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ @@ -61,6 +62,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ @@ -86,6 +88,7 @@ describe("", () => { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; }); @@ -139,6 +142,7 @@ describe("", () => { on: () => undefined, removeListener: () => undefined, isGuest: () => false, + mxcUrlToHttp: (s) => s, }; }); @@ -284,6 +288,7 @@ describe("", () => { getAccountData: () => undefined, getUrlPreview: (url) => new Promise(() => {}), isGuest: () => false, + mxcUrlToHttp: (s) => s, }; const ev = mkEvent({ diff --git a/test/setupTests.js b/test/setupTests.js index 9c2d16a8df..6d37d48987 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -2,3 +2,5 @@ import * as languageHandler from "../src/languageHandler"; languageHandler.setLanguage('en'); languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]); + +require('jest-fetch-mock').enableMocks(); diff --git a/test/test-utils.js b/test/test-utils.js index b6e0468d86..d259fcb95f 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -213,6 +213,7 @@ export function mkStubRoom(roomId = null) { rawDisplayName: 'Member', roomId: roomId, getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }), getMembersWithMembership: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]), @@ -242,6 +243,7 @@ export function mkStubRoom(roomId = null) { removeListener: jest.fn(), getDMInviter: jest.fn(), getAvatarUrl: () => 'mxc://avatar.url/room.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', }; } diff --git a/yarn.lock b/yarn.lock index f99ea5900d..58686248f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,6 +2589,13 @@ crc-32@^0.3.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" integrity sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14= +cross-fetch@^3.0.4: + version "3.0.6" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" + integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -4918,6 +4925,14 @@ jest-environment-node@^26.6.2: jest-mock "^26.6.2" jest-util "^26.6.2" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" @@ -5573,8 +5588,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "9.8.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fb73ab687826e4d05fb8b424ab013a771213f84f" + version "9.9.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/cd38fb9b4c349eb31feac14e806e710bf6431b72" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5835,6 +5850,11 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -6448,6 +6468,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-polyfill@^8.1.3: + version "8.2.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.2.0.tgz#367394726da7561457aba2133c9ceefbd6267da0" + integrity sha512-k/TC0mIcPVF6yHhUvwAp7cvL6I2fFV7TzF1DuGPI8mBh4QQazf36xCKEHKTZKRysEoTQoQdKyP25J8MPJp7j5g== + promise@^7.0.3, promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"