Merge pull request #2276 from matrix-org/bwindels/timelinetypingnotif

Redesign: typing notifications in timeline
pull/21833/head
Bruno Windels 2018-11-14 10:24:31 +00:00 committed by GitHub
commit 04bb9aad3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 234 additions and 87 deletions

View File

@ -108,6 +108,7 @@
@import "./views/rooms/_SearchableEntityList.scss";
@import "./views/rooms/_Stickers.scss";
@import "./views/rooms/_TopUnreadMessagesBar.scss";
@import "./views/rooms/_WhoIsTypingTile.scss";
@import "./views/settings/_DevicesPanel.scss";
@import "./views/settings/_IntegrationsManager.scss";
@import "./views/settings/_Notifications.scss";

View File

@ -0,0 +1,77 @@
/*
Copyright 2018 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_WhoIsTypingTile {
margin-left: -18px; //offset padding from mx_RoomView_MessageList to center avatars
padding-top: 18px;
display: flex;
align-items: center;
}
/* position the indicator in the same place horizontally as .mx_EventTile_avatar. */
.mx_WhoIsTypingTile_avatars {
flex: 0 0 83px; // 18 + 65
text-align: center;
}
.mx_WhoIsTypingTile_avatars > :not(:first-child) {
margin-left: -12px;
}
.mx_WhoIsTypingTile_avatars .mx_BaseAvatar_image {
border: 1px solid $primary-bg-color;
}
.mx_WhoIsTypingTile_avatars .mx_BaseAvatar_initial {
padding-top: 1px;
}
.mx_WhoIsTypingTile_remainingAvatarPlaceholder {
display: inline-block;
color: #acacac;
background-color: #ddd;
border: 1px solid $primary-bg-color;
border-radius: 40px;
width: 24px;
height: 24px;
line-height: 24px;
font-size: 0.8em;
vertical-align: top;
text-align: center;
}
.mx_WhoIsTypingTile_label {
flex: 1;
font-size: 14px;
font-weight: 600;
color: $eventtile-meta-color;
}
.mx_WhoIsTypingTile_label > span {
background-image: url('../../img/typing-indicator-2x.gif');
background-size: 25px;
background-position: left bottom;
background-repeat: no-repeat;
padding-bottom: 15px;
display: block;
}
.mx_MatrixChat_useCompactLayout {
.mx_WhoIsTypingTile {
padding-top: 4px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -119,6 +119,7 @@ $topleftmenu-color: #212121;
$roomheader-color: #45474a;
$roomheader-addroom-color: #929eb4;
$roomtopic-color: #9fa9ba;
$eventtile-meta-color: $roomtopic-color;
// ********************

View File

@ -115,7 +115,7 @@ $topleftmenu-color: $primary-fg-color;
$roomheader-color: $primary-fg-color;
$roomheader-addroom-color: $primary-bg-color;
$roomtopic-color: $settings-grey-fg-color;
$eventtile-meta-color: $roomtopic-color;
// ********************
$roomtile-name-color: rgba(69, 69, 69, 0.8);

View File

@ -63,16 +63,16 @@ module.exports = {
if (whoIsTyping.length == 0) {
return '';
} else if (whoIsTyping.length == 1) {
return _t('%(displayName)s is typing', {displayName: whoIsTyping[0].name});
return _t('%(displayName)s is typing', {displayName: whoIsTyping[0].name});
}
const names = whoIsTyping.map(function(m) {
return m.name;
});
if (othersCount>=1) {
return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount});
return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount});
} else {
const lastPerson = names.pop();
return _t('%(names)s and %(lastPerson)s are typing', {names: names.join(', '), lastPerson: lastPerson});
return _t('%(names)s and %(lastPerson)s are typing', {names: names.join(', '), lastPerson: lastPerson});
}
},
};

View File

@ -631,12 +631,20 @@ module.exports = React.createClass({
}
},
_scrollDownIfAtBottom: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
render: function() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
const Spinner = sdk.getComponent("elements.Spinner");
let topSpinner;
let bottomSpinner;
@ -666,6 +674,7 @@ module.exports = React.createClass({
stickyBottom={this.props.stickyBottom}>
{ topSpinner }
{ this._getEventTiles() }
<WhoIsTypingTile room={this.props.room} onVisible={this._scrollDownIfAtBottom} />
{ bottomSpinner }
</ScrollPanel>
);

View File

@ -62,10 +62,6 @@ module.exports = React.createClass({
// more interesting)
hasActiveCall: PropTypes.bool,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
// true if the room is being peeked at. This affects components that shouldn't
// logically be shown when peeking, such as a prompt to invite people to a room.
isPeeking: PropTypes.bool,
@ -103,24 +99,16 @@ module.exports = React.createClass({
onVisible: PropTypes.func,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
@ -135,7 +123,6 @@ module.exports = React.createClass({
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
}
},
@ -150,12 +137,6 @@ module.exports = React.createClass({
});
},
onRoomMemberTyping: function(ev, member) {
this.setState({
usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room),
});
},
_onSendWithoutVerifyingClick: function() {
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
@ -199,7 +180,6 @@ module.exports = React.createClass({
// indicate other sizes.
_getSize: function() {
if (this._shouldShowConnectionError() ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
@ -213,10 +193,7 @@ module.exports = React.createClass({
},
// return suitable content for the image on the left of the status bar.
//
// if wantPlaceholder is true, we include a "..." placeholder if
// there is nothing better to put in.
_getIndicator: function(wantPlaceholder) {
_getIndicator: function() {
if (this.props.numUnreadMessages) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
@ -250,49 +227,9 @@ module.exports = React.createClass({
return null;
}
if (wantPlaceholder) {
return (
<div className="mx_RoomStatusBar_typingIndicatorAvatars">
{ this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) }
</div>
);
}
return null;
},
_renderTypingIndicatorAvatars: function(limit) {
let users = this.state.usersTyping;
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining" key="others">
+{ othersCount }
</span>,
);
}
return avatars;
},
_shouldShowConnectionError: function() {
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
@ -440,18 +377,6 @@ module.exports = React.createClass({
);
}
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit,
);
if (typingString) {
return (
<div className="mx_RoomStatusBar_typingBar">
<EmojiText>{ typingString }</EmojiText>
</div>
);
}
if (this.props.hasActiveCall) {
return (
<div className="mx_RoomStatusBar_callBar">
@ -483,7 +408,7 @@ module.exports = React.createClass({
render: function() {
const content = this._getContent();
const indicator = this._getIndicator(this.state.usersTyping.length > 0);
const indicator = this._getIndicator();
return (
<div className="mx_RoomStatusBar">

View File

@ -1613,7 +1613,6 @@ module.exports = React.createClass({
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={3}
/>;
}

View File

@ -1154,6 +1154,7 @@ var TimelinePanel = React.createClass({
);
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}

View File

@ -0,0 +1,130 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 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 from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import WhoIsTyping from '../../../WhoIsTyping';
import MatrixClientPeg from '../../../MatrixClientPeg';
import MemberAvatar from '../avatars/MemberAvatar';
module.exports = React.createClass({
displayName: 'WhoIsTypingTile',
propTypes: {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
onVisible: PropTypes.func,
// Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: PropTypes.number,
},
getDefaultProps: function() {
return {
whoIsTypingLimit: 3,
};
},
getInitialState: function() {
return {
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
},
componentDidUpdate: function(_, prevState) {
if (this.props.onVisible &&
!prevState.usersTyping.length &&
this.state.usersTyping.length
) {
this.props.onVisible();
}
},
componentWillUnmount: function() {
// we may have entirely lost our client as we're logging out before clicking login on the guest bar...
const client = MatrixClientPeg.get();
if (client) {
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
}
},
onRoomMemberTyping: function(ev, member) {
this.setState({
usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room),
});
},
_renderTypingIndicatorAvatars: function(limit) {
let users = this.state.usersTyping;
let othersCount = 0;
if (users.length > limit) {
othersCount = users.length - limit + 1;
users = users.slice(0, limit - 1);
}
const avatars = users.map((u) => {
return (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_WhoIsTypingTile_remainingAvatarPlaceholder" key="others">
+{ othersCount }
</span>,
);
}
return avatars;
},
render: function() {
const typingString = WhoIsTyping.whoIsTypingString(
this.state.usersTyping,
this.props.whoIsTypingLimit,
);
if (!typingString) {
return (<div className="mx_WhoIsTypingTile_empty" />);
}
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<li className="mx_WhoIsTypingTile">
<div className="mx_WhoIsTypingTile_avatars">
{ this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit) }
</div>
<div className="mx_WhoIsTypingTile_label">
<EmojiText>{ typingString }</EmojiText>
</div>
</li>
);
},
});

View File

@ -202,10 +202,10 @@
"%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
"%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s",
"%(displayName)s is typing": "%(displayName)s is typing",
"%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing",
"%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing",
"%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing",
"%(displayName)s is typing": "%(displayName)s is typing",
"%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing",
"%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing",
"%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing",
"Failure to create room": "Failure to create room",
"Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.",
"Send anyway": "Send anyway",

View File

@ -26,11 +26,13 @@ const sdk = require('matrix-react-sdk');
const MessagePanel = sdk.getComponent('structures.MessagePanel');
import MatrixClientPeg from '../../../src/MatrixClientPeg';
import Matrix from 'matrix-js-sdk';
const test_utils = require('test-utils');
const mockclock = require('mock-clock');
let client;
const room = new Matrix.Room();
// wrap MessagePanel with a component which provides the MatrixClient in the context.
const WrappedMessagePanel = React.createClass({
@ -45,7 +47,7 @@ const WrappedMessagePanel = React.createClass({
},
render: function() {
return <MessagePanel {...this.props} />;
return <MessagePanel room={room} {...this.props} />;
},
});

View File

@ -82,6 +82,8 @@ describe('TimelinePanel', function() {
sandbox = test_utils.stubClient(sandbox);
room = sinon.createStubInstance(jssdk.Room);
room.currentState = sinon.createStubInstance(jssdk.RoomState);
room.currentState.members = {};
room.roomId = ROOM_ID;
timelineSet = sinon.createStubInstance(jssdk.EventTimelineSet);