diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 5cdefa0324..dd28a3107c 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -65,6 +65,10 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations .mx_LeftPanel2_userAvatarContainer { position: relative; // to make default avatars work margin-right: 8px; + + .mx_LeftPanel2_userAvatar { + border-radius: 32px; // should match avatar size + } } .mx_LeftPanel2_userName { @@ -72,6 +76,11 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations font-size: $font-15px; line-height: $font-20px; flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; } .mx_LeftPanel2_headerButtons { diff --git a/res/css/structures/_UserMenuButton.scss b/res/css/structures/_UserMenuButton.scss index 1fbbbb5fd8..3871fc22ef 100644 --- a/res/css/structures/_UserMenuButton.scss +++ b/res/css/structures/_UserMenuButton.scss @@ -35,9 +35,6 @@ limitations under the License. // Create another flexbox of columns to handle large user IDs display: flex; flex-direction: column; - - // fit the container - flex: 1; width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button * { diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 378a24a70e..ec846bd177 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import * as React from "react"; +import { createRef } from "react"; import TagPanel from "./TagPanel"; import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; @@ -30,7 +31,9 @@ import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import ResizeNotifier from "../../utils/ResizeNotifier"; -import { createRef } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { throttle } from 'lodash'; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; /******************************************************************* * CAUTION * @@ -73,13 +76,32 @@ export default class LeftPanel2 extends React.Component { // We watch the middle panel because we don't actually get resized, the middle panel does. // We listen to the noisy channel to avoid choppy reaction times. this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); + + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); } public componentWillUnmount() { BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); + OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); } + // TSLint wants this to be a member, but we don't want that. + // tslint:disable-next-line + private onRoomStateUpdate = throttle((ev: MatrixEvent) => { + const myUserId = MatrixClientPeg.get().getUserId(); + if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { + // noinspection JSIgnoredPromiseFromCall + this.onProfileUpdate(); + } + }, 200, {trailing: true, leading: true}); + + private onProfileUpdate = async () => { + // the store triggered an update, so force a layout update. We don't + // have any state to store here for that to magically happen. + this.forceUpdate(); + }; + private onSearch = (term: string): void => { this.setState({searchFilter: term}); }; @@ -149,16 +171,7 @@ export default class LeftPanel2 extends React.Component { // TODO: Presence // TODO: Breadcrumbs toggle // TODO: Menu button - const avatarSize = 32; - // TODO: Don't do this profile lookup in render() - const client = MatrixClientPeg.get(); - let displayName = client.getUserId(); - let avatarUrl: string = null; - const myUser = client.getUser(client.getUserId()); - if (myUser) { - displayName = myUser.rawDisplayName; - avatarUrl = myUser.avatarUrl; - } + const avatarSize = 32; // should match border-radius of the avatar let breadcrumbs; if (this.state.showBreadcrumbs) { @@ -169,7 +182,7 @@ export default class LeftPanel2 extends React.Component { ); } - let name = {displayName}; + let name = {OwnProfileStore.instance.displayName}; let buttons = ( @@ -186,8 +199,8 @@ export default class LeftPanel2 extends React.Component { { this.state = { menuDisplayed: false, - user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()), isDarkTheme: this.isUserOnDarkTheme(), }; - } - private get displayName(): string { - if (MatrixClientPeg.get().isGuest()) { - return _t("Guest"); - } else if (this.state.user) { - return this.state.user.displayName; - } else { - return MatrixClientPeg.get().getUserId(); - } + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); } private get hasHomePage(): boolean { @@ -81,6 +72,7 @@ export default class UserMenuButton extends React.Component { public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); + OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); } private isUserOnDarkTheme(): boolean { @@ -91,6 +83,12 @@ export default class UserMenuButton extends React.Component { return theme === "dark"; } + private onProfileUpdate = async () => { + // the store triggered an update, so force a layout update. We don't + // have any state to store here for that to magically happen. + this.forceUpdate(); + }; + private onThemeChanged = () => { this.setState({isDarkTheme: this.isUserOnDarkTheme()}); }; @@ -209,7 +207,7 @@ export default class UserMenuButton extends React.Component {
- {this.displayName} + {OwnProfileStore.instance.displayName} {MatrixClientPeg.get().getUserId()} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 197fa109e8..74e747726a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -422,6 +422,7 @@ "Upgrade your Riot": "Upgrade your Riot", "A new version of Riot is available!": "A new version of Riot is available!", "You: %(message)s": "You: %(message)s", + "Guest": "Guest", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", @@ -2059,7 +2060,6 @@ "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", - "Guest": "Guest", "Your profile": "Your profile", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts new file mode 100644 index 0000000000..45d8829e30 --- /dev/null +++ b/src/stores/OwnProfileStore.ts @@ -0,0 +1,122 @@ +/* +Copyright 2020 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 { ActionPayload } from "../dispatcher/payloads"; +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 { MatrixClientPeg } from "../MatrixClientPeg"; +import { _t } from "../languageHandler"; + +interface IState { + displayName?: string; + avatarUrl?: string; +} + +export class OwnProfileStore extends AsyncStoreWithClient { + private static internalInstance = new OwnProfileStore(); + + private monitoredUser: User; + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): OwnProfileStore { + return OwnProfileStore.internalInstance; + } + + /** + * Gets the display name for the user, or null if not present. + */ + public get displayName(): string { + if (!this.matrixClient) return this.state.displayName || null; + + if (this.matrixClient.isGuest()) { + return _t("Guest"); + } else if (this.state.displayName) { + return this.state.displayName; + } else { + return this.matrixClient.getUserId(); + } + } + + /** + * Gets the MXC URI of the user's avatar, or null if not present. + */ + public get avatarMxc(): string { + return this.state.avatarUrl || null; + } + + /** + * Gets the user's avatar as an HTTP URL of the given size. If the user's + * avatar is not present, this returns null. + * @param size The size of the avatar + * @returns The HTTP URL of the user's avatar + */ + public getHttpAvatarUrl(size: number): string { + if (!this.avatarMxc) return null; + return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size); + } + + protected async onNotReady() { + if (this.monitoredUser) { + this.monitoredUser.removeListener("User.displayName", this.onProfileUpdate); + this.monitoredUser.removeListener("User.avatarUrl", this.onProfileUpdate); + } + if (this.matrixClient) { + this.matrixClient.removeListener("RoomState.events", this.onStateEvents); + } + await this.reset({}); + } + + protected async onReady() { + const myUserId = this.matrixClient.getUserId(); + this.monitoredUser = this.matrixClient.getUser(myUserId); + if (this.monitoredUser) { + this.monitoredUser.on("User.displayName", this.onProfileUpdate); + this.monitoredUser.on("User.avatarUrl", this.onProfileUpdate); + } + + // We also have to listen for membership events for ourselves as the above User events + // are fired only with presence, which matrix.org (and many others) has disabled. + this.matrixClient.on("RoomState.events", this.onStateEvents); + + await this.onProfileUpdate(); // trigger an initial update + } + + protected async onAction(payload: ActionPayload) { + // we don't actually do anything here + } + + private onProfileUpdate = async () => { + // We specifically do not use the User object we stored for profile info as it + // could easily be wrong (such as per-room instead of global profile). + const profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getUserId()); + await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url}); + }; + + // TSLint wants this to be a member, but we don't want that. + // tslint:disable-next-line + private onStateEvents = throttle(async (ev: MatrixEvent) => { + const myUserId = MatrixClientPeg.get().getUserId(); + if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { + await this.onProfileUpdate(); + } + }, 200, {trailing: true, leading: true}); +}