diff --git a/package.json b/package.json index e0883f5556f..e5ba601e75a 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", + "context-filter-polyfill": "^0.2.4", "counterpart": "^0.18.6", "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", @@ -195,6 +196,7 @@ "decoderWorker\\.min\\.js": "/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "context-filter-polyfill": "/__mocks__/empty.js", "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js", "RecorderWorklet": "/__mocks__/empty.js" }, diff --git a/res/css/_common.scss b/res/css/_common.scss index fa925eba5b0..ae565d2fe81 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -168,7 +168,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { // it has the appearance of a text box so the controls // appear to be part of the input -.mx_Dialog, .mx_MatrixChat { +.mx_Dialog, .mx_MatrixChat_wrapper { .mx_textinput > input[type=text], .mx_textinput > input[type=search] { border: none; diff --git a/res/css/_components.scss b/res/css/_components.scss index 035caec36ac..f6c63630465 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -17,6 +17,7 @@ @import "./structures/_LeftPanelWidget.scss"; @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; +@import "./structures/_BackdropPanel.scss"; @import "./structures/_MyGroups.scss"; @import "./structures/_NonUrgentToastContainer.scss"; @import "./structures/_NotificationPanel.scss"; diff --git a/res/css/structures/_BackdropPanel.scss b/res/css/structures/_BackdropPanel.scss new file mode 100644 index 00000000000..c7ada2b0a54 --- /dev/null +++ b/res/css/structures/_BackdropPanel.scss @@ -0,0 +1,51 @@ +/* +Copyright 2021 New Vector Ltd + +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. +*/ + +.mx_BackdropPanel { + position: absolute; + left: 0; + top: 0; + height: 100vh; + width: 100%; + overflow: hidden; + + &::before { + content: " "; + position: absolute; + left: 0; + top: 0; + height: 100vh; + width: 100%; + background-color: var(--lp-background-overlay); + } +} + +.mx_BackdropPanel--canvas { + position: absolute; + top: 0; + left: 0; + min-height: 100%; + z-index: 0; + pointer-events: none; + overflow: hidden; + + &:nth-of-type(2n-1) { + opacity: 0.2; + } + &:nth-of-type(2n) { + opacity: 0.1; + } +} diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index 444435dd572..c62230edfc6 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -14,10 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_MatrixChat--with-avatar { + .mx_GroupFilterPanel { + background-color: transparent; + } +} + .mx_GroupFilterPanel { - flex: 1; background-color: $groupFilterPanel-bg-color; + flex: 1; cursor: pointer; + position: relative; display: flex; flex-direction: column; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index f254ca32265..db634cd71f2 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -17,15 +17,22 @@ limitations under the License. $groupFilterPanelWidth: 56px; // only applies in this file, used for calculations $roomListCollapsedWidth: 68px; +.mx_MatrixChat--with-avatar { + .mx_LeftPanel, + .mx_LeftPanel .mx_LeftPanel_roomListContainer { + background-color: transparent; + } +} + .mx_LeftPanel { background-color: $roomlist-bg-color; // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel min-width: 206px; - max-width: 50%; // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; contain: content; + position: relative; .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index a220c5d5052..90e28fb0a98 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -29,8 +29,6 @@ limitations under the License. .mx_MatrixChat_wrapper { display: flex; - flex-direction: column; - width: 100%; height: 100%; } @@ -42,15 +40,16 @@ limitations under the License. } .mx_MatrixChat { + position: relative; width: 100%; height: 100%; display: flex; - order: 2; - flex: 1; + flex-grow: 0; min-height: 0; + max-width: 50%; } .mx_MatrixChat_syncError { diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 831f186ed49..84f28b5ada8 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -18,6 +18,8 @@ limitations under the License. word-wrap: break-word; display: flex; flex-direction: column; + flex: 1; + position: relative; } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 1dea6332f58..e271d6bb00c 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -22,11 +22,18 @@ $activeBorderTransparentGap: 1px; $activeBackgroundColor: $roomtile-selected-bg-color; $activeBorderColor: $secondary-fg-color; +.mx_MatrixChat--with-avatar { + .mx_SpacePanel { + background-color: transparent; + } +} + .mx_SpacePanel { - flex: 0 0 auto; background-color: $groupFilterPanel-bg-color; + flex: 0 0 auto; padding: 0; margin: 0; + position: relative; // Create another flexbox so the Panel fills the container display: flex; diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 8c305b9828b..cce7d6ab67d 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -238,9 +238,12 @@ $voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; -// blur amounts for left left panel (only for element theme, used in _mods.scss) -$roomlist-background-blur-amount: 60px; -$groupFilterPanel-background-blur-amount: 30px; +// blur amounts for left left panel (only for element theme) +:root { + --llp-background-blur: 160px; + --lp-background-blur: 90px; + --lp-background-overlay: rgba(255, 255, 255, 0.055); +} $composer-shadow-color: rgba(0, 0, 0, 0.28); diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss index 600cfd528a4..df83d6db88b 100644 --- a/res/themes/dark/css/dark.scss +++ b/res/themes/dark/css/dark.scss @@ -2,10 +2,6 @@ @import "../../light/css/_paths.scss"; @import "../../light/css/_fonts.scss"; @import "../../light/css/_light.scss"; -// important this goes before _mods, -// as $groupFilterPanel-background-blur-amount and -// $roomlist-background-blur-amount -// are overridden in _dark.scss @import "_dark.scss"; @import "../../light/css/_mods.scss"; @import "../../../../res/css/_components.scss"; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index e64fe12d3b9..982ca7cf08e 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -361,10 +361,12 @@ $voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; -// blur amounts for left left panel (only for element theme, used in _mods.scss) -$roomlist-background-blur-amount: 40px; -$groupFilterPanel-background-blur-amount: 20px; - +// blur amounts for left left panel (only for element theme) +:root { + --llp-background-blur: 120px; + --lp-background-blur: 60px; + --lp-background-overlay: rgba(0, 0, 0, 0.055); +} $composer-shadow-color: rgba(0, 0, 0, 0.04); // Bubble tiles diff --git a/res/themes/light/css/_mods.scss b/res/themes/light/css/_mods.scss index fbca58dfb1e..15f6d4b0fe6 100644 --- a/res/themes/light/css/_mods.scss +++ b/res/themes/light/css/_mods.scss @@ -4,27 +4,6 @@ // set the user avatar (if any) as a background so // it can be blurred by the tag panel and room list -@supports (backdrop-filter: none) { - .mx_LeftPanel { - background-image: var(--avatar-url, unset); - background-repeat: no-repeat; - background-size: cover; - background-position: left top; - } - - .mx_GroupFilterPanel { - backdrop-filter: blur($groupFilterPanel-background-blur-amount); - } - - .mx_SpacePanel { - backdrop-filter: blur($groupFilterPanel-background-blur-amount); - } - - .mx_LeftPanel .mx_LeftPanel_roomListContainer { - backdrop-filter: blur($roomlist-background-blur-amount); - } -} - .mx_RoomSublist_showNButton { background-color: transparent !important; } diff --git a/src/components/structures/BackdropPanel.tsx b/src/components/structures/BackdropPanel.tsx new file mode 100644 index 00000000000..2dd4b18fb9e --- /dev/null +++ b/src/components/structures/BackdropPanel.tsx @@ -0,0 +1,165 @@ +/* +Copyright 2021 New Vector Ltd + +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 React, { createRef } from "react"; +import "context-filter-polyfill"; + +import UIStore from "../../stores/UIStore"; + +interface IProps { + backgroundImage?: CanvasImageSource; +} + +interface IState { + // Left Panel image + lpImage?: string; + // Left-left panel image + llpImage?: string; +} + +export default class BackdropPanel extends React.PureComponent { + private leftLeftPanelRef = createRef(); + private leftPanelRef = createRef(); + + private sizes = { + leftLeftPanelWidth: 0, + leftPanelWidth: 0, + height: 0, + }; + private style = getComputedStyle(document.documentElement); + + public state: IState = {}; + + public componentDidMount() { + UIStore.instance.on("SpacePanel", this.onResize); + UIStore.instance.on("GroupFilterPanelContainer", this.onResize); + this.onResize(); + } + + public componentWillUnmount() { + UIStore.instance.off("SpacePanel", this.onResize); + UIStore.instance.on("GroupFilterPanelContainer", this.onResize); + } + + public componentDidUpdate(prevProps: IProps) { + if (prevProps.backgroundImage !== this.props.backgroundImage) { + this.setState({}); + this.onResize(); + } + } + + private onResize = () => { + if (this.props.backgroundImage) { + const groupFilterPanelDimensions = UIStore.instance.getElementDimensions("GroupFilterPanelContainer"); + const spacePanelDimensions = UIStore.instance.getElementDimensions("SpacePanel"); + const roomListDimensions = UIStore.instance.getElementDimensions("LeftPanel"); + this.sizes = { + leftLeftPanelWidth: spacePanelDimensions?.width ?? groupFilterPanelDimensions?.width ?? 0, + leftPanelWidth: roomListDimensions?.width ?? 0, + height: UIStore.instance.windowHeight, + }; + this.refreshBackdropImage(); + } + }; + + private refreshBackdropImage = (): void => { + const leftLeftPanelContext = this.leftLeftPanelRef.current.getContext("2d"); + const leftPanelContext = this.leftPanelRef.current.getContext("2d"); + const { leftLeftPanelWidth, leftPanelWidth, height } = this.sizes; + const width = leftLeftPanelWidth + leftPanelWidth; + const { backgroundImage } = this.props; + + const imageWidth = (backgroundImage as ImageBitmap).width; + const imageHeight = (backgroundImage as ImageBitmap).height; + + const contentRatio = imageWidth / imageHeight; + const containerRatio = width / height; + let resultHeight; + let resultWidth; + if (contentRatio > containerRatio) { + resultHeight = height; + resultWidth = height * contentRatio; + } else { + resultWidth = width; + resultHeight = width / contentRatio; + } + + // This value has been chosen to be as close with rendering as the css-only + // backdrop-filter: blur effect was, mostly takes effect for vertical pictures. + const x = width * 0.1; + const y = (height - resultHeight) / 2; + + this.leftLeftPanelRef.current.width = leftLeftPanelWidth; + this.leftLeftPanelRef.current.height = height; + this.leftPanelRef.current.width = (window.screen.width * 0.5); + this.leftPanelRef.current.height = height; + + const spacesBlur = this.style.getPropertyValue('--llp-background-blur'); + const roomListBlur = this.style.getPropertyValue('--lp-background-blur'); + + leftLeftPanelContext.filter = `blur(${spacesBlur})`; + leftPanelContext.filter = `blur(${roomListBlur})`; + leftLeftPanelContext.drawImage( + backgroundImage, + 0, 0, + imageWidth, imageHeight, + x, + y, + resultWidth, + resultHeight, + ); + leftPanelContext.drawImage( + backgroundImage, + 0, 0, + imageWidth, imageHeight, + x - leftLeftPanelWidth, + y, + resultWidth, + resultHeight, + ); + this.setState({ + lpImage: this.leftPanelRef.current.toDataURL('image/jpeg', 1), + llpImage: this.leftLeftPanelRef.current.toDataURL('image/jpeg', 1), + + }); + }; + + public render() { + if (!this.props.backgroundImage) return null; + return
+ + + + +
; + } +} diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.tsx similarity index 71% rename from src/components/structures/GroupFilterPanel.js rename to src/components/structures/GroupFilterPanel.tsx index 5d1be64f25c..3e7c6e9b17f 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.tsx @@ -15,6 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { EventSubscription } from "fbemitter"; import React from 'react'; import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore'; @@ -30,22 +31,43 @@ import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; import { replaceableComponent } from "../../utils/replaceableComponent"; +import UIStore from "../../stores/UIStore"; + +interface IGroupFilterPanelProps { + +} + +// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript +type OrderedTagsTemporaryType = Array<{}>; +// FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript +type SelectedTagsTemporaryType = Array<{}>; + +interface IGroupFilterPanelState { + // FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript + orderedTags: OrderedTagsTemporaryType; + // FIXME: Properly type this after migrating GroupFilterOrderStore.js to Typescript + selectedTags: SelectedTagsTemporaryType; +} @replaceableComponent("structures.GroupFilterPanel") -class GroupFilterPanel extends React.Component { - static contextType = MatrixClientContext; +class GroupFilterPanel extends React.Component { + public static contextType = MatrixClientContext; - state = { + public state = { orderedTags: [], selectedTags: [], }; - componentDidMount() { + private ref = React.createRef(); + private unmounted = false; + private groupFilterOrderStoreToken?: EventSubscription; + + public componentDidMount() { this.unmounted = false; - this.context.on("Group.myMembership", this._onGroupMyMembership); - this.context.on("sync", this._onClientSync); + this.context.on("Group.myMembership", this.onGroupMyMembership); + this.context.on("sync", this.onClientSync); - this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => { + this.groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => { if (this.unmounted) { return; } @@ -56,23 +78,25 @@ class GroupFilterPanel extends React.Component { }); // This could be done by anything with a matrix client dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); + UIStore.instance.trackElementDimensions("GroupPanel", this.ref.current); } - componentWillUnmount() { + public componentWillUnmount() { this.unmounted = true; - this.context.removeListener("Group.myMembership", this._onGroupMyMembership); - this.context.removeListener("sync", this._onClientSync); - if (this._groupFilterOrderStoreToken) { - this._groupFilterOrderStoreToken.remove(); + this.context.removeListener("Group.myMembership", this.onGroupMyMembership); + this.context.removeListener("sync", this.onClientSync); + if (this.groupFilterOrderStoreToken) { + this.groupFilterOrderStoreToken.remove(); } + UIStore.instance.stopTrackingElementDimensions("GroupPanel"); } - _onGroupMyMembership = () => { + private onGroupMyMembership = () => { if (this.unmounted) return; dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); }; - _onClientSync = (syncState, prevState) => { + private onClientSync = (syncState, prevState) => { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. const reconnected = syncState !== "ERROR" && prevState !== syncState; @@ -82,18 +106,18 @@ class GroupFilterPanel extends React.Component { } }; - onClick = e => { + private onClick = e => { // only dispatch if its not a no-op if (this.state.selectedTags.length > 0) { dis.dispatch({ action: 'deselect_tags' }); } }; - onClearFilterClick = ev => { + private onClearFilterClick = ev => { dis.dispatch({ action: 'deselect_tags' }); }; - renderGlobalIcon() { + private renderGlobalIcon() { if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null; return ( @@ -104,7 +128,7 @@ class GroupFilterPanel extends React.Component { ); } - render() { + public render() { const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const ActionButton = sdk.getComponent('elements.ActionButton'); @@ -147,7 +171,7 @@ class GroupFilterPanel extends React.Component { ); } - return
+ return
{ private ref: React.RefObject = createRef(); private listContainerRef: React.RefObject = createRef(); private groupFilterPanelWatcherRef: string; + private groupFilterPanelContainer = createRef(); private bgImageWatcherRef: string; private focusedElement = null; private isDoingStickyHeaders = false; @@ -86,17 +85,19 @@ export default class LeftPanel extends React.Component { BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); - OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.updateActiveSpace); - this.bgImageWatcherRef = SettingsStore.watchSetting( - "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { this.setState({ showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel") }); }); } public componentDidMount() { + UIStore.instance.trackElementDimensions("LeftPanel", this.ref.current); UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current); + if (this.groupFilterPanelContainer.current) { + const componentName = "GroupFilterPanelContainer"; + UIStore.instance.trackElementDimensions(componentName, this.groupFilterPanelContainer.current); + } UIStore.instance.on("ListContainer", this.refreshStickyHeaders); // Using the passive option to not block the main thread // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners @@ -105,10 +106,8 @@ export default class LeftPanel extends React.Component { public componentWillUnmount() { SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef); - SettingsStore.unwatchSetting(this.bgImageWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); - OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); UIStore.instance.stopTrackingElementDimensions("ListContainer"); UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders); @@ -149,23 +148,6 @@ export default class LeftPanel extends React.Component { } }; - private onBackgroundImageUpdate = () => { - // Note: we do this in the LeftPanel as it uses this variable most prominently. - const avatarSize = 32; // arbitrary - let avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); - const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); - if (settingBgMxc) { - avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); - } - - const avatarUrlProp = `url(${avatarUrl})`; - if (!avatarUrl) { - document.body.style.removeProperty("--avatar-url"); - } else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { - document.body.style.setProperty("--avatar-url", avatarUrlProp); - } - }; - private handleStickyHeaders(list: HTMLDivElement) { if (this.isDoingStickyHeaders) return; this.isDoingStickyHeaders = true; @@ -443,7 +425,7 @@ export default class LeftPanel extends React.Component { let leftLeftPanel; if (this.state.showGroupFilterPanel) { leftLeftPanel = ( -
+
{ SettingsStore.getValue("feature_custom_tags") ? : null }
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 4a8b77abec2..44c65c73ff6 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -58,12 +58,16 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import CallHandler from '../../CallHandler'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall'; +import { OwnProfileStore } from '../../stores/OwnProfileStore'; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; import RoomView from './RoomView'; import ToastContainer from './ToastContainer'; import MyGroups from "./MyGroups"; import UserView from "./UserView"; import GroupView from "./GroupView"; +import BackdropPanel from "./BackdropPanel"; import SpaceStore from "../../stores/SpaceStore"; +import classNames from 'classnames'; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -127,6 +131,7 @@ interface IState { usageLimitEventTs?: number; useCompactLayout: boolean; activeCalls: Array; + backgroundImage?: CanvasImageSource; } /** @@ -193,7 +198,10 @@ class LoggedInView extends React.Component { this.resizer = this.createResizer(); this.resizer.attach(); + + OwnProfileStore.instance.on(UPDATE_EVENT, this.refreshBackgroundImage); this.loadResizerPreferences(); + this.refreshBackgroundImage(); } componentWillUnmount() { @@ -202,10 +210,17 @@ class LoggedInView extends React.Component { this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); + OwnProfileStore.instance.off(UPDATE_EVENT, this.refreshBackgroundImage); SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); this.resizer.detach(); } + private refreshBackgroundImage = async (): Promise => { + this.setState({ + backgroundImage: await OwnProfileStore.instance.getAvatarBitmap(), + }); + }; + private onAction = (payload): void => { switch (payload.action) { case 'call_state': { @@ -608,10 +623,11 @@ class LoggedInView extends React.Component { break; } - let bodyClasses = 'mx_MatrixChat'; - if (this.state.useCompactLayout) { - bodyClasses += ' mx_MatrixChat_useCompactLayout'; - } + const bodyClasses = classNames({ + 'mx_MatrixChat': true, + 'mx_MatrixChat_useCompactLayout': this.state.useCompactLayout, + 'mx_MatrixChat--with-avatar': this.state.backgroundImage, + }); const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { return ( @@ -629,14 +645,17 @@ class LoggedInView extends React.Component { >
+ { SpaceStore.spacesEnabled ? : null } - { pageElement }
+ { pageElement }
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 40016af36f2..d2e09c0d692 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -14,7 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; +import React, { + ComponentProps, + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -43,6 +52,7 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; +import UIStore from "../../../stores/UIStore"; const useSpaces = (): [Room[], Room[], Room | null] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -206,6 +216,11 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo const SpacePanel = () => { const [isPanelCollapsed, setPanelCollapsed] = useState(true); + const ref = useRef(); + useLayoutEffect(() => { + UIStore.instance.trackElementDimensions("SpacePanel", ref.current); + return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel"); + }, []); const onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; @@ -280,6 +295,7 @@ const SpacePanel = () => { onKeyDown={onKeyDownHandler} role="tree" aria-label={_t("Spaces")} + ref={ref} > { (provided, snapshot) => ( diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index fb2dd527cbe..9591240f177 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -19,10 +19,12 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { User } from "matrix-js-sdk/src/models/user"; -import { throttle } from "lodash"; +import { memoize, throttle } from "lodash"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { _t } from "../languageHandler"; import { mediaFromMxc } from "../customisations/Media"; +import SettingsStore from "../settings/SettingsStore"; +import { getDrawable } from "../utils/drawable"; interface IState { displayName?: string; @@ -137,6 +139,22 @@ export class OwnProfileStore extends AsyncStoreWithClient { await this.updateState({ displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url }); }; + public async getAvatarBitmap(avatarSize = 32): Promise { + let avatarUrl = this.getHttpAvatarUrl(avatarSize); + const settingBgMxc = SettingsStore.getValue("RoomList.backgroundImage"); + if (settingBgMxc) { + avatarUrl = mediaFromMxc(settingBgMxc).getSquareThumbnailHttp(avatarSize); + } + + if (avatarUrl) { + return await this.buildBitmap(avatarUrl); + } else { + return null; + } + } + + private buildBitmap = memoize(getDrawable); + private onStateEvents = throttle(async (ev: MatrixEvent) => { const myUserId = MatrixClientPeg.get().getUserId(); if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { diff --git a/src/utils/drawable.ts b/src/utils/drawable.ts new file mode 100644 index 00000000000..31f7bc8cec7 --- /dev/null +++ b/src/utils/drawable.ts @@ -0,0 +1,36 @@ +/* +Copyright 2021 New Vector Ltd + +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. +*/ + +/** + * Fetch an image using the best available method based on browser compatibility + * @param url the URL of the image to fetch + * @returns a canvas drawable object + */ +export async function getDrawable(url: string): Promise { + if ('createImageBitmap' in window) { + const response = await fetch(url); + const blob = await response.blob(); + return await createImageBitmap(blob); + } else { + return new Promise((resolve, reject) => { + const img = document.createElement("img"); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = (e) => reject(e); + img.src = url; + }); + } +} diff --git a/yarn.lock b/yarn.lock index 256fee5277b..03e0ba49bdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2917,6 +2917,11 @@ content-type@^1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +context-filter-polyfill@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/context-filter-polyfill/-/context-filter-polyfill-0.2.4.tgz#ecf88d3197e7c3a47e9a7ae2d5167b703945a5d4" + integrity sha512-LDZ3WiTzo6kIeJM7j8kPSgZf+gbD1cV1GaLyYO8RWvAg25cO3zUo3d2KizO0w9hAezNwz7tTbuWKpPdvLWzKqQ== + convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"