Merge pull request #6079 from matrix-org/gsouquet/switch-rooms

This commit is contained in:
Germain 2021-06-03 08:44:01 +01:00 committed by GitHub
commit 7f83590846
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 184 additions and 171 deletions

View file

@ -239,6 +239,7 @@ hr.mx_RoomView_myReadMarker {
position: relative; position: relative;
top: -1px; top: -1px;
z-index: 1; z-index: 1;
will-change: width;
transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s;
width: 99%; width: 99%;
opacity: 1; opacity: 1;

View file

@ -115,8 +115,7 @@ $irc-line-height: $font-18px;
.mx_EventTile_line { .mx_EventTile_line {
.mx_EventTile_e2eIcon, .mx_EventTile_e2eIcon,
.mx_TextualEvent, .mx_TextualEvent,
.mx_MTextBody, .mx_MTextBody {
.mx_ReplyThread_wrapper_empty {
display: inline-block; display: inline-block;
} }
} }
@ -177,8 +176,6 @@ $irc-line-height: $font-18px;
.mx_SenderProfile_hover { .mx_SenderProfile_hover {
background-color: $primary-bg-color; background-color: $primary-bg-color;
overflow: hidden; overflow: hidden;
> span {
display: flex; display: flex;
> .mx_SenderProfile_name { > .mx_SenderProfile_name {
@ -188,7 +185,6 @@ $irc-line-height: $font-18px;
text-align: end; text-align: end;
} }
} }
}
.mx_SenderProfile:hover { .mx_SenderProfile:hover {
justify-content: flex-start; justify-content: flex-start;

View file

@ -32,14 +32,14 @@ limitations under the License.
// first triggering the enter state with the newest breadcrumb off screen (-40px) then // first triggering the enter state with the newest breadcrumb off screen (-40px) then
// sliding it into view. // sliding it into view.
&.mx_RoomBreadcrumbs-enter { &.mx_RoomBreadcrumbs-enter {
margin-left: -40px; // 32px for the avatar, 8px for the margin transform: translateX(-40px); // 32px for the avatar, 8px for the margin
} }
&.mx_RoomBreadcrumbs-enter-active { &.mx_RoomBreadcrumbs-enter-active {
margin-left: 0; transform: translateX(0);
// Timing function is as-requested by design. // Timing function is as-requested by design.
// NOTE: The transition time MUST match the value passed to CSSTransition! // NOTE: The transition time MUST match the value passed to CSSTransition!
transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1); transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
} }
.mx_RoomBreadcrumbs_placeholder { .mx_RoomBreadcrumbs_placeholder {

View file

@ -648,13 +648,12 @@ export default class MessagePanel extends React.Component {
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
<li <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
key={mxEv.getTxnId() || eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<TileErrorBoundary mxEvent={mxEv}>
<EventTile <EventTile
as="li"
data-scroll-tokens={scrollToken}
ref={this._collectEventNode.bind(this, eventId)}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
mxEvent={mxEv} mxEvent={mxEv}
continuation={continuation} continuation={continuation}
isRedacted={mxEv.isRedacted()} isRedacted={mxEv.isRedacted()}
@ -679,8 +678,7 @@ export default class MessagePanel extends React.Component {
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
showReadReceipts={this.props.showReadReceipts} showReadReceipts={this.props.showReadReceipts}
/> />
</TileErrorBoundary> </TileErrorBoundary>,
</li>,
); );
return ret; return ret;
@ -782,7 +780,7 @@ export default class MessagePanel extends React.Component {
} }
_collectEventNode = (eventId, node) => { _collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node; this.eventNodes[eventId] = node?.ref?.current;
} }
// once dynamic content in the events load, make the scrollPanel check the // once dynamic content in the events load, make the scrollPanel check the

View file

@ -83,6 +83,7 @@ import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView"; import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import { omit } from 'lodash';
import UIStore from "../../stores/UIStore"; import UIStore from "../../stores/UIStore";
const DEBUG = false; const DEBUG = false;
@ -176,6 +177,7 @@ export interface IState {
statusBarVisible: boolean; statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us. // We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion() // This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: { upgradeRecommendation?: {
version: string; version: string;
needsUpgrade: boolean; needsUpgrade: boolean;
@ -529,7 +531,20 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); const hasPropsDiff = objectHasDiff(this.props, nextProps);
// React only shallow comparison and we only want to trigger
// a component re-render if a room requires an upgrade
const newUpgradeRecommendation = nextState.upgradeRecommendation || {}
const state = omit(this.state, ['upgradeRecommendation']);
const newState = omit(nextState, ['upgradeRecommendation'])
const hasStateDiff =
objectHasDiff(state, newState) ||
(newUpgradeRecommendation.needsUpgrade === true)
return hasPropsDiff || hasStateDiff;
} }
componentDidUpdate() { componentDidUpdate() {
@ -823,7 +838,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private onEvent = (ev) => { private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev); this.handleEffects(ev);
}; };

View file

@ -36,7 +36,6 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile"; import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature"; import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays"; import { arrayFastClone } from "../../utils/arrays";
@ -270,30 +269,6 @@ class TimelinePanel extends React.Component {
} }
} }
shouldComponentUpdate(nextProps, nextState) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
}
componentWillUnmount() { componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending // set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results. // promises can use to throw away their results.

View file

@ -55,6 +55,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const totalCount = this.state.toasts.length; const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1; const isStacked = totalCount > 1;
let toast; let toast;
let containerClasses;
if (totalCount !== 0) { if (totalCount !== 0) {
const topToast = this.state.toasts[0]; const topToast = this.state.toasts[0];
const {title, icon, key, component, className, props} = topToast; const {title, icon, key, component, className, props} = topToast;
@ -79,16 +80,17 @@ export default class ToastContainer extends React.Component<{}, IState> {
</div> </div>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div> <div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>); </div>);
}
const containerClasses = classNames("mx_ToastContainer", { containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked, "mx_ToastContainer_stacked": isStacked,
}); });
}
return ( return toast
? (
<div className={containerClasses} role="alert"> <div className={containerClasses} role="alert">
{toast} {toast}
</div> </div>
); )
: null;
} }
} }

View file

@ -73,7 +73,7 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)} tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title} label={tooltip || title}
yOffset={yOffset} yOffset={yOffset}
/> : <div />; /> : null;
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}

View file

@ -116,7 +116,7 @@ export default class Flair extends React.Component {
render() { render() {
if (this.state.profiles.length === 0) { if (this.state.profiles.length === 0) {
return <span className="mx_Flair" />; return null;
} }
const avatars = this.state.profiles.map((profile, index) => { const avatars = this.state.profiles.map((profile, index) => {
return <FlairAvatar key={index} groupProfile={profile} />; return <FlairAvatar key={index} groupProfile={profile} />;

View file

@ -214,7 +214,7 @@ export default class ReplyThread extends React.Component {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) { static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
if (!ReplyThread.getParentEventId(parentEv)) { if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />; return null;
} }
return <ReplyThread return <ReplyThread
parentEv={parentEv} parentEv={parentEv}
@ -269,39 +269,30 @@ export default class ReplyThread extends React.Component {
const {parentEv} = this.props; const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return; if (this.unmounted) return;
if (ev) { if (ev) {
const loadedEv = await this.getNextEvent(ev);
this.setState({ this.setState({
events: [ev], events: [ev],
}, this.loadNextEvent); loadedEv,
} else {
this.setState({err: true});
}
}
async loadNextEvent() {
if (this.unmounted) return;
const ev = this.state.events[0];
const inReplyToEventId = ReplyThread.getParentEventId(ev);
if (!inReplyToEventId) {
this.setState({
loading: false, loading: false,
}); });
return;
}
const loadedEv = await this.getEvent(inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else { } else {
this.setState({err: true}); this.setState({err: true});
} }
} }
async getNextEvent(ev) {
try {
const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId);
} catch (e) {
return null;
}
}
async getEvent(eventId) { async getEvent(eventId) {
const event = this.room.findEventById(eventId); const event = this.room.findEventById(eventId);
if (event) return event; if (event) return event;
@ -326,13 +317,18 @@ export default class ReplyThread extends React.Component {
this.initialize(); this.initialize();
} }
onQuoteClick() { async onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
if (events.length > 0) {
loadedEv = await this.getNextEvent(events[0]);
}
this.setState({ this.setState({
loadedEv: null, loadedEv,
events, events,
}, this.loadNextEvent); });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }

View file

@ -31,21 +31,23 @@ export default class SenderProfile extends React.Component {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
state = { constructor(props) {
userGroups: null, super(props);
const senderId = this.props.mxEvent.getSender();
this.state = {
userGroups: FlairStore.cachedPublicisedGroups(senderId) || [],
relatedGroups: [], relatedGroups: [],
}; };
}
componentDidMount() { componentDidMount() {
this.unmounted = false; this.unmounted = false;
this._updateRelatedGroups(); this._updateRelatedGroups();
FlairStore.getPublicisedGroupsCached( if (this.state.userGroups.length === 0) {
this.context, this.props.mxEvent.getSender(), this.getPublicisedGroups();
).then((userGroups) => { }
if (this.unmounted) return;
this.setState({userGroups});
});
this.context.on('RoomState.events', this.onRoomStateEvents); this.context.on('RoomState.events', this.onRoomStateEvents);
} }
@ -55,6 +57,15 @@ export default class SenderProfile extends React.Component {
this.context.removeListener('RoomState.events', this.onRoomStateEvents); this.context.removeListener('RoomState.events', this.onRoomStateEvents);
} }
async getPublicisedGroups() {
if (!this.unmounted) {
const userGroups = await FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
);
this.setState({userGroups});
}
}
onRoomStateEvents = event => { onRoomStateEvents = event => {
if (event.getType() === 'm.room.related_groups' && if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId() event.getRoomId() === this.props.mxEvent.getRoomId()
@ -93,10 +104,10 @@ export default class SenderProfile extends React.Component {
const {msgtype} = mxEvent.getContent(); const {msgtype} = mxEvent.getContent();
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return <span />; // emote message must include the name so don't duplicate it return null; // emote message must include the name so don't duplicate it
} }
let flair = <div />; let flair = null;
if (this.props.enableFlair) { if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups( const displayedGroups = this._getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups, this.state.userGroups, this.state.relatedGroups,
@ -110,19 +121,12 @@ export default class SenderProfile extends React.Component {
const nameElem = name || ''; const nameElem = name || '';
// Name + flair return (
const nameFlair = <span> <div className="mx_SenderProfile mx_SenderProfile_hover" dir="auto" onClick={this.props.onClick}>
<span className={`mx_SenderProfile_name ${colorClass}`}> <span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem } { nameElem }
</span> </span>
{ flair } { flair }
</span>;
return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover">
{ nameFlair }
</div>
</div> </div>
); );
} }

View file

@ -277,6 +277,12 @@ interface IProps {
// Helper to build permalinks for the room // Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
// Symbol of the root node
as?: string
// whether or not to always show timestamps
alwaysShowTimestamps?: boolean
} }
interface IState { interface IState {
@ -291,12 +297,15 @@ interface IState {
previouslyRequestedKeys: boolean; previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations; reactions: Relations;
hover: boolean;
} }
@replaceableComponent("views.rooms.EventTile") @replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component<IProps, IState> { export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
private ref: React.RefObject<unknown>;
private tile = React.createRef(); private tile = React.createRef();
private replyThread = React.createRef(); private replyThread = React.createRef();
@ -322,6 +331,8 @@ export default class EventTile extends React.Component<IProps, IState> {
previouslyRequestedKeys: false, previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(), reactions: this.getReactions(),
hover: false,
}; };
// don't do RR animations until we are mounted // don't do RR animations until we are mounted
@ -333,6 +344,8 @@ export default class EventTile extends React.Component<IProps, IState> {
// to determine if we've already subscribed and use a combination of other flags to find // to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all. // out if we should even be subscribed at all.
this.isListeningForReceipts = false; this.isListeningForReceipts = false;
this.ref = React.createRef();
} }
/** /**
@ -640,6 +653,11 @@ export default class EventTile extends React.Component<IProps, IState> {
let left = 0; let left = 0;
const receipts = this.props.readReceipts || []; const receipts = this.props.readReceipts || [];
if (receipts.length === 0) {
return null;
}
for (let i = 0; i < receipts.length; ++i) { for (let i = 0; i < receipts.length; ++i) {
const receipt = receipts[i]; const receipt = receipts[i];
@ -690,10 +708,14 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
} }
return <span className="mx_EventTile_readAvatars"> return (
<div className="mx_EventTile_msgOption">
<span className="mx_EventTile_readAvatars">
{ remText } { remText }
{ avatars } { avatars }
</span>; </span>
</div>
)
} }
onSenderProfileClick = event => { onSenderProfileClick = event => {
@ -953,7 +975,8 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
/> : undefined; /> : undefined;
const timestamp = this.props.mxEvent.getTs() ? const showTimestamp = this.props.mxEvent.getTs() && (this.props.alwaysShowTimestamps || this.state.hover);
const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null; <MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
const keyRequestHelpText = const keyRequestHelpText =
@ -1016,11 +1039,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let msgOption; let msgOption;
if (this.props.showReadReceipts) { if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars(); const readAvatars = this.getReadAvatars();
msgOption = ( msgOption = readAvatars;
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
);
} }
switch (this.props.tileShape) { switch (this.props.tileShape) {
@ -1124,11 +1143,20 @@ export default class EventTile extends React.Component<IProps, IState> {
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return ( return (
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true"> React.createElement(this.props.as || "div", {
{ ircTimestamp } "ref": this.ref,
{ sender } "className": classes,
{ ircPadlock } "tabIndex": -1,
<div className="mx_EventTile_line"> "aria-live": ariaLive,
"aria-atomic": "true",
"data-scroll-tokens": this.props["data-scroll-tokens"],
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, [
ircTimestamp,
sender,
ircPadlock,
<div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
{ thread } { thread }
@ -1145,16 +1173,12 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo } { keyRequestInfo }
{ reactionsRow } { reactionsRow }
{ actionBar } { actionBar }
</div> </div>,
{msgOption} msgOption,
{ avatar,
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids ])
// the need for further z-indexing chaos) )
}
{ avatar }
</div>
);
} }
} }
} }

View file

@ -62,15 +62,13 @@ export default class SimpleRoomHeader extends React.Component {
} }
return ( return (
<div className="mx_RoomHeader" > <div className="mx_RoomHeader mx_RoomHeader_wrapper" >
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader"> <div className="mx_RoomHeader_simpleHeader">
{ icon } { icon }
{ this.props.title } { this.props.title }
{ cancelButton } { cancelButton }
</div> </div>
</div> </div>
</div>
); );
} }
} }

View file

@ -215,7 +215,7 @@ export default class WhoIsTypingTile extends React.Component<IProps, IState> {
this.props.whoIsTypingLimit, this.props.whoIsTypingLimit,
); );
if (!typingString) { if (!typingString) {
return (<div className="mx_WhoIsTypingTile_empty" />); return null;
} }
return ( return (

View file

@ -65,6 +65,10 @@ class FlairStore extends EventEmitter {
delete this._userGroups[userId]; delete this._userGroups[userId];
} }
cachedPublicisedGroups(userId) {
return this._userGroups[userId];
}
getPublicisedGroupsCached(matrixClient, userId) { getPublicisedGroupsCached(matrixClient, userId) {
if (this._userGroups[userId]) { if (this._userGroups[userId]) {
return Promise.resolve(this._userGroups[userId]); return Promise.resolve(this._userGroups[userId]);

View file

@ -309,7 +309,7 @@ describe('MessagePanel', function() {
const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container'); const rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container');
// it should follow the <li> which wraps the event tile for event 4 // it should follow the <li> which wraps the event tile for event 4
const eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; const eventContainer = ReactDOM.findDOMNode(tiles[4]);
expect(rm.previousSibling).toEqual(eventContainer); expect(rm.previousSibling).toEqual(eventContainer);
}); });
@ -365,7 +365,7 @@ describe('MessagePanel', function() {
const tiles = TestUtils.scryRenderedComponentsWithType( const tiles = TestUtils.scryRenderedComponentsWithType(
mp, sdk.getComponent('rooms.EventTile')); mp, sdk.getComponent('rooms.EventTile'));
const tileContainers = tiles.map(function(t) { const tileContainers = tiles.map(function(t) {
return ReactDOM.findDOMNode(t).parentNode; return ReactDOM.findDOMNode(t);
}); });
// find the <li> which wraps the read marker // find the <li> which wraps the read marker