Merge branch 'experimental' into bwindels/roomgridview-experimental

This commit is contained in:
Bruno Windels 2019-01-07 14:17:57 +01:00
commit 290dc9d8fb
146 changed files with 5292 additions and 1051 deletions

View file

@ -70,6 +70,7 @@ const EntityTile = React.createClass({
onClick: PropTypes.func,
suppressOnHover: PropTypes.bool,
showPresence: PropTypes.bool,
subtextLabel: PropTypes.string,
},
getDefaultProps: function() {
@ -129,6 +130,9 @@ const EntityTile = React.createClass({
presenceState={this.props.presenceState} />;
nameClasses += ' mx_EntityTile_name_hover';
}
if (this.props.subtextLabel) {
presenceLabel = <span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>;
}
nameEl = (
<div className="mx_EntityTile_details">
<EmojiText element="div" className={nameClasses} dir="auto">
@ -137,6 +141,15 @@ const EntityTile = React.createClass({
{presenceLabel}
</div>
);
} else if (this.props.subtextLabel) {
nameEl = (
<div className="mx_EntityTile_details">
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">
{name}
</EmojiText>
<span className="mx_EntityTile_subtext">{this.props.subtextLabel}</span>
</div>
);
} else {
nameEl = (
<EmojiText element="div" className="mx_EntityTile_name" dir="auto">{ name }</EmojiText>

View file

@ -40,6 +40,8 @@ import { findReadReceiptFromUserId } from '../../../utils/Receipt';
import withMatrixClient from '../../../wrappers/withMatrixClient';
import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig';
import MultiInviter from "../../../utils/MultiInviter";
import SettingsStore from "../../../settings/SettingsStore";
module.exports = withMatrixClient(React.createClass({
displayName: 'MemberInfo',
@ -714,12 +716,18 @@ module.exports = withMatrixClient(React.createClass({
const roomId = member && member.roomId ? member.roomId : this.props.roomId;
const onInviteUserButton = async() => {
try {
await cli.invite(roomId, member.userId);
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
const inviter = new MultiInviter(roomId);
await inviter.invite([member.userId]).then(() => {
if (inviter.getCompletionState(userId) !== "invited")
throw new Error(inviter.getErrorText(userId));
});
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
title: _t('Failed to invite'),
description: ((err && err.message) ? err.message : "Operation failed"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
}
};
@ -882,11 +890,16 @@ module.exports = withMatrixClient(React.createClass({
let presenceState;
let presenceLastActiveAgo;
let presenceCurrentlyActive;
let statusMessage;
if (this.props.member.user) {
presenceState = this.props.member.user.presence;
presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
presenceCurrentlyActive = this.props.member.user.currentlyActive;
if (SettingsStore.isFeatureEnabled("feature_custom_status")) {
statusMessage = this.props.member.user._unstable_statusMessage;
}
}
const room = this.props.matrixClient.getRoom(this.props.member.roomId);
@ -908,6 +921,11 @@ module.exports = withMatrixClient(React.createClass({
presenceState={presenceState} />;
}
let statusLabel = null;
if (statusMessage) {
statusLabel = <span className="mx_MemberInfo_statusMessage">{ statusMessage }</span>;
}
let roomMemberDetails = null;
if (this.props.member.roomId) { // is in room
const PowerSelector = sdk.getComponent('elements.PowerSelector');
@ -924,6 +942,7 @@ module.exports = withMatrixClient(React.createClass({
</div>
<div className="mx_MemberInfo_profileField">
{presenceLabel}
{statusLabel}
</div>
</div>;
}

View file

@ -68,7 +68,9 @@ module.exports = React.createClass({
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.on("User.lastPresenceTs", this.onUserPresenceChange);
cli.on("User.presence", this.onUserPresenceChange);
cli.on("User.currentlyActive", this.onUserPresenceChange);
// cli.on("Room.timeline", this.onRoomTimeline);
},
@ -81,7 +83,9 @@ module.exports = React.createClass({
cli.removeListener("Room.myMembership", this.onMyMembership);
cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom);
cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
cli.removeListener("User.lastPresenceTs", this.onUserPresenceChange);
cli.removeListener("User.presence", this.onUserPresenceChange);
cli.removeListener("User.currentlyActive", this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
@ -132,12 +136,12 @@ module.exports = React.createClass({
};
},
onUserLastPresenceTs(event, user) {
onUserPresenceChange(event, user) {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
// console.log("explicit presence from " + user.userId);
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this._updateList(); // reorder the membership list
}
@ -181,6 +185,10 @@ module.exports = React.createClass({
},
_updateList: new rate_limited_func(function() {
this._updateListNow();
}, 500),
_updateListNow: function() {
// console.log("Updating memberlist");
const newState = {
loading: false,
@ -189,7 +197,7 @@ module.exports = React.createClass({
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery);
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery);
this.setState(newState);
}, 500),
},
getMembersWithUser: function() {
if (!this.props.roomId) return [];
@ -267,7 +275,8 @@ module.exports = React.createClass({
if (!member) {
return "(null)";
} else {
return "(" + member.name + ", " + member.powerLevel + ", " + member.user.lastActiveAgo + ", " + member.user.currentlyActive + ")";
const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")";
}
},
@ -275,48 +284,59 @@ module.exports = React.createClass({
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
memberSort: function(memberA, memberB) {
// order by last active, with "active now" first.
// ...and then by power
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
// ...and then alphabetically.
// We could tiebreak instead by "last recently spoken in this room" if we wanted to.
const userA = memberA.user;
const userB = memberB.user;
// console.log(`Comparing userA=${this.memberString(memberA)} userB=${this.memberString(memberB)}`);
// if (!userA || !userB) {
// console.log("comparing " + memberA.name + " user=" + memberA.user + " with " + memberB.name + " user=" + memberB.user);
// }
const userA = memberA.user;
const userB = memberB.user;
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
// if (!userA) console.log("!! MISSING USER FOR A-SIDE: " + memberA.name + " !!");
// if (!userB) console.log("!! MISSING USER FOR B-SIDE: " + memberB.name + " !!");
// console.log("comparing " + this.memberString(memberA) + " and " + this.memberString(memberB));
if (!userA && !userB) return 0;
if (userA && !userB) return -1;
if (!userA && userB) return 1;
if ((userA.currentlyActive && userB.currentlyActive) || !this._showPresence) {
// console.log(memberA.name + " and " + memberB.name + " are both active");
if (memberA.powerLevel === memberB.powerLevel) {
// console.log(memberA + " and " + memberB + " have same power level");
if (memberA.name && memberB.name) {
// console.log("comparing names: " + memberA.name + " and " + memberB.name);
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
return nameA.localeCompare(nameB);
} else {
return 0;
}
} else {
// console.log("comparing power: " + memberA.powerLevel + " and " + memberB.powerLevel);
return memberB.powerLevel - memberA.powerLevel;
}
// First by presence
if (this._showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? 'active' : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? 'active' : userB.presence);
// console.log(`userA_presenceGroup=${idxA} userB_presenceGroup=${idxB}`);
if (idxA !== idxB) {
// console.log("Comparing on presence group - returning");
return idxA - idxB;
}
}
if (userA.currentlyActive && !userB.currentlyActive) return -1;
if (!userA.currentlyActive && userB.currentlyActive) return 1;
// Second by power level
if (memberA.powerLevel !== memberB.powerLevel) {
// console.log("Comparing on power level - returning");
return memberB.powerLevel - memberA.powerLevel;
}
// For now, let's just order things by timestamp. It's really annoying
// that a user disappears from sight just because they temporarily go offline
// Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
const nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name;
const nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name;
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB);
},
onSearchQueryChanged: function(ev) {

View file

@ -16,6 +16,8 @@ limitations under the License.
'use strict';
import SettingsStore from "../../../settings/SettingsStore";
const React = require('react');
import PropTypes from 'prop-types';
@ -85,6 +87,11 @@ module.exports = React.createClass({
const active = -1;
const presenceState = member.user ? member.user.presence : null;
let statusMessage = null;
if (member.user && SettingsStore.isFeatureEnabled("feature_custom_status")) {
statusMessage = member.user._unstable_statusMessage;
}
const av = (
<MemberAvatar member={member} width={36} height={36} />
);
@ -106,7 +113,9 @@ module.exports = React.createClass({
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} />
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence}
subtextLabel={statusMessage}
/>
);
},
});

View file

@ -138,7 +138,8 @@ export default class MessageComposer extends React.Component {
}
onUploadFileSelected(files) {
this.uploadFiles(files.target.files);
const tfiles = files.target.files;
this.uploadFiles(tfiles);
}
uploadFiles(files) {
@ -146,10 +147,21 @@ export default class MessageComposer extends React.Component {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const fileList = [];
const acceptedFiles = [];
const failedFiles = [];
for (let i=0; i<files.length; i++) {
fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> { files[i].name || _t('Attachment') }
</li>);
const fileAcceptedOrError = this.props.uploadAllowed(files[i]);
if (fileAcceptedOrError === true) {
acceptedFiles.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> { files[i].name || _t('Attachment') }
</li>);
fileList.push(files[i]);
} else {
failedFiles.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> { files[i].name || _t('Attachment') } <p>{ _t('Reason') + ": " + fileAcceptedOrError}</p>
</li>);
}
}
const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent());
@ -160,23 +172,47 @@ export default class MessageComposer extends React.Component {
}</p>;
}
const acceptedFilesPart = acceptedFiles.length === 0 ? null : (
<div>
<p>{ _t('Are you sure you want to upload the following files?') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ acceptedFiles }
</ul>
</div>
);
const failedFilesPart = failedFiles.length === 0 ? null : (
<div>
<p>{ _t('The following files cannot be uploaded:') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ failedFiles }
</ul>
</div>
);
let buttonText;
if (acceptedFiles.length > 0 && failedFiles.length > 0) {
buttonText = "Upload selected"
} else if (failedFiles.length > 0) {
buttonText = "Close"
}
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
title: _t('Upload Files'),
description: (
<div>
<p>{ _t('Are you sure you want to upload the following files?') }</p>
<ul style={{listStyle: 'none', textAlign: 'left'}}>
{ fileList }
</ul>
{ acceptedFilesPart }
{ failedFilesPart }
{ replyToWarning }
</div>
),
hasCancelButton: acceptedFiles.length > 0,
button: buttonText,
onFinished: (shouldUpload) => {
if (shouldUpload) {
// MessageComposer shouldn't have to rely on its parent passing in a callback to upload a file
if (files) {
for (let i=0; i<files.length; i++) {
this.props.uploadFile(files[i]);
if (fileList) {
for (let i=0; i<fileList.length; i++) {
this.props.uploadFile(fileList[i]);
}
}
}
@ -254,7 +290,7 @@ export default class MessageComposer extends React.Component {
render() {
const uploadInputStyle = {display: 'none'};
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
@ -263,7 +299,7 @@ export default class MessageComposer extends React.Component {
if (this.state.me) {
controls.push(
<div key="controls_avatar" className="mx_MessageComposer_avatar">
<MemberAvatar member={this.state.me} width={24} height={24} />
<MemberStatusMessageAvatar member={this.state.me} width={24} height={24} />
</div>,
);
}
@ -301,17 +337,45 @@ export default class MessageComposer extends React.Component {
} else {
callButton =
<AccessibleButton key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={_t('Voice call')}>
<TintableSvg src="img/icon-call.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/phone.svg" width="20" height="20" />
</AccessibleButton>;
videoCallButton =
<AccessibleButton key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title={_t('Video call')}>
<TintableSvg src="img/icons-video.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/video.svg" width="20" height="20" />
</AccessibleButton>;
}
const canSendMessages = !this.state.tombstone &&
this.props.room.maySendMessage();
// TODO: Remove temporary logging for riot-web#7838
// Note: we rip apart the power level event ourselves because we don't want to
// log too much data about it - just the bits we care about. Many of the variables
// logged here are to help figure out where in the stack the 'cannot post in room'
// warning is coming from. This means logging various numbers from the PL event to
// verify RoomState._maySendEventOfType is doing the right thing.
const room = this.props.room;
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
let plEventString = "<no power level event>";
if (plEvent) {
const content = plEvent.getContent();
if (!content) {
plEventString = "<no event content>";
} else {
const stringifyFalsey = (v) => v === null ? '<null>' : (v === undefined ? '<undefined>' : v);
const actualUserPl = stringifyFalsey(content.users ? content.users[room.myUserId] : "<no users in content>");
const usersPl = stringifyFalsey(content.users_default);
const actualEventPl = stringifyFalsey(content.events ? content.events['m.room.message'] : "<no events in content>");
const eventPl = stringifyFalsey(content.events_default);
plEventString = `actualUserPl=${actualUserPl} defaultUserPl=${usersPl} actualEventPl=${actualEventPl} defaultEventPl=${eventPl}`;
}
}
console.log(
`[riot-web#7838] renderComposer() hasTombstone=${!!this.state.tombstone} maySendMessage=${room.maySendMessage()}` +
` myMembership=${room.getMyMembership()} maySendEvent=${room.currentState.maySendEvent('m.room.message', room.myUserId)}` +
` myUserId=${room.myUserId} roomId=${room.roomId} hasPlEvent=${!!plEvent} powerLevels='${plEventString}'`
);
if (canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
@ -319,7 +383,7 @@ export default class MessageComposer extends React.Component {
const uploadButton = (
<AccessibleButton key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title={_t('Upload file')}>
<TintableSvg src="img/icons-upload.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/paperclip.svg" width="20" height="20" />
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple
@ -390,6 +454,8 @@ export default class MessageComposer extends React.Component {
</div>
</div>);
} else {
// TODO: Remove temporary logging for riot-web#7838
console.log("[riot-web#7838] Falling back to showing cannot post in room error");
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
@ -460,6 +526,9 @@ MessageComposer.propTypes = {
// callback when a file to upload is chosen
uploadFile: PropTypes.func.isRequired,
// function to test whether a file should be allowed to be uploaded.
uploadAllowed: PropTypes.func.isRequired,
// string representing the current room app drawer state
showApps: PropTypes.bool,
roomViewStore: PropTypes.object.isRequired,

View file

@ -23,7 +23,6 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from "../../../Modal";
import dis from "../../../dispatcher";
import RateLimitedFunc from '../../../ratelimitedfunc';
import * as linkify from 'linkifyjs';
@ -146,10 +145,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendStateEvent(this.props.room.roomId, 'm.room.avatar', {url: null}, '');
},
onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' });
},
onShareRoomClick: function(ev) {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room dialog', '', ShareDialog, {
@ -342,7 +337,7 @@ module.exports = React.createClass({
if (this.props.onSettingsClick) {
settingsButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title={_t("Settings")}>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16" />
<TintableSvg src="img/feather-icons/settings.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -382,7 +377,7 @@ module.exports = React.createClass({
if (this.props.onSearchClick && this.props.inRoom) {
searchButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title={_t("Search")}>
<TintableSvg src="img/icons-search.svg" width="16" height="16" />
<TintableSvg src="img/feather-icons/search.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -390,15 +385,7 @@ module.exports = React.createClass({
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShareRoomClick} title={_t('Share room')}>
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>;
}
let rightPanelButtons;
if (this.props.collapsedRhs) {
rightPanelButtons =
<AccessibleButton className="mx_RoomHeader_button mx_RoomHeader_showPanel" onClick={this.onShowRhsClick} title={_t('Show panel')}>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
<TintableSvg src="img/feather-icons/share.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -419,7 +406,6 @@ module.exports = React.createClass({
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
{ rightPanelButtons }
</div>;
}
@ -433,7 +419,7 @@ module.exports = React.createClass({
{ saveButton }
{ cancelButton }
{ rightRow }
{ !this.props.isGrid ? <RoomHeaderButtons /> : undefined }
{ !this.props.isGrid ? <RoomHeaderButtons collapsedRhs={this.props.collapsedRhs} /> : undefined }
</div>
</div>
);

View file

@ -71,6 +71,10 @@ module.exports = React.createClass({
getInitialState: function() {
this._subListRefs = {
// key => RoomSubList ref
};
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
this.subListSizes = sizesJson ? JSON.parse(sizesJson) : {};
@ -79,8 +83,10 @@ module.exports = React.createClass({
isLoadingLeftRooms: false,
totalRoomCount: null,
lists: {},
incomingCallTag: null,
incomingCall: null,
selectedTags: [],
hover: false,
};
},
@ -148,6 +154,8 @@ module.exports = React.createClass({
}
this.subListSizes[id] = newSize;
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.subListSizes));
// update overflow indicators
this._checkSubListsOverflow();
},
componentDidMount: function() {
@ -163,19 +171,24 @@ module.exports = React.createClass({
});
// load stored sizes
Object.entries(this.subListSizes).forEach(([id, size]) => {
const handle = this.resizer.forHandleWithId(id);
if (handle) {
handle.resize(size);
}
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
this.resizer.attach();
this.mounted = true;
},
componentDidUpdate: function() {
componentDidUpdate: function(prevProps) {
this._repositionIncomingCallBox(undefined, false);
if (this.props.searchFilter !== prevProps.searchFilter) {
// restore sizes
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
}
},
onAction: function(payload) {
@ -188,11 +201,13 @@ module.exports = React.createClass({
if (call && call.call_state === 'ringing') {
this.setState({
incomingCall: call,
incomingCallTag: this.getTagNameForRoomId(payload.room_id),
});
this._repositionIncomingCallBox(undefined, true);
} else {
this.setState({
incomingCall: null,
incomingCallTag: null,
});
}
break;
@ -280,6 +295,17 @@ module.exports = React.createClass({
this.forceUpdate();
},
onMouseEnter: function(ev) {
this.setState({hover: true});
},
onMouseLeave: function(ev) {
this.setState({hover: false});
// Refresh the room list just in case the user missed something.
this._delayedRefreshRoomList();
},
_delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList();
}, 500),
@ -332,6 +358,11 @@ module.exports = React.createClass({
},
refreshRoomList: function() {
if (this.state.hover) {
// Don't re-sort the list if we're hovering over the list
return;
}
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
// as needed.
@ -347,11 +378,36 @@ module.exports = React.createClass({
// Do this here so as to not render every time the selected tags
// themselves change.
selectedTags: TagOrderStore.getSelectedTags(),
}, () => {
// we don't need to restore any size here, do we?
// i guess we could have triggered a new group to appear
// that already an explicit size the last time it appeared ...
this._checkSubListsOverflow();
});
// this._lastRefreshRoomListTs = Date.now();
},
getTagNameForRoomId: function(roomId) {
const lists = RoomListStore.getRoomLists();
for (const tagName of Object.keys(lists)) {
for (const room of lists[tagName]) {
// Should be impossible, but guard anyways.
if (!room) {
continue;
}
const myUserId = MatrixClientPeg.get().getUserId();
if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, myUserId, this.props.ConferenceHandler)) {
continue;
}
if (room.roomId === roomId) return tagName;
}
}
return null;
},
getRoomLists: function() {
const lists = RoomListStore.getRoomLists();
@ -478,9 +534,38 @@ module.exports = React.createClass({
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
_persistCollapsedState: function(key, collapsed) {
_handleCollapsedState: function(key, collapsed) {
// persist collapsed state
this.collapsedState[key] = collapsed;
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.collapsedState));
// load the persisted size configuration of the expanded sub list
if (!collapsed) {
this._restoreSubListSize(key);
}
// check overflow, as sub lists sizes have changed
// important this happens after calling resize above
this._checkSubListsOverflow();
},
_restoreSubListSize(key) {
const size = this.subListSizes[key];
const handle = this.resizer.forHandleWithId(key);
if (handle) {
handle.resize(size);
}
},
// check overflow for scroll indicator gradient
_checkSubListsOverflow() {
Object.values(this._subListRefs).forEach(l => l.checkOverflow());
},
_subListRef: function(key, ref) {
if (!ref) {
delete this._subListRefs[key];
} else {
this._subListRefs[key] = ref;
}
},
_mapSubListProps: function(subListsProps) {
@ -505,7 +590,7 @@ module.exports = React.createClass({
const {key, label, onHeaderClick, ... otherProps} = props;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._persistCollapsedState(chosenKey, collapsed);
this._handleCollapsedState(chosenKey, collapsed);
if (onHeaderClick) {
onHeaderClick(collapsed);
}
@ -513,6 +598,7 @@ module.exports = React.createClass({
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
let subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
onHeaderClick={onSubListHeaderClick}
key={chosenKey}
@ -535,6 +621,12 @@ module.exports = React.createClass({
},
render: function() {
const incomingCallIfTaggedAs = (tagName) => {
if (!this.state.incomingCall) return null;
if (this.state.incomingCallTag !== tagName) return null;
return this.state.incomingCall;
};
let subLists = [
{
list: [],
@ -547,6 +639,7 @@ module.exports = React.createClass({
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
{
@ -554,6 +647,7 @@ module.exports = React.createClass({
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
@ -561,6 +655,7 @@ module.exports = React.createClass({
tagName: "im.vector.fake.direct",
headerItems: this._getHeaderItems('im.vector.fake.direct'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.direct'),
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'})},
},
{
@ -568,6 +663,7 @@ module.exports = React.createClass({
label: _t('Rooms'),
headerItems: this._getHeaderItems('im.vector.fake.recent'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
onAddRoom: () => {dis.dispatch({action: 'view_create_room'})},
},
];
@ -581,6 +677,7 @@ module.exports = React.createClass({
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
subLists = subLists.concat(tagSubLists);
@ -590,11 +687,13 @@ module.exports = React.createClass({
label: _t('Low priority'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.lowpriority'),
},
{
list: this.state.lists['im.vector.fake.archived'],
label: _t('Historical'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.archived'),
startAsHidden: true,
showSpinner: this.state.isLoadingLeftRooms,
onHeaderClick: this.onArchivedHeaderClick,
@ -604,15 +703,17 @@ module.exports = React.createClass({
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
return (
<div ref={this._collectResizeContainer} className="mx_RoomList">
<div ref={this._collectResizeContainer} className="mx_RoomList"
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{ subListComponents }
</div>
);
},
});
});

View file

@ -0,0 +1,170 @@
/*
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.
*/
import React from "react";
import PropTypes from "prop-types";
import sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
loading: true,
error: null,
unverifiedDevice: null,
};
}
componentWillMount() {
this._loadBackupStatus();
}
async _loadBackupStatus() {
let backupSigStatus;
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo);
} catch (e) {
console.log("Unable to fetch key backup status", e);
this.setState({
loading: false,
error: e,
});
return;
}
let unverifiedDevice;
for (const sig of backupSigStatus.sigs) {
if (!sig.device.isVerified()) {
unverifiedDevice = sig.device;
break;
}
}
this.setState({
loading: false,
unverifiedDevice,
});
}
showSetupDialog = () => {
if (this.state.unverifiedDevice) {
// A key backup exists for this account, but the creating device is not
// verified, so we'll show the device verify dialog.
// TODO: Should change to a restore key backup flow that checks the recovery
// passphrase while at the same time also cross-signing the device as well in
// a single flow (for cases where a key backup exists but the backup creating
// device is unverified). Since we don't have that yet, we'll look for an
// unverified device and verify it. Note that this means we won't restore
// keys yet; instead we'll only trust the backup for sending our own new keys
// to it.
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: this.state.unverifiedDevice,
onFinished: this.props.onFinished,
});
return;
}
// The default case assumes that a key backup doesn't exist for this account, so
// we'll show the create key backup flow.
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
{
onFinished: this.props.onFinished,
},
);
}
onDontAskAgainClick = () => {
// When you choose "Don't ask again" from the room reminder, we show a
// dialog to confirm the choice.
Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder",
import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"),
{
onDontAskAgain: () => {
// Report false to the caller, who should prevent the
// reminder from appearing in the future.
this.props.onFinished(false);
},
onSetup: () => {
this.showSetupDialog();
},
},
);
}
onSetupClick = () => {
this.showSetupDialog();
}
render() {
if (this.state.loading) {
return null;
}
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
let body;
if (this.state.error) {
body = <div className="error">
{_t("Unable to load key backup status")}
</div>;
} else if (this.state.unverifiedDevice) {
// A key backup exists for this account, but the creating device is not
// verified.
body = _t(
"To view your secure message history and ensure you can view new " +
"messages on future devices, set up Secure Message Recovery.",
);
} else {
// The default case assumes that a key backup doesn't exist for this account.
// (This component doesn't currently check that itself.)
body = _t(
"If you log out or use another device, you'll lose your " +
"secure message history. To prevent this, set up Secure " +
"Message Recovery.",
);
}
return (
<div className="mx_RoomRecoveryReminder">
<div className="mx_RoomRecoveryReminder_header">{_t(
"Secure Message Recovery",
)}</div>
<div className="mx_RoomRecoveryReminder_body">{body}</div>
<div className="mx_RoomRecoveryReminder_buttons">
<AccessibleButton className="mx_RoomRecoveryReminder_button mx_RoomRecoveryReminder_secondary"
onClick={this.onDontAskAgainClick}>
{ _t("Don't ask again") }
</AccessibleButton>
<AccessibleButton className="mx_RoomRecoveryReminder_button"
onClick={this.onSetupClick}>
{ _t("Set up") }
</AccessibleButton>
</div>
</div>
);
}
}

View file

@ -29,6 +29,7 @@ import * as RoomNotifs from '../../../RoomNotifs';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import AccessibleButton from '../elements/AccessibleButton';
import ActiveRoomObserver from '../../../ActiveRoomObserver';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
displayName: 'RoomTile',
@ -250,6 +251,17 @@ module.exports = React.createClass({
const mentionBadges = this.props.highlight && this._shouldShowMentionBadge();
const badges = notifBadges || mentionBadges;
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
let subtext = null;
if (!isInvite && isJoined && looksLikeDm && SettingsStore.isFeatureEnabled("feature_custom_status")) {
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (otherMember && otherMember.user && otherMember.user._unstable_statusMessage) {
subtext = otherMember.user._unstable_statusMessage;
}
}
const classes = classNames({
'mx_RoomTile': true,
'mx_RoomTile_selected': this.state.selected,
@ -260,6 +272,7 @@ module.exports = React.createClass({
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges,
'mx_RoomTile_transparent': this.props.transparent,
'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
});
const avatarClasses = classNames({
@ -285,6 +298,7 @@ module.exports = React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
let subtextLabel;
let tooltip;
if (!this.props.collapsed) {
const nameClasses = classNames({
@ -293,6 +307,8 @@ module.exports = React.createClass({
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed,
});
subtextLabel = subtext ? <span className="mx_RoomTile_subtext">{ subtext }</span> : null;
if (this.state.selected) {
const nameSelected = <EmojiText>{ name }</EmojiText>;
@ -332,13 +348,18 @@ module.exports = React.createClass({
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={32} height={32} />
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
{ label }
{ contextMenuButton }
{ badge }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>;

View file

@ -351,7 +351,7 @@ export default class Stickerpicker extends React.Component {
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src="img/icons-stickers.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
} else {
// Show show-stickers button
@ -362,7 +362,7 @@ export default class Stickerpicker extends React.Component {
className="mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src="img/icons-stickers.svg" width="25" height="25" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
}
return <div>