Merge pull request #5714 from matrix-org/travis/media-customization

Support a media handling customisation endpoint
This commit is contained in:
Travis Ralston 2021-03-12 11:01:59 -07:00 committed by GitHub
commit d3541b78eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 564 additions and 341 deletions

19
docs/media-handling.md Normal file
View file

@ -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.

View file

@ -157,6 +157,7 @@
"jest": "^26.6.3", "jest": "^26.6.3",
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom-sixteen": "^1.0.3", "jest-environment-jsdom-sixteen": "^1.0.3",
"jest-fetch-mock": "^3.0.3",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.2",
"olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz",

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { .mx_MFileBody_download {
color: $accent-color; 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 { .mx_MFileBody_download a {

View file

@ -14,27 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import {User} from "matrix-js-sdk/src/models/user"; import {User} from "matrix-js-sdk/src/models/user";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import {mediaFromMxc} from "./customisations/Media";
export type ResizeMethod = "crop" | "scale"; export type ResizeMethod = "crop" | "scale";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // 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) { export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
let url: string; let url: string;
if (member && member.getAvatarUrl) { if (member?.getMxcAvatarUrl()) {
url = member.getAvatarUrl( url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod, resizeMethod,
false,
false,
); );
} }
if (!url) { 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) { export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
const url = getHttpUriForMxc( if (!user.avatarUrl) return null;
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(
Math.floor(width * window.devicePixelRatio), Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio), Math.floor(height * window.devicePixelRatio),
resizeMethod, resizeMethod,
); );
if (!url || url.length === 0) {
return null;
}
return url;
} }
function isValidHexColor(color: string): boolean { 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) { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
if (!room) return null; // null-guard if (!room) return null; // null-guard
const explicitRoomAvatar = room.getAvatarUrl( if (room.getMxcAvatarUrl()) {
MatrixClientPeg.get().getHomeserverUrl(), return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
} }
// space rooms cannot be DMs so skip the rest // 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) // then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember(); otherMember = room.getAvatarFallbackMember();
} }
if (otherMember) { if (otherMember?.getMxcAvatarUrl()) {
return otherMember.getAvatarUrl( return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
} }
return null; return null;
} }

View file

@ -32,10 +32,10 @@ import { AllHtmlEntities } from 'html-entities';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import cheerio from 'cheerio'; import cheerio from 'cheerio';
import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread"; import ReplyThread from "./components/views/elements/ReplyThread";
import {mediaFromMxc} from "./customisations/Media";
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -181,11 +181,9 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to
if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) {
return { tagName, attribs: {}}; return { tagName, attribs: {}};
} }
attribs.src = MatrixClientPeg.get().mxcUrlToHttp( const width = Number(attribs.width) || 800;
attribs.src, const height = Number(attribs.height) || 600;
attribs.width || 800, attribs.src = mediaFromMxc(attribs.src).getThumbnailOfSourceHttp(width, height);
attribs.height || 600,
);
return { tagName, attribs }; return { tagName, attribs };
}, },
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {

View file

@ -36,6 +36,7 @@ import {SettingLevel} from "./settings/SettingLevel";
import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers";
import RoomViewStore from "./stores/RoomViewStore"; import RoomViewStore from "./stores/RoomViewStore";
import UserActivity from "./UserActivity"; import UserActivity from "./UserActivity";
import {mediaFromMxc} from "./customisations/Media";
/* /*
* Dispatches: * Dispatches:
@ -150,7 +151,7 @@ export const Notifier = {
// Ideally in here we could use MSC1310 to detect the type of file, and reject it. // Ideally in here we could use MSC1310 to detect the type of file, and reject it.
return { return {
url: MatrixClientPeg.get().mxcUrlToHttp(content.url), url: mediaFromMxc(content.url).srcHttp,
name: content.name, name: content.name,
type: content.type, type: content.type,
size: content.size, size: content.size,

View file

@ -27,6 +27,7 @@ import {sortBy} from "lodash";
import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import {ICompletion, ISelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore"; import FlairStore from "../stores/FlairStore";
import {mediaFromMxc} from "../customisations/Media";
const COMMUNITY_REGEX = /\B\+\S*/g; const COMMUNITY_REGEX = /\B\+\S*/g;
@ -95,7 +96,7 @@ export default class CommunityProvider extends AutocompleteProvider {
name={name || groupId} name={name || groupId}
width={24} width={24}
height={24} height={24}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} /> url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(24) : null} />
</PillCompletion> </PillCompletion>
), ),
range, range,

View file

@ -39,6 +39,7 @@ import {Group} from "matrix-js-sdk";
import {allSettled, sleep} from "../../utils/promise"; import {allSettled, sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
const LONG_DESC_PLACEHOLDER = _td( const LONG_DESC_PLACEHOLDER = _td(
@ -368,8 +369,7 @@ class FeaturedUser extends React.Component {
const permalink = makeUserPermalink(this.props.summaryInfo.user_id); const permalink = makeUserPermalink(this.props.summaryInfo.user_id);
const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>; const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>;
const httpUrl = MatrixClientPeg.get() const httpUrl = mediaFromMxc(this.props.summaryInfo.avatar_url).getSquareThumbnailHttp(64);
.mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64);
const deleteButton = this.props.editing ? const deleteButton = this.props.editing ?
<img <img
@ -981,10 +981,9 @@ export default class GroupView extends React.Component {
<Spinner /> <Spinner />
</div>; </div>;
} }
const httpInviterAvatar = this.state.inviterProfile ? const httpInviterAvatar = this.state.inviterProfile
this._matrixClient.mxcUrlToHttp( ? mediaFromMxc(this.state.inviterProfile.avatarUrl).getSquareThumbnailHttp(36)
this.state.inviterProfile.avatarUrl, 36, 36, : null;
) : null;
const inviter = group.inviter || {}; const inviter = group.inviter || {};
let inviterName = inviter.userId; let inviterName = inviter.userId;

View file

@ -36,11 +36,11 @@ import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar"; import IndicatorScrollbar from "../structures/IndicatorScrollbar";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import RoomListNumResults from "../views/rooms/RoomListNumResults"; import RoomListNumResults from "../views/rooms/RoomListNumResults";
import LeftPanelWidget from "./LeftPanelWidget"; import LeftPanelWidget from "./LeftPanelWidget";
import SpacePanel from "../views/spaces/SpacePanel"; import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -121,7 +121,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage");
if (settingBgMxc) { if (settingBgMxc) {
avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize);
} }
const avatarUrlProp = `url(${avatarUrl})`; const avatarUrlProp = `url(${avatarUrl})`;

View file

@ -27,7 +27,6 @@ import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig'; import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
@ -35,6 +34,7 @@ import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics"; import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
@ -521,10 +521,9 @@ export default class RoomDirectory extends React.Component {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`; topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
} }
topic = linkifyAndSanitizeHtml(topic); topic = linkifyAndSanitizeHtml(topic);
const avatarUrl = getHttpUriForMxc( let avatarUrl = null;
MatrixClientPeg.get().getHomeserverUrl(), if (room.avatar_url) avatarUrl = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(32);
room.avatar_url, 32, 32, "crop",
);
return [ return [
<div key={ `${room.room_id}_avatar` } <div key={ `${room.room_id}_avatar` }
onClick={(ev) => this.onRoomClicked(room, ev)} onClick={(ev) => this.onRoomClicked(room, ev)}

View file

@ -34,6 +34,7 @@ import {EnhancedMap} from "../../utils/maps";
import StyledCheckbox from "../views/elements/StyledCheckbox"; import StyledCheckbox from "../views/elements/StyledCheckbox";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import BaseAvatar from "../views/avatars/BaseAvatar"; import BaseAvatar from "../views/avatars/BaseAvatar";
import {mediaFromMxc} from "../../customisations/Media";
interface IProps { interface IProps {
space: Room; space: Room;
@ -158,12 +159,7 @@ const SubSpace: React.FC<ISubspaceProps> = ({
let url: string; let url: string;
if (space.avatar_url) { if (space.avatar_url) {
url = MatrixClientPeg.get().mxcUrlToHttp( url = mediaFromMxc(space.avatar_url).getSquareThumbnailHttp(Math.floor(24 * window.devicePixelRatio));
space.avatar_url,
Math.floor(24 * window.devicePixelRatio),
Math.floor(24 * window.devicePixelRatio),
"crop",
);
} }
return <div className="mx_SpaceRoomDirectory_subspace"> return <div className="mx_SpaceRoomDirectory_subspace">
@ -265,12 +261,7 @@ const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinCli
let url: string; let url: string;
if (room.avatar_url) { if (room.avatar_url) {
url = cli.mxcUrlToHttp( url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(32 * window.devicePixelRatio));
room.avatar_url,
Math.floor(32 * window.devicePixelRatio),
Math.floor(32 * window.devicePixelRatio),
"crop",
);
} }
const content = <React.Fragment> const content = <React.Fragment>

View file

@ -25,6 +25,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units"; import {toPx} from "../../../utils/units";
import {ResizeMethod} from "../../../Avatar";
interface IProps { interface IProps {
name: string; // The name (first initial used as default) name: string; // The name (first initial used as default)
@ -35,7 +36,7 @@ interface IProps {
width?: number; width?: number;
height?: number; height?: number;
// XXX: resizeMethod not actually used. // XXX: resizeMethod not actually used.
resizeMethod?: string; resizeMethod?: ResizeMethod;
defaultToInitialLetter?: boolean; // true to add default url defaultToInitialLetter?: boolean; // true to add default url
onClick?: React.MouseEventHandler; onClick?: React.MouseEventHandler;
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>; inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
export interface IProps { export interface IProps {
groupId?: string; groupId?: string;
@ -25,7 +26,7 @@ export interface IProps {
groupAvatarUrl?: string; groupAvatarUrl?: string;
width?: number; width?: number;
height?: number; height?: number;
resizeMethod?: string; resizeMethod?: ResizeMethod;
onClick?: React.MouseEventHandler; onClick?: React.MouseEventHandler;
} }
@ -38,8 +39,8 @@ export default class GroupAvatar extends React.Component<IProps> {
}; };
getGroupAvatarUrl() { getGroupAvatarUrl() {
return MatrixClientPeg.get().mxcUrlToHttp( if (!this.props.groupAvatarUrl) return null;
this.props.groupAvatarUrl, return mediaFromMxc(this.props.groupAvatarUrl).getThumbnailOfSourceHttp(
this.props.width, this.props.width,
this.props.height, this.props.height,
this.props.resizeMethod, this.props.resizeMethod,

View file

@ -20,16 +20,17 @@ import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar"; import BaseAvatar from "./BaseAvatar";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {ResizeMethod} from "../../../Avatar";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> { interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember; member: RoomMember;
fallbackUserId?: string; fallbackUserId?: string;
width: number; width: number;
height: number; height: number;
resizeMethod?: string; resizeMethod?: ResizeMethod;
// The onClick to give the avatar // The onClick to give the avatar
onClick?: React.MouseEventHandler; onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
@ -63,18 +64,19 @@ export default class MemberAvatar extends React.Component<IProps, IState> {
} }
private static getState(props: IProps): IState { private static getState(props: IProps): IState {
if (props.member && props.member.name) { if (props.member?.name) {
return { let imageUrl = null;
name: props.member.name, if (props.member.getMxcAvatarUrl()) {
title: props.title || props.member.userId, imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(
imageUrl: props.member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio), Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod, props.resizeMethod,
false, );
false, }
), return {
name: props.member.name,
title: props.title || props.member.userId,
imageUrl: imageUrl,
}; };
} else if (props.fallbackUserId) { } else if (props.fallbackUserId) {
return { return {

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import React, {ComponentProps} from 'react'; import React, {ComponentProps} from 'react';
import Room from 'matrix-js-sdk/src/models/room'; import Room from 'matrix-js-sdk/src/models/room';
import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView'; import ImageView from '../elements/ImageView';
@ -24,6 +23,7 @@ import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
import {ResizeMethod} from "../../../Avatar"; import {ResizeMethod} from "../../../Avatar";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> { interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
// Room may be left unset here, but if it is, // Room may be left unset here, but if it is,
@ -90,16 +90,16 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}; };
private static getImageUrls(props: IProps): string[] { private static getImageUrls(props: IProps): string[] {
return [ let oobAvatar = null;
getHttpUriForMxc( if (props.oobData.avatarUrl) {
MatrixClientPeg.get().getHomeserverUrl(), oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
// Default props don't play nicely with getDerivedStateFromProps
//props.oobData !== undefined ? props.oobData.avatarUrl : {},
props.oobData.avatarUrl,
Math.floor(props.width * window.devicePixelRatio), Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod, props.resizeMethod,
), // highest priority );
}
return [
oobAvatar, // highest priority
RoomAvatar.getRoomAvatarUrl(props), RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) { ].filter(function(url) {
return (url !== null && url !== ""); return (url !== null && url !== "");

View file

@ -14,21 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ComponentProps, useContext} from 'react'; import React, {ComponentProps} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {IApp} from "../../../stores/WidgetStore"; import {IApp} from "../../../stores/WidgetStore";
import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; import BaseAvatar, {BaseAvatarType} from "./BaseAvatar";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> { interface IProps extends Omit<ComponentProps<BaseAvatarType>, "name" | "url" | "urls"> {
app: IApp; app: IApp;
} }
const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => { const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 20, ...props }) => {
const cli = useContext(MatrixClientContext);
let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")];
// heuristics for some better icons until Widgets support their own icons // heuristics for some better icons until Widgets support their own icons
if (app.type.includes("jitsi")) { if (app.type.includes("jitsi")) {
@ -47,7 +44,7 @@ const WidgetAvatar: React.FC<IProps> = ({ app, className, width = 20, height = 2
name={app.id} name={app.id}
className={classNames("mx_WidgetAvatar", className)} className={classNames("mx_WidgetAvatar", className)}
// MSC2765 // 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} urls={iconUrls}
width={width} width={width}
height={height} height={height}

View file

@ -26,12 +26,12 @@ import SdkConfig from "../../../SdkConfig";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import InviteDialog from "./InviteDialog"; import InviteDialog from "./InviteDialog";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite";
import StyledCheckbox from "../elements/StyledCheckbox"; import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog"; import ErrorDialog from "./ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
roomId: string; roomId: string;
@ -142,12 +142,14 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
private renderPerson(person: IPerson, key: any) { private renderPerson(person: IPerson, key: any) {
const avatarSize = 36; const avatarSize = 36;
let avatarUrl = null;
if (person.user.getMxcAvatarUrl()) {
avatarUrl = mediaFromMxc(person.user.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize);
}
return ( return (
<div className="mx_CommunityPrototypeInviteDialog_person" key={key}> <div className="mx_CommunityPrototypeInviteDialog_person" key={key}>
<BaseAvatar <BaseAvatar
url={getHttpUriForMxc( url={avatarUrl}
MatrixClientPeg.get().getHomeserverUrl(), person.user.getMxcAvatarUrl(),
avatarSize, avatarSize, "crop")}
name={person.user.name} name={person.user.name}
idName={person.user.userId} idName={person.user.userId}
width={avatarSize} width={avatarSize}

View file

@ -21,6 +21,7 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
/* /*
* A dialog for confirming an operation on another user. * A dialog for confirming an operation on another user.
@ -108,8 +109,9 @@ export default class ConfirmUserActionDialog extends React.Component {
name = this.props.member.name; name = this.props.member.name;
userId = this.props.member.userId; userId = this.props.member.userId;
} else { } else {
const httpAvatarUrl = this.props.groupMember.avatarUrl ? const httpAvatarUrl = this.props.groupMember.avatarUrl
this.props.matrixClient.mxcUrlToHttp(this.props.groupMember.avatarUrl, 48, 48) : null; ? mediaFromMxc(this.props.groupMember.avatarUrl).getSquareThumbnailHttp(48)
: null;
name = this.props.groupMember.displayname || this.props.groupMember.userId; name = this.props.groupMember.displayname || this.props.groupMember.userId;
userId = this.props.groupMember.userId; userId = this.props.groupMember.userId;
avatar = <BaseAvatar name={name} url={httpAvatarUrl} width={48} height={48} />; avatar = <BaseAvatar name={name} url={httpAvatarUrl} width={48} height={48} />;

View file

@ -24,6 +24,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import FlairStore from "../../../stores/FlairStore"; import FlairStore from "../../../stores/FlairStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
communityId: string; communityId: string;
@ -118,7 +119,7 @@ export default class EditCommunityPrototypeDialog extends React.PureComponent<IP
let preview = <img src={this.state.avatarPreview} className="mx_EditCommunityPrototypeDialog_avatar" />; let preview = <img src={this.state.avatarPreview} className="mx_EditCommunityPrototypeDialog_avatar" />;
if (!this.state.avatarPreview) { if (!this.state.avatarPreview) {
if (this.state.currentAvatarUrl) { if (this.state.currentAvatarUrl) {
const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); const url = mediaFromMxc(this.state.currentAvatarUrl).srcHttp;
preview = <img src={url} className="mx_EditCommunityPrototypeDialog_avatar" />; preview = <img src={url} className="mx_EditCommunityPrototypeDialog_avatar" />;
} else { } else {
preview = <div className="mx_EditCommunityPrototypeDialog_placeholderAvatar" /> preview = <div className="mx_EditCommunityPrototypeDialog_placeholderAvatar" />

View file

@ -20,6 +20,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
const PHASE_START = 0; const PHASE_START = 0;
const PHASE_SHOW_SAS = 1; const PHASE_SHOW_SAS = 1;
@ -123,22 +124,21 @@ export default class IncomingSasDialog extends React.Component {
const Spinner = sdk.getComponent("views.elements.Spinner"); const Spinner = sdk.getComponent("views.elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); 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; 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 = <div className="mx_IncomingSasDialog_opponentProfile"> profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={this.state.opponentProfile.displayname} <BaseAvatar name={oppProfile.displayname}
idName={this.props.verifier.userId} idName={this.props.verifier.userId}
url={MatrixClientPeg.get().mxcUrlToHttp( url={url}
this.state.opponentProfile.avatar_url,
Math.floor(48 * window.devicePixelRatio),
Math.floor(48 * window.devicePixelRatio),
'crop',
)}
width={48} height={48} resizeMethod='crop' width={48} height={48} resizeMethod='crop'
/> />
<h2>{this.state.opponentProfile.displayname}</h2> <h2>{oppProfile.displayname}</h2>
</div>; </div>;
} else if (this.state.opponentProfileError) { } else if (this.state.opponentProfileError) {
profile = <div> profile = <div>

View file

@ -22,7 +22,6 @@ import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Pe
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import * as Email from "../../../email"; import * as Email from "../../../email";
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils";
import {abbreviateUrl} from "../../../utils/UrlUtils"; import {abbreviateUrl} from "../../../utils/UrlUtils";
@ -43,6 +42,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent"; 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. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -160,9 +160,9 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
width={avatarSize} height={avatarSize} /> width={avatarSize} height={avatarSize} />
: <BaseAvatar : <BaseAvatar
className='mx_InviteDialog_userTile_avatar' className='mx_InviteDialog_userTile_avatar'
url={getHttpUriForMxc( url={this.props.member.getMxcAvatarUrl()
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(), ? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
avatarSize, avatarSize, "crop")} : null}
name={this.props.member.name} name={this.props.member.name}
idName={this.props.member.userId} idName={this.props.member.userId}
width={avatarSize} width={avatarSize}
@ -262,9 +262,9 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
src={require("../../../../res/img/icon-email-pill-avatar.svg")} src={require("../../../../res/img/icon-email-pill-avatar.svg")}
width={avatarSize} height={avatarSize} /> width={avatarSize} height={avatarSize} />
: <BaseAvatar : <BaseAvatar
url={getHttpUriForMxc( url={this.props.member.getMxcAvatarUrl()
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(), ? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
avatarSize, avatarSize, "crop")} : null}
name={this.props.member.name} name={this.props.member.name}
idName={this.props.member.userId} idName={this.props.member.userId}
width={avatarSize} width={avatarSize}

View file

@ -19,10 +19,10 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js'; import { UserAddressType } from '../../../UserAddress.js';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.elements.AddressTile") @replaceableComponent("views.elements.AddressTile")
export default class AddressTile extends React.Component { 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); const isMatrixAddress = ['mx-user-id', 'mx-room-id'].includes(address.addressType);
if (isMatrixAddress && address.avatarMxc) { if (isMatrixAddress && address.avatarMxc) {
imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp( imgUrls.push(mediaFromMxc(address.avatarMxc).getSquareThumbnailHttp(25));
address.avatarMxc, 25, 25, 'crop',
));
} else if (address.addressType === 'email') { } else if (address.addressType === 'email') {
imgUrls.push(require("../../../../res/img/icon-email-user.svg")); imgUrls.push(require("../../../../res/img/icon-email-user.svg"));
} }

View file

@ -70,9 +70,7 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const userId = client.getUserId(); const userId = client.getUserId();
const profileInfo = await client.getProfileInfo(userId); const profileInfo = await client.getProfileInfo(userId);
const avatarUrl = Avatar.avatarUrlForUser( const avatarUrl = profileInfo.avatar_url;
{avatarUrl: profileInfo.avatar_url},
AVATAR_SIZE, AVATAR_SIZE, "crop");
this.setState({ this.setState({
userId, userId,
@ -113,8 +111,9 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
name: displayname, name: displayname,
userId: userId, userId: userId,
getAvatarUrl: (..._) => { getAvatarUrl: (..._) => {
return avatarUrl; return Avatar.avatarUrlForUser({avatarUrl}, AVATAR_SIZE, AVATAR_SIZE, "crop");
}, },
getMxcAvatarUrl: () => avatarUrl,
}; };
return event; return event;

View file

@ -20,6 +20,7 @@ import FlairStore from '../../../stores/FlairStore';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
class FlairAvatar extends React.Component { class FlairAvatar extends React.Component {
@ -39,8 +40,7 @@ class FlairAvatar extends React.Component {
} }
render() { render() {
const httpUrl = this.context.mxcUrlToHttp( const httpUrl = mediaFromMxc(this.props.groupProfile.avatarUrl).getSquareThumbnailHttp(16);
this.props.groupProfile.avatarUrl, 16, 16, 'scale', false);
const tooltip = this.props.groupProfile.name ? const tooltip = this.props.groupProfile.name ?
`${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`: `${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`:
this.props.groupProfile.groupId; this.props.groupProfile.groupId;

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 - 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 {getPrimaryPermalinkEntity, parseAppLocalLink} from "../../../utils/permalinks/Permalinks";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {mediaFromMxc} from "../../../customisations/Media";
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -254,12 +253,12 @@ class Pill extends React.Component {
case Pill.TYPE_GROUP_MENTION: { case Pill.TYPE_GROUP_MENTION: {
if (this.state.group) { if (this.state.group) {
const {avatarUrl, groupId, name} = this.state.group; const {avatarUrl, groupId, name} = this.state.group;
const cli = MatrixClientPeg.get();
linkText = groupId; linkText = groupId;
if (this.props.shouldShowPillAvatar) { if (this.props.shouldShowPillAvatar) {
avatar = <BaseAvatar name={name || groupId} width={16} height={16} aria-hidden="true" avatar = <BaseAvatar
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 16, 16) : null} />; name={name || groupId} width={16} height={16} aria-hidden="true"
url={avatarUrl ? mediaFromMxc(avatarUrl).getSquareThumbnailHttp(16) : null} />;
} }
pillClass = 'mx_GroupPill'; pillClass = 'mx_GroupPill';
} }

View file

@ -24,6 +24,7 @@ import AccessibleButton from "./AccessibleButton";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login"; import {IdentityProviderBrand, IIdentityProvider, ISSOFlow} from "../../../Login";
import AccessibleTooltipButton from "./AccessibleTooltipButton"; import AccessibleTooltipButton from "./AccessibleTooltipButton";
import {mediaFromMxc} from "../../../customisations/Media";
interface ISSOButtonProps extends Omit<IProps, "flow"> { interface ISSOButtonProps extends Omit<IProps, "flow"> {
idp: IIdentityProvider; idp: IIdentityProvider;
@ -72,7 +73,7 @@ const SSOButton: React.FC<ISSOButtonProps> = ({
brandClass = `mx_SSOButton_brand_${brandName}`; brandClass = `mx_SSOButton_brand_${brandName}`;
icon = <img src={brandIcon} height="24" width="24" alt={brandName} />; icon = <img src={brandIcon} height="24" width="24" alt={brandName} />;
} else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) { } else if (typeof idp?.icon === "string" && idp.icon.startsWith("mxc://")) {
const src = matrixClient.mxcUrlToHttp(idp.icon, 24, 24, "crop", true); const src = mediaFromMxc(idp.icon).getSquareThumbnailHttp(24);
icon = <img src={src} height="24" width="24" alt={idp.name} />; icon = <img src={src} height="24" width="24" alt={idp.name} />;
} }

View file

@ -30,6 +30,7 @@ import GroupFilterOrderStore from '../../../stores/GroupFilterOrderStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {mediaFromMxc} from "../../../customisations/Media";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
// A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents // A class for a child of GroupFilterPanel (possibly wrapped in a DNDTagTile) that represents
@ -130,11 +131,11 @@ export default class TagTile extends React.Component {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const profile = this.state.profile || {}; const profile = this.state.profile || {};
const name = profile.name || this.props.tag; const name = profile.name || this.props.tag;
const avatarHeight = 32; const avatarSize = 32;
const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp( const httpUrl = profile.avatarUrl
profile.avatarUrl, avatarHeight, avatarHeight, "crop", ? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarSize)
) : null; : null;
const isPrototype = SettingsStore.getValue("feature_communities_v2_prototypes"); const isPrototype = SettingsStore.getValue("feature_communities_v2_prototypes");
const className = classNames({ const className = classNames({
@ -180,8 +181,8 @@ export default class TagTile extends React.Component {
name={name} name={name}
idName={this.props.tag} idName={this.props.tag}
url={httpUrl} url={httpUrl}
width={avatarHeight} width={avatarSize}
height={avatarHeight} height={avatarSize}
/> />
{contextButton} {contextButton}
{badgeElement} {badgeElement}

View file

@ -27,6 +27,7 @@ import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/Contex
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
// XXX this class copies a lot from RoomTile.js // XXX this class copies a lot from RoomTile.js
@replaceableComponent("views.groups.GroupInviteTile") @replaceableComponent("views.groups.GroupInviteTile")
@ -117,8 +118,9 @@ export default class GroupInviteTile extends React.Component {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const groupName = this.props.group.name || this.props.group.groupId; const groupName = this.props.group.name || this.props.group.groupId;
const httpAvatarUrl = this.props.group.avatarUrl ? const httpAvatarUrl = this.props.group.avatarUrl
this.context.mxcUrlToHttp(this.props.group.avatarUrl, 24, 24) : null; ? mediaFromMxc(this.props.group.avatarUrl).getSquareThumbnailHttp(24)
: null;
const av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />; const av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />;

View file

@ -23,6 +23,7 @@ import dis from '../../../dispatcher/dispatcher';
import { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.groups.GroupMemberTile") @replaceableComponent("views.groups.GroupMemberTile")
export default class GroupMemberTile extends React.Component { export default class GroupMemberTile extends React.Component {
@ -46,10 +47,9 @@ export default class GroupMemberTile extends React.Component {
const EntityTile = sdk.getComponent('rooms.EntityTile'); const EntityTile = sdk.getComponent('rooms.EntityTile');
const name = this.props.member.displayname || this.props.member.userId; const name = this.props.member.displayname || this.props.member.userId;
const avatarUrl = this.context.mxcUrlToHttp( const avatarUrl = this.props.member.avatarUrl
this.props.member.avatarUrl, ? mediaFromMxc(this.props.member.avatarUrl).getSquareThumbnailHttp(36)
36, 36, 'crop', : null;
);
const av = ( const av = (
<BaseAvatar <BaseAvatar

View file

@ -25,6 +25,7 @@ import GroupStore from '../../../stores/GroupStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.groups.GroupRoomInfo") @replaceableComponent("views.groups.GroupRoomInfo")
export default class GroupRoomInfo extends React.Component { export default class GroupRoomInfo extends React.Component {
@ -204,10 +205,8 @@ export default class GroupRoomInfo extends React.Component {
const avatarUrl = this.state.groupRoom.avatarUrl; const avatarUrl = this.state.groupRoom.avatarUrl;
let avatarElement; let avatarElement;
if (avatarUrl) { if (avatarUrl) {
const httpUrl = this.context.mxcUrlToHttp(avatarUrl, 800, 800); const httpUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(800);
avatarElement = (<div className="mx_MemberInfo_avatar"> avatarElement = <div className="mx_MemberInfo_avatar"><img src={httpUrl} /></div>;
<img src={httpUrl} />
</div>);
} }
const groupRoomName = this.state.groupRoom.displayname; const groupRoomName = this.state.groupRoom.displayname;

View file

@ -21,6 +21,7 @@ import dis from '../../../dispatcher/dispatcher';
import { GroupRoomType } from '../../../groups'; import { GroupRoomType } from '../../../groups';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.groups.GroupRoomTile") @replaceableComponent("views.groups.GroupRoomTile")
class GroupRoomTile extends React.Component { class GroupRoomTile extends React.Component {
@ -42,10 +43,9 @@ class GroupRoomTile extends React.Component {
render() { render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const avatarUrl = this.context.mxcUrlToHttp( const avatarUrl = this.props.groupRoom.avatarUrl
this.props.groupRoom.avatarUrl, ? mediaFromMxc(this.props.groupRoom.avatarUrl).getSquareThumbnailHttp(36)
36, 36, 'crop', : null;
);
const av = ( const av = (
<BaseAvatar name={this.props.groupRoom.displayname} <BaseAvatar name={this.props.groupRoom.displayname}

View file

@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
function nop() {} function nop() {}
@ -73,8 +74,9 @@ class GroupTile extends React.Component {
const descElement = this.props.showDescription ? const descElement = this.props.showDescription ?
<div className="mx_GroupTile_desc">{ profile.shortDescription }</div> : <div className="mx_GroupTile_desc">{ profile.shortDescription }</div> :
<div />; <div />;
const httpUrl = profile.avatarUrl ? this.context.mxcUrlToHttp( const httpUrl = profile.avatarUrl
profile.avatarUrl, avatarHeight, avatarHeight, "crop") : null; ? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight)
: null;
let avatarElement = ( let avatarElement = (
<div className="mx_GroupTile_avatar"> <div className="mx_GroupTile_avatar">

View file

@ -17,11 +17,11 @@
import React from 'react'; import React from 'react';
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
@replaceableComponent("views.messages.MAudioBody") @replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.Component { export default class MAudioBody extends React.Component {
@ -41,11 +41,11 @@ export default class MAudioBody extends React.Component {
} }
_getContentUrl() { _getContentUrl() {
const content = this.props.mxEvent.getContent(); const media = mediaFromContent(this.props.mxEvent.getContent());
if (content.file !== undefined) { if (media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url); return media.srcHttp;
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016, 2018, 2021 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import filesize from 'filesize'; import filesize from 'filesize';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {decryptFile} from '../../../utils/DecryptFile'; import {decryptFile} from '../../../utils/DecryptFile';
import Tinter from '../../../Tinter';
import request from 'browser-request';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent"; 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") async function cacheDownloadIcon() {
let tintedDownloadImageURL; if (downloadIconUrl) return; // cached already
// Track a list of mounted MFileBody instances so that we can update const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
// the require("../../../../res/img/download.svg") when the tint changes. downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
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();
});
});
} }
Tinter.registerTintable(updateTintedDownloadImage); // Cache the asset immediately
cacheDownloadIcon();
// User supplied content can contain scripts, we have to be careful that // 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 // 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); const style = window.getComputedStyle(element, null);
let cssText = style.cssText; let cssText = style.cssText;
// noinspection EqualityComparisonWithCoercionJS
if (cssText == "") { if (cssText == "") {
// Firefox doesn't implement ".cssText" for computed styles. // Firefox doesn't implement ".cssText" for computed styles.
// https://bugzilla.mozilla.org/show_bug.cgi?id=137687 // https://bugzilla.mozilla.org/show_bug.cgi?id=137687
@ -145,7 +117,6 @@ export default class MFileBody extends React.Component {
this._iframe = createRef(); this._iframe = createRef();
this._dummyLink = createRef(); this._dummyLink = createRef();
this._downloadImage = createRef();
} }
/** /**
@ -178,16 +149,8 @@ export default class MFileBody extends React.Component {
} }
_getContentUrl() { _getContentUrl() {
const content = this.props.mxEvent.getContent(); const media = mediaFromContent(this.props.mxEvent.getContent());
return MatrixClientPeg.get().mxcUrlToHttp(content.url); return media.srcHttp;
}
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();
} }
componentDidUpdate(prevProps, prevState) { 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() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content); const text = this.presentableTextForFile(content);
const isEncrypted = content.file !== undefined; const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const fileSize = content.info ? content.info.size : null; const fileSize = content.info ? content.info.size : null;
const fileType = content.info ? content.info.mimetype : "application/octet-stream"; 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 // When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => { const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({ 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), style: computedStyle(this._dummyLink.current),
blob: this.state.decryptedBlob, blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file // Set a download attribute for encrypted files so that the file
@ -384,7 +326,7 @@ export default class MFileBody extends React.Component {
{placeholder} {placeholder}
<div className="mx_MFileBody_download"> <div className="mx_MFileBody_download">
<a {...downloadProps}> <a {...downloadProps}>
<img src={tintedDownloadImageURL} width="12" height="14" ref={this._downloadImage} /> <span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) } { _t("Download %(text)s", { text: text }) }
</a> </a>
</div> </div>

View file

@ -28,6 +28,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
@replaceableComponent("views.messages.MImageBody") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component {
@ -70,7 +71,7 @@ export default class MImageBody extends React.Component {
this._image = createRef(); 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) { onClientSync(syncState, prevState) {
if (this.unmounted) return; if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
@ -167,16 +168,16 @@ export default class MImageBody extends React.Component {
} }
_getContentUrl() { _getContentUrl() {
const content = this.props.mxEvent.getContent(); const media = mediaFromContent(this.props.mxEvent.getContent());
if (content.file !== undefined) { if (media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
return this.context.mxcUrlToHttp(content.url); return media.srcHttp;
} }
} }
_getThumbUrl() { _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 // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced. // thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable. // custom timeline widths seems preferable.
@ -185,21 +186,19 @@ export default class MImageBody extends React.Component {
const thumbHeight = Math.round(600 * pixelRatio); const thumbHeight = Math.round(600 * pixelRatio);
const content = this.props.mxEvent.getContent(); 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. // Don't use the thumbnail for clients wishing to autoplay gifs.
if (this.state.decryptedThumbnailUrl) { if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} }
return this.state.decryptedUrl; 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, // special case to return clientside sender-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent // given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar // billion lol attacks and similar
return this.context.mxcUrlToHttp( return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale');
content.info.thumbnail_url,
thumbWidth,
thumbHeight,
);
} else { } else {
// we try to download the correct resolution // we try to download the correct resolution
// for hi-res images (like retina screenshots). // for hi-res images (like retina screenshots).
@ -218,7 +217,7 @@ export default class MImageBody extends React.Component {
pixelRatio === 1.0 || pixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size) (!info || !info.w || !info.h || !info.size)
) { ) {
return this.context.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
} else { } else {
// we should only request thumbnails if the image is bigger than 800x600 // we should only request thumbnails if the image is bigger than 800x600
// (or 1600x1200 on retina) otherwise the image in the timeline will just // (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.w > thumbWidth ||
info.h > thumbHeight info.h > thumbHeight
); );
const isLargeFileSize = info.size > 1*1024*1024; const isLargeFileSize = info.size > 1*1024*1024; // 1mb
if (isLargeFileSize && isLargerThanThumbnail) { if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and bytewise to clutter our timeline so // 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 // 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). // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
return this.context.mxcUrlToHttp( return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
content.url,
thumbWidth,
thumbHeight,
);
} else { } else {
// download the original image otherwise, so we can scale it client side // download the original image otherwise, so we can scale it client side
// to take pixelRatio into account. // to take pixelRatio into account.
// ( no width/height means we want the original image) return media.srcHttp;
return this.context.mxcUrlToHttp(
content.url,
);
} }
} }
} }

View file

@ -17,12 +17,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
interface IProps { interface IProps {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
@ -76,11 +76,11 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} }
private getContentUrl(): string|null { private getContentUrl(): string|null {
const content = this.props.mxEvent.getContent(); const media = mediaFromContent(this.props.mxEvent.getContent());
if (content.file !== undefined) { if (media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url); return media.srcHttp;
} }
} }
@ -91,10 +91,11 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
private getThumbUrl(): string|null { private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { const media = mediaFromContent(content);
if (media.isEncrypted) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} else if (content.info && content.info.thumbnail_url) { } else if (media.hasThumbnail) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url); return media.thumbnailHttp;
} else { } else {
return null; return null;
} }

View file

@ -24,6 +24,7 @@ import * as sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.messages.RoomAvatarEvent") @replaceableComponent("views.messages.RoomAvatarEvent")
export default class RoomAvatarEvent extends React.Component { export default class RoomAvatarEvent extends React.Component {
@ -35,7 +36,7 @@ export default class RoomAvatarEvent extends React.Component {
onAvatarClick = () => { onAvatarClick = () => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const ev = this.props.mxEvent; 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 room = cli.getRoom(this.props.mxEvent.getRoomId());
const text = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', { const text = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', {

View file

@ -64,6 +64,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {mediaFromMxc} from "../../../customisations/Media";
interface IDevice { interface IDevice {
deviceId: string; deviceId: string;
@ -1424,14 +1425,14 @@ const UserInfoHeader: React.FC<{
const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl; const avatarUrl = member.getMxcAvatarUrl ? member.getMxcAvatarUrl() : member.avatarUrl;
if (!avatarUrl) return; if (!avatarUrl) return;
const httpUrl = cli.mxcUrlToHttp(avatarUrl); const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
const params = { const params = {
src: httpUrl, src: httpUrl,
name: member.name, name: member.name,
}; };
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, [cli, member]); }, [member]);
const avatarElement = ( const avatarElement = (
<div className="mx_UserInfo_avatar"> <div className="mx_UserInfo_avatar">

View file

@ -21,6 +21,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import Field from "../elements/Field"; import Field from "../elements/Field";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
// TODO: Merge with ProfileSettings? // TODO: Merge with ProfileSettings?
@replaceableComponent("views.room_settings.RoomProfileSettings") @replaceableComponent("views.room_settings.RoomProfileSettings")
@ -38,7 +39,7 @@ export default class RoomProfileSettings extends React.Component {
const avatarEvent = room.currentState.getStateEvents("m.room.avatar", ""); const avatarEvent = room.currentState.getStateEvents("m.room.avatar", "");
let avatarUrl = avatarEvent && avatarEvent.getContent() ? avatarEvent.getContent()["url"] : null; 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 topicEvent = room.currentState.getStateEvents("m.room.topic", "");
const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()['topic'] : ''; const topic = topicEvent && topicEvent.getContent() ? topicEvent.getContent()['topic'] : '';
@ -112,7 +113,7 @@ export default class RoomProfileSettings extends React.Component {
if (this.state.avatarFile) { if (this.state.avatarFile) {
const uri = await client.uploadContent(this.state.avatarFile); const uri = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', {url: uri}, ''); 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.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null; newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {

View file

@ -26,6 +26,7 @@ import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils"; import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.rooms.LinkPreviewWidget") @replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component { export default class LinkPreviewWidget extends React.Component {
@ -83,7 +84,7 @@ export default class LinkPreviewWidget extends React.Component {
let src = p["og:image"]; let src = p["og:image"];
if (src && src.startsWith("mxc://")) { if (src && src.startsWith("mxc://")) {
src = MatrixClientPeg.get().mxcUrlToHttp(src); src = mediaFromMxc(src).srcHttp;
} }
const params = { const params = {
@ -109,9 +110,11 @@ export default class LinkPreviewWidget extends React.Component {
if (!SettingsStore.getValue("showImages")) { if (!SettingsStore.getValue("showImages")) {
image = null; // Don't render a button to show the image, just hide it outright 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://")) { 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; let thumbHeight = imageMaxHeight;

View file

@ -18,10 +18,9 @@ import * as sdk from '../../../index';
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { linkifyElement } from '../../../HtmlUtils'; import { linkifyElement } from '../../../HtmlUtils';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
export function getDisplayAliasForRoom(room) { export function getDisplayAliasForRoom(room) {
return room.canonicalAlias || (room.aliases ? room.aliases[0] : ""); return room.canonicalAlias || (room.aliases ? room.aliases[0] : "");
@ -100,13 +99,14 @@ export default class RoomDetailRow extends React.Component {
{ guestJoin } { guestJoin }
</div>) : <div />; </div>) : <div />;
let avatarUrl = null;
if (room.avatarUrl) avatarUrl = mediaFromMxc(room.avatarUrl).getSquareThumbnailHttp(24);
return <tr key={room.roomId} onClick={this.onClick} onMouseDown={this.props.onMouseDown}> return <tr key={room.roomId} onClick={this.onClick} onMouseDown={this.props.onMouseDown}>
<td className="mx_RoomDirectory_roomAvatar"> <td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={24} height={24} resizeMethod='crop' <BaseAvatar width={24} height={24} resizeMethod='crop'
name={name} idName={name} name={name} idName={name}
url={getHttpUriForMxc( url={avatarUrl} />
MatrixClientPeg.get().getHomeserverUrl(),
room.avatarUrl, 24, 24, "crop")} />
</td> </td>
<td className="mx_RoomDirectory_roomDescription"> <td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp; <div className="mx_RoomDirectory_name">{ name }</div>&nbsp;

View file

@ -16,9 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import Pill from "../elements/Pill"; import Pill from "../elements/Pill";
import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import BaseAvatar from "../avatars/BaseAvatar"; 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 { Room } from "matrix-js-sdk/src/models/room";
import { isUrlPermitted } from '../../../HtmlUtils'; import { isUrlPermitted } from '../../../HtmlUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
interface IProps { interface IProps {
ev: MatrixEvent; ev: MatrixEvent;
@ -114,10 +113,7 @@ export default class BridgeTile extends React.PureComponent<IProps> {
let networkIcon; let networkIcon;
if (protocol.avatar_url) { if (protocol.avatar_url) {
const avatarUrl = getHttpUriForMxc( const avatarUrl = mediaFromMxc(protocol.avatar_url).getSquareThumbnailHttp(64);
MatrixClientPeg.get().getHomeserverUrl(),
protocol.avatar_url, 64, 64, "crop",
);
networkIcon = <BaseAvatar className="protocol-icon" networkIcon = <BaseAvatar className="protocol-icon"
width={48} width={48}

View file

@ -21,6 +21,7 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Spinner from '../elements/Spinner'; import Spinner from '../elements/Spinner';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.settings.ChangeAvatar") @replaceableComponent("views.settings.ChangeAvatar")
export default class ChangeAvatar extends React.Component { export default class ChangeAvatar extends React.Component {
@ -117,7 +118,7 @@ export default class ChangeAvatar extends React.Component {
httpPromise.then(function() { httpPromise.then(function() {
self.setState({ self.setState({
phase: ChangeAvatar.Phases.Display, phase: ChangeAvatar.Phases.Display,
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl), avatarUrl: mediaFromMxc(newUrl).srcHttp,
}); });
}, function(error) { }, function(error) {
self.setState({ self.setState({

View file

@ -24,6 +24,7 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
@replaceableComponent("views.settings.ProfileSettings") @replaceableComponent("views.settings.ProfileSettings")
export default class ProfileSettings extends React.Component { export default class ProfileSettings extends React.Component {
@ -32,7 +33,7 @@ export default class ProfileSettings extends React.Component {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let avatarUrl = OwnProfileStore.instance.avatarMxc; let avatarUrl = OwnProfileStore.instance.avatarMxc;
if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false); if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96);
this.state = { this.state = {
userId: client.getUserId(), userId: client.getUserId(),
originalDisplayName: OwnProfileStore.instance.displayName, originalDisplayName: OwnProfileStore.instance.displayName,
@ -97,7 +98,7 @@ export default class ProfileSettings extends React.Component {
` (${this.state.avatarFile.size}) bytes`); ` (${this.state.avatarFile.size}) bytes`);
const uri = await client.uploadContent(this.state.avatarFile); const uri = await client.uploadContent(this.state.avatarFile);
await client.setAvatarUrl(uri); await client.setAvatarUrl(uri);
newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl; newState.originalAvatarUrl = newState.avatarUrl;
newState.avatarFile = null; newState.avatarFile = null;
} else if (this.state.originalAvatarUrl !== this.state.avatarUrl) { } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {

144
src/customisations/Media.ts Normal file
View file

@ -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<Response>} Resolves to the server's response for chaining.
*/
public downloadSource(): Promise<Response> {
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});
}

View file

@ -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?");
}

View file

@ -22,6 +22,7 @@ import { User } from "matrix-js-sdk/src/models/user";
import { throttle } from "lodash"; import { throttle } from "lodash";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import {mediaFromMxc} from "../customisations/Media";
interface IState { interface IState {
displayName?: string; displayName?: string;
@ -72,8 +73,12 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
*/ */
public getHttpAvatarUrl(size = 0): string { public getHttpAvatarUrl(size = 0): string {
if (!this.avatarMxc) return null; if (!this.avatarMxc) return null;
const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through const media = mediaFromMxc(this.avatarMxc);
return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); if (!size || size <= 0) {
return media.srcHttp;
} else {
return media.getSquareThumbnailHttp(size);
}
} }
protected async onNotReady() { protected async onNotReady() {

View file

@ -1,10 +1,8 @@
function remoteRender(event) { function remoteRender(event) {
const data = event.data; 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.id = "img";
img.src = data.imgSrc;
img.style = data.imgStyle;
const a = document.createElement("a"); const a = document.createElement("a");
a.id = "a"; a.id = "a";
@ -16,6 +14,23 @@ function remoteRender(event) {
a.appendChild(img); a.appendChild(img);
a.appendChild(document.createTextNode(data.textContent)); 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; const body = document.body;
// Don't display scrollbars if the link takes more than one line to display. // Don't display scrollbars if the link takes more than one line to display.
body.style = "margin: 0px; overflow: hidden"; 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) { window.onmessage = function(e) {
if (e.origin === window.location.origin) { if (e.origin === window.location.origin) {
if (e.data.blob) remoteRender(e); if (e.data.blob) remoteRender(e);
else remoteSetTint(e);
} }
}; };

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016, 2018, 2021 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. // Pull in the encryption lib so that we can decrypt attachments.
import encrypt from 'browser-encrypt-attachment'; import encrypt from 'browser-encrypt-attachment';
// Grab the client so that we can turn mxc:// URLs into https:// URLS. import {mediaFromContent} from "../customisations/Media";
import {MatrixClientPeg} from '../MatrixClientPeg'; import {IEncryptedFile} from "../customisations/models/IMediaEventContent";
// WARNING: We have to be very careful about what mime-types we allow into blobs, // 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() // 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: // 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. // text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
const ALLOWED_BLOB_MIMETYPES = { const ALLOWED_BLOB_MIMETYPES = [
'image/jpeg': true, 'image/jpeg',
'image/gif': true, 'image/gif',
'image/png': true, 'image/png',
'video/mp4': true, 'video/mp4',
'video/webm': true, 'video/webm',
'video/ogg': true, 'video/ogg',
'audio/mp4': true, 'audio/mp4',
'audio/webm': true, 'audio/webm',
'audio/aac': true, 'audio/aac',
'audio/mpeg': true, 'audio/mpeg',
'audio/ogg': true, 'audio/ogg',
'audio/wave': true, 'audio/wave',
'audio/wav': true, 'audio/wav',
'audio/x-wav': true, 'audio/x-wav',
'audio/x-pn-wav': true, 'audio/x-pn-wav',
'audio/flac': true, 'audio/flac',
'audio/x-flac': true, 'audio/x-flac',
}; ];
/** /**
* Decrypt a file attached to a matrix event. * 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} * 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 * as the encryption info object, so will also have the those keys in addition to
* the keys below. * the keys below.
* @param {string} file.url An mxc:// URL for the encrypted file. * @returns {Promise<Blob>} Resolves to a Blob of the file.
* @param {string} file.mimetype The MIME-type of the plaintext file.
* @returns {Promise}
*/ */
export function decryptFile(file) { export function decryptFile(file: IEncryptedFile): Promise<Blob> {
const url = MatrixClientPeg.get().mxcUrlToHttp(file.url); const media = mediaFromContent({file});
// Download the encrypted file as an array buffer. // Download the encrypted file as an array buffer.
return Promise.resolve(fetch(url)).then(function(response) { return media.downloadSource().then((response) => {
return response.arrayBuffer(); return response.arrayBuffer();
}).then(function(responseData) { }).then((responseData) => {
// Decrypt the array buffer using the information taken from // Decrypt the array buffer using the information taken from
// the event content. // the event content.
return encrypt.decryptAttachment(responseData, file); return encrypt.decryptAttachment(responseData, file);
}).then(function(dataArray) { }).then((dataArray) => {
// Turn the array into a Blob and give it the correct MIME-type. // Turn the array into a Blob and give it the correct MIME-type.
// IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise // 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.) // browser (e.g. by copying the URI into a new tab or window.)
// See warning at top of file. // See warning at top of file.
let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
mimetype = 'application/octet-stream'; mimetype = 'application/octet-stream';
} }
const blob = new Blob([dataArray], {type: mimetype}); return new Blob([dataArray], {type: mimetype});
return blob;
}); });
} }

View file

@ -262,7 +262,8 @@ describe('GroupView', function() {
expect(longDescElement.innerHTML).toContain('<ul>'); expect(longDescElement.innerHTML).toContain('<ul>');
expect(longDescElement.innerHTML).toContain('<li>And lists!</li>'); expect(longDescElement.innerHTML).toContain('<li>And lists!</li>');
const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl?width=800&amp;height=600"; const imgSrc = "https://my.home.server/_matrix/media/r0/thumbnail/someimageurl" +
"?width=800&amp;height=600&amp;method=scale";
expect(longDescElement.innerHTML).toContain('<img src="' + imgSrc + '">'); expect(longDescElement.innerHTML).toContain('<img src="' + imgSrc + '">');
}); });

View file

@ -116,6 +116,7 @@ describe('MessagePanel', function() {
getAvatarUrl: () => { getAvatarUrl: () => {
return "avatar.jpeg"; return "avatar.jpeg";
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
ts: ts0 + i*1000, ts: ts0 + i*1000,
mship: 'join', mship: 'join',
@ -148,6 +149,7 @@ describe('MessagePanel', function() {
getAvatarUrl: () => { getAvatarUrl: () => {
return "avatar.jpeg"; return "avatar.jpeg";
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
ts: ts0 + i*1000, ts: ts0 + i*1000,
mship: 'join', mship: 'join',
@ -193,6 +195,7 @@ describe('MessagePanel', function() {
getAvatarUrl: () => { getAvatarUrl: () => {
return "avatar.jpeg"; return "avatar.jpeg";
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
ts: ts0 + 1, ts: ts0 + 1,
mship: 'join', mship: 'join',
@ -239,6 +242,7 @@ describe('MessagePanel', function() {
getAvatarUrl: () => { getAvatarUrl: () => {
return "avatar.jpeg"; return "avatar.jpeg";
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
ts: ts0 + 5, ts: ts0 + 5,
mship: 'invite', mship: 'invite',

View file

@ -50,6 +50,7 @@ describe('MemberEventListSummary', function() {
getAvatarUrl: () => { getAvatarUrl: () => {
return "avatar.jpeg"; return "avatar.jpeg";
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
}); });
// Override random event ID to allow for equality tests against tiles from // Override random event ID to allow for equality tests against tiles from

View file

@ -37,6 +37,7 @@ describe("<TextualBody />", () => {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s,
}; };
const ev = mkEvent({ const ev = mkEvent({
@ -61,6 +62,7 @@ describe("<TextualBody />", () => {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s,
}; };
const ev = mkEvent({ const ev = mkEvent({
@ -86,6 +88,7 @@ describe("<TextualBody />", () => {
getRoom: () => mkStubRoom("room_id"), getRoom: () => mkStubRoom("room_id"),
getAccountData: () => undefined, getAccountData: () => undefined,
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s,
}; };
}); });
@ -139,6 +142,7 @@ describe("<TextualBody />", () => {
on: () => undefined, on: () => undefined,
removeListener: () => undefined, removeListener: () => undefined,
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s,
}; };
}); });
@ -284,6 +288,7 @@ describe("<TextualBody />", () => {
getAccountData: () => undefined, getAccountData: () => undefined,
getUrlPreview: (url) => new Promise(() => {}), getUrlPreview: (url) => new Promise(() => {}),
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s,
}; };
const ev = mkEvent({ const ev = mkEvent({

View file

@ -2,3 +2,5 @@ import * as languageHandler from "../src/languageHandler";
languageHandler.setLanguage('en'); languageHandler.setLanguage('en');
languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]); languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]);
require('jest-fetch-mock').enableMocks();

View file

@ -213,6 +213,7 @@ export function mkStubRoom(roomId = null) {
rawDisplayName: 'Member', rawDisplayName: 'Member',
roomId: roomId, roomId: roomId,
getAvatarUrl: () => 'mxc://avatar.url/image.png', getAvatarUrl: () => 'mxc://avatar.url/image.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}), }),
getMembersWithMembership: jest.fn().mockReturnValue([]), getMembersWithMembership: jest.fn().mockReturnValue([]),
getJoinedMembers: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]),
@ -242,6 +243,7 @@ export function mkStubRoom(roomId = null) {
removeListener: jest.fn(), removeListener: jest.fn(),
getDMInviter: jest.fn(), getDMInviter: jest.fn(),
getAvatarUrl: () => 'mxc://avatar.url/room.png', getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
}; };
} }

View file

@ -2589,6 +2589,13 @@ crc-32@^0.3.0:
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e"
integrity sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14= 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: cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5" version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" 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-mock "^26.6.2"
jest-util "^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: jest-get-type@^26.3.0:
version "26.3.0" version "26.3.0"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
@ -5835,6 +5850,11 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== 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: node-fetch@^1.0.1:
version "1.7.3" version "1.7.3"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" 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" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== 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: promise@^7.0.3, promise@^7.1.1:
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"