Merge branch 'experimental' into travis/develop2

This commit is contained in:
Travis Ralston 2019-01-17 16:03:45 -07:00
commit 7f6ce69c3e
212 changed files with 6552 additions and 3349 deletions

View file

@ -289,11 +289,6 @@ const Notifier = {
const room = MatrixClientPeg.get().getRoom(ev.getRoomId());
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) {
dis.dispatch({
action: "event_notification",
event: ev,
room: room,
});
if (this.isEnabled()) {
this._displayPopupNotification(ev, room);
}

View file

@ -20,7 +20,6 @@ export default {
HomePage: "home_page",
RoomView: "room_view",
UserSettings: "user_settings",
CreateRoom: "create_room",
RoomDirectory: "room_directory",
UserView: "user_view",
GroupView: "group_view",

View file

@ -15,21 +15,7 @@ limitations under the License.
*/
import SdkConfig from './SdkConfig';
function hashCode(str) {
let hash = 0;
let i;
let chr;
if (str.length === 0) {
return hash;
}
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return Math.abs(hash);
}
import {hashCode} from './utils/FormattingUtils';
export function phasedRollOutExpiredForUser(username, feature, now, rollOutConfig = SdkConfig.get().phasedRollOut) {
if (!rollOutConfig) {

View file

@ -17,21 +17,33 @@ limitations under the License.
const MatrixClientPeg = require("./MatrixClientPeg");
const dis = require("./dispatcher");
import Timer from './utils/Timer';
// Time in ms after that a user is considered as unavailable/away
const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];
class Presence {
constructor() {
this._activitySignal = null;
this._unavailableTimer = null;
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the Home Server.
*/
start() {
this.running = true;
if (undefined === this.state) {
this._resetTimer();
this.dispatcherRef = dis.register(this._onAction.bind(this));
async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
// the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction);
while (this._unavailableTimer) {
try {
await this._unavailableTimer.finished();
this.setState("unavailable");
} catch(e) { /* aborted, stop got called */ }
}
}
@ -39,13 +51,14 @@ class Presence {
* Stop tracking user activity
*/
stop() {
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
dis.unregister(this.dispatcherRef);
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
}
if (this._unavailableTimer) {
this._unavailableTimer.abort();
this._unavailableTimer = null;
}
this.state = undefined;
}
/**
@ -56,21 +69,25 @@ class Presence {
return this.state;
}
_onAction(payload) {
if (payload.action === 'user_activity') {
this.setState("online");
this._unavailableTimer.restart();
}
}
/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
setState(newState) {
async setState(newState) {
if (newState === this.state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
if (!this.running) {
return;
}
const old_state = this.state;
this.state = newState;
@ -78,42 +95,14 @@ class Presence {
return; // don't try to set presence when a guest; it won't work.
}
const self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() {
try {
await MatrixClientPeg.get().setPresence(this.state);
console.log("Presence: %s", newState);
}, function(err) {
} catch(err) {
console.error("Failed to set presence: %s", err);
self.state = old_state;
});
}
/**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private
*/
_onUnavailableTimerFire() {
this.setState("unavailable");
}
_onAction(payload) {
if (payload.action === "user_activity") {
this._resetTimer();
this.state = old_state;
}
}
/**
* Callback called when the user made an action on the page
* @private
*/
_resetTimer() {
const self = this;
this.setState("online");
// Re-arm the timer
clearTimeout(this.timer);
this.timer = setTimeout(function() {
self._onUnavailableTimerFire();
}, UNAVAILABLE_TIME_MS);
}
}
module.exports = new Presence();

View file

@ -135,6 +135,18 @@ export const CommandMap = {
},
}),
roomname: new Command({
name: 'roomname',
args: '<name>',
description: _td('Sets the room name'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().setRoomName(roomId, args));
}
return reject(this.getUsage());
},
}),
invite: new Command({
name: 'invite',
args: '<user-id>',

View file

@ -129,6 +129,11 @@ function textForRoomNameEvent(ev) {
});
}
function textForTombstoneEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName});
}
function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
@ -433,6 +438,7 @@ const stateHandlers = {
'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
'm.room.tombstone': textForTombstoneEvent,
'im.vector.modular.widgets': textForWidgetEvent,
};

View file

@ -154,6 +154,8 @@ class Tinter {
}
tint(primaryColor, secondaryColor, tertiaryColor) {
return;
// eslint-disable-next-line no-unreachable
this.currentTint[0] = primaryColor;
this.currentTint[1] = secondaryColor;
this.currentTint[2] = tertiaryColor;
@ -390,7 +392,7 @@ class Tinter {
// XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now.
calcSvgFixups(svgs, forceColors) {
calcSvgFixups(svgs) {
// go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the
@ -418,21 +420,13 @@ class Tinter {
const tag = tags[j];
for (let k = 0; k < this.svgAttrs.length; k++) {
const attr = this.svgAttrs[k];
for (let m = 0; m < this.keyHex.length; m++) { // dev note: don't use L please.
// We use a different attribute from the one we're setting
// because we may also be using forceColors. If we were to
// check the keyHex against a forceColors value, it may not
// match and therefore not change when we need it to.
const valAttrName = "mx-val-" + attr;
let attribute = tag.getAttribute(valAttrName);
if (!attribute) attribute = tag.getAttribute(attr); // fall back to the original
if (attribute && (attribute.toUpperCase() === this.keyHex[m] || attribute.toLowerCase() === this.keyRgb[m])) {
for (let l = 0; l < this.keyHex.length; l++) {
if (tag.getAttribute(attr) &&
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
fixups.push({
node: tag,
attr: attr,
refAttr: valAttrName,
index: m,
forceColors: forceColors,
index: l,
});
}
}
@ -448,9 +442,7 @@ class Tinter {
if (DEBUG) console.log("applySvgFixups start for " + fixups);
for (let i = 0; i < fixups.length; i++) {
const svgFixup = fixups[i];
const forcedColor = svgFixup.forceColors ? svgFixup.forceColors[svgFixup.index] : null;
svgFixup.node.setAttribute(svgFixup.attr, forcedColor ? forcedColor : this.colors[svgFixup.index]);
svgFixup.node.setAttribute(svgFixup.refAttr, this.colors[svgFixup.index]);
svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
}
if (DEBUG) console.log("applySvgFixups end");
}

View file

@ -15,32 +15,73 @@ limitations under the License.
*/
import dis from './dispatcher';
import Timer from './utils/Timer';
const MIN_DISPATCH_INTERVAL_MS = 500;
const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;
// important this is larger than the timeouts of timers
// used with UserActivity.timeWhileActive,
// such as READ_MARKER_INVIEW_THRESHOLD_MS,
// READ_MARKER_OUTOFVIEW_THRESHOLD_MS,
// READ_RECEIPT_INTERVAL_MS in TimelinePanel
const CURRENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
/**
* This class watches for user activity (moving the mouse or pressing a key)
* and dispatches the user_activity action at times when the user is interacting
* with the app (but at a much lower frequency than mouse move events)
* and starts/stops attached timers while the user is active.
*/
class UserActivity {
constructor() {
this._attachedTimers = [];
this._activityTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onDocumentBlurred = this._onDocumentBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
}
/**
* Runs the given timer while the user is active, aborting when the user becomes inactive.
* Can be called multiple times with the same already running timer, which is a NO-OP.
* Can be called before the user becomes active, in which case it is only started
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
timeWhileActive(timer) {
// important this happens first
const index = this._attachedTimers.indexOf(timer);
if (index === -1) {
this._attachedTimers.push(timer);
// remove when done or aborted
timer.finished().finally(() => {
const index = this._attachedTimers.indexOf(timer);
if (index !== -1) { // should never be -1
this._attachedTimers.splice(index, 1);
}
// as we fork the promise here,
// avoid unhandled rejection warnings
}).catch((err) => {});
}
if (this.userCurrentlyActive()) {
timer.start();
}
}
/**
* Start listening to user activity
*/
start() {
document.onmousedown = this._onUserActivity.bind(this);
document.onmousemove = this._onUserActivity.bind(this);
document.onkeydown = this._onUserActivity.bind(this);
document.onmousedown = this._onUserActivity;
document.onmousemove = this._onUserActivity;
document.onkeydown = this._onUserActivity;
document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
document.addEventListener("blur", this._onDocumentBlurred);
document.addEventListener("focus", this._onUserActivity);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
window.addEventListener('wheel', this._onUserActivity.bind(this),
window.addEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
this.lastActivityAtTs = new Date().getTime();
this.lastDispatchAtTs = 0;
this.activityEndTimer = undefined;
}
/**
@ -50,8 +91,12 @@ class UserActivity {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this),
window.removeEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
document.removeEventListener("blur", this._onDocumentBlurred);
document.removeEventListener("focus", this._onUserActivity);
}
/**
@ -60,10 +105,22 @@ class UserActivity {
* @returns {boolean} true if user is currently/very recently active
*/
userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
return this._activityTimeout.isRunning();
}
_onUserActivity(event) {
_onPageVisibilityChanged(e) {
if (document.visibilityState === "hidden") {
this._activityTimeout.abort();
} else {
this._onUserActivity(e);
}
}
_onDocumentBlurred() {
this._activityTimeout.abort();
}
async _onUserActivity(event) {
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
@ -73,30 +130,20 @@ class UserActivity {
this.lastScreenY = event.screenY;
}
this.lastActivityAtTs = new Date().getTime();
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({
action: 'user_activity',
});
if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS);
}
}
}
_onActivityEndTimer() {
const now = new Date().getTime();
const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) {
dis.dispatch({
action: 'user_activity_end',
});
this.activityEndTimer = undefined;
dis.dispatch({action: 'user_activity'});
if (!this._activityTimeout.isRunning()) {
this._activityTimeout.start();
dis.dispatch({action: 'user_activity_start'});
this._attachedTimers.forEach((t) => t.start());
try {
await this._activityTimeout.finished();
} catch (_e) { /* aborted */ }
this._attachedTimers.forEach((t) => t.abort());
} else {
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now);
this._activityTimeout.restart();
}
}
}
module.exports = new UserActivity();

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

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 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.
@ -21,7 +21,7 @@ import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver';
import { _t, _td } from '../../../../languageHandler';
import { _t } from '../../../../languageHandler';
const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1;
@ -32,6 +32,7 @@ const PHASE_DONE = 5;
const PHASE_OPTOUT_CONFIRM = 6;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
// XXX: copied from ShareDialog: factor out into utils
function selectText(target) {
@ -63,6 +64,13 @@ export default React.createClass({
componentWillMount: function() {
this._recoveryKeyNode = null;
this._keyBackupInfo = null;
this._setZxcvbnResultTimeout = null;
},
componentWillUnmount: function() {
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
},
_collectRecoveryKeyNode: function(n) {
@ -102,7 +110,7 @@ export default React.createClass({
info = await MatrixClientPeg.get().createKeyBackupVersion(
this._keyBackupInfo,
);
await MatrixClientPeg.get().backupAllGroupSessions(info.version);
await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
this.setState({
phase: PHASE_DONE,
});
@ -150,9 +158,23 @@ export default React.createClass({
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
},
_onPassPhraseKeyPress: function(e) {
if (e.key === 'Enter' && this._passPhraseIsValid()) {
this._onPassPhraseNextClick();
_onPassPhraseKeyPress: async function(e) {
if (e.key === 'Enter') {
// If we're waiting for the timeout before updating the result at this point,
// skip ahead and do it now, otherwise we'll deny the attempt to proceed
// even if the user entered a valid passphrase
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this._onPassPhraseNextClick();
}
}
},
@ -177,10 +199,11 @@ export default React.createClass({
passPhrase: '',
passPhraseConfirm: '',
phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
});
},
_onKeepItSafeGotItClick: function() {
_onKeepItSafeBackClick: function() {
this.setState({
phase: PHASE_SHOWKEY,
});
@ -189,11 +212,20 @@ export default React.createClass({
_onPassPhraseChange: function(e) {
this.setState({
passPhrase: e.target.value,
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(e.target.value),
});
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
},
_onPassPhraseConfirmChange: function(e) {
@ -246,6 +278,7 @@ export default React.createClass({
value={this.state.passPhrase}
className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Enter a passphrase...")}
autoFocus={true}
/>
<div className="mx_CreateKeyBackupDialog_passPhraseHelp">
{strengthMeter}
@ -294,14 +327,22 @@ export default React.createClass({
_renderPhasePassPhraseConfirm: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let matchText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
} else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) {
// only tell them they're wrong if they've actually gone wrong.
// Security concious readers will note that if you left riot-web unattended
// on this screen, this would make it easy for a malicious person to guess
// your passphrase one letter at a time, but they could get this faster by
// just opening the browser's developer tools and reading it.
// Note that not having typed anything at all will not hit this clause and
// fall through so empty box === no hint.
matchText = _t("That doesn't match.");
}
let passPhraseMatch = null;
if (this.state.passPhraseConfirm.length > 0) {
let matchText;
if (this.state.passPhraseConfirm === this.state.passPhrase) {
matchText = _t("That matches!");
} else {
matchText = _t("That doesn't match.");
}
if (matchText) {
passPhraseMatch = <div className="mx_CreateKeyBackupDialog_passPhraseMatch">
<div>{matchText}</div>
<div>
@ -342,11 +383,12 @@ export default React.createClass({
},
_renderPhaseShowKey: function() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let bodyText;
if (this.state.setPassPhrase) {
bodyText = _t("As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.");
bodyText = _t(
"As a safety net, you can use it to restore your encrypted message " +
"history if you forget your Recovery Passphrase.",
);
} else {
bodyText = _t("As a safety net, you can use it to restore your encrypted message history.");
}
@ -354,7 +396,7 @@ export default React.createClass({
return <div>
<p>{_t("Make a copy of this Recovery Key and keep it safe.")}</p>
<p>{bodyText}</p>
<p className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
{_t("Your Recovery Key")}
</div>
@ -371,13 +413,7 @@ export default React.createClass({
</button>
</div>
</div>
</p>
<br />
<DialogButtons primaryButton={_t("I've made a copy")}
onPrimaryButtonClick={this._createBackup}
hasCancel={false}
disabled={!this.state.copied && !this.state.downloaded}
/>
</div>
</div>;
},
@ -402,17 +438,17 @@ export default React.createClass({
<li>{_t("<b>Save it</b> on a USB key or backup drive", {}, {b: s => <b>{s}</b>})}</li>
<li>{_t("<b>Copy it</b> to your personal cloud storage", {}, {b: s => <b>{s}</b>})}</li>
</ul>
<DialogButtons primaryButton={_t("Got it")}
onPrimaryButtonClick={this._onKeepItSafeGotItClick}
hasCancel={false}
/>
<DialogButtons primaryButton={_t("OK")}
onPrimaryButtonClick={this._createBackup}
hasCancel={false}>
<button onClick={this._onKeepItSafeBackClick}>{_t("Back")}</button>
</DialogButtons>
</div>;
},
_renderBusyPhase: function(text) {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div>
<p>{_t(text)}</p>
<Spinner />
</div>;
},
@ -420,8 +456,10 @@ export default React.createClass({
_renderPhaseDone: function() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<p>{_t("Backup created")}</p>
<p>{_t("Your encryption keys are now being backed up to your Homeserver.")}</p>
<p>{_t(
"Your encryption keys are now being backed up in the background " +
"to your Homeserver. The initial backup could take several minutes. " +
"You can view key backup upload progress in Settings.")}</p>
<DialogButtons primaryButton={_t('Close')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
@ -458,7 +496,9 @@ export default React.createClass({
case PHASE_KEEPITSAFE:
return _t('Keep it safe');
case PHASE_BACKINGUP:
return _t('Backing up...');
return _t('Starting backup...');
case PHASE_DONE:
return _t('Backup Started');
default:
return _t("Create Key Backup");
}
@ -495,7 +535,7 @@ export default React.createClass({
content = this._renderPhaseKeepItSafe();
break;
case PHASE_BACKINGUP:
content = this._renderBusyPhase(_td("Backing up..."));
content = this._renderBusyPhase();
break;
case PHASE_DONE:
content = this._renderPhaseDone();
@ -510,7 +550,7 @@ export default React.createClass({
<BaseDialog className='mx_CreateKeyBackupDialog'
onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)}
hasCancel={[PHASE_DONE].includes(this.state.phase)}
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)}
>
<div>
{content}

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018-2019 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.
@ -24,9 +24,15 @@ import Modal from "../../../../Modal";
export default class NewRecoveryMethodDialog extends React.PureComponent {
static propTypes = {
// As returned by js-sdk getKeyBackupVersion()
newVersionInfo: PropTypes.object,
onFinished: PropTypes.func.isRequired,
}
onOkClick = () => {
this.props.onFinished();
}
onGoToSettingsClick = () => {
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
@ -41,8 +47,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
// sending our own new keys to it.
let backupSigStatus;
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo);
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(this.props.newVersionInfo);
} catch (e) {
console.log("Unable to fetch key backup status", e);
return;
@ -71,39 +76,62 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
const title = <span className="mx_NewRecoveryMethodDialog_title">
const title = <span className="mx_KeyBackupFailedDialog_title">
{_t("New Recovery Method")}
</span>;
const newMethodDetected = <p>{_t(
"A new recovery passphrase and key for Secure " +
"Messages have been detected.",
)}</p>;
const hackWarning = <p className="warning">{_t(
"If you didn't set the new recovery method, an " +
"attacker may be trying to access your account. " +
"Change your account password and set a new recovery " +
"method immediately in Settings.",
)}</p>;
let content;
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
content = <div>
{newMethodDetected}
<p>{_t(
"This device is encrypting history using the new recovery method.",
)}</p>
{hackWarning}
<DialogButtons
primaryButton={_t("OK")}
onPrimaryButtonClick={this.onOkClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>;
} else {
content = <div>
{newMethodDetected}
<p>{_t(
"Setting up Secure Messages on this device " +
"will re-encrypt this device's message history with " +
"the new recovery method.",
)}</p>
{hackWarning}
<DialogButtons
primaryButton={_t("Set up Secure Messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>;
}
return (
<BaseDialog className="mx_NewRecoveryMethodDialog"
<BaseDialog className="mx_KeyBackupFailedDialog"
onFinished={this.props.onFinished}
title={title}
hasCancel={false}
>
<div>
<p>{_t(
"A new recovery passphrase and key for Secure " +
"Messages has been detected.",
)}</p>
<p>{_t(
"Setting up Secure Messages on this device " +
"will re-encrypt this device's message history with " +
"the new recovery method.",
)}</p>
<p className="warning">{_t(
"If you didn't set the new recovery method, an " +
"attacker may be trying to access your account. " +
"Change your account password and set a new recovery " +
"method immediately in Settings.",
)}</p>
<DialogButtons
primaryButton={_t("Set up Secure Messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>
{content}
</BaseDialog>
);
}

View file

@ -0,0 +1,80 @@
/*
Copyright 2019 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 dis from "../../../../dispatcher";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
export default class RecoveryMethodRemovedDialog extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
onGoToSettingsClick = () => {
this.props.onFinished();
dis.dispatch({ action: 'view_user_settings' });
}
onSetupClick = () => {
this.props.onFinished();
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("./CreateKeyBackupDialog"),
);
}
render() {
const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog");
const DialogButtons = sdk.getComponent("views.elements.DialogButtons");
const title = <span className="mx_KeyBackupFailedDialog_title">
{_t("Recovery Method Removed")}
</span>;
return (
<BaseDialog className="mx_KeyBackupFailedDialog"
onFinished={this.props.onFinished}
title={title}
>
<div>
<p>{_t(
"This device has detected that your recovery passphrase and key " +
"for Secure Messages have been removed.",
)}</p>
<p>{_t(
"If you did this accidentally, you can setup Secure Messages on " +
"this device which will re-encrypt this device's message " +
"history with a new recovery method.",
)}</p>
<p className="warning">{_t(
"If you didn't remove the recovery method, an " +
"attacker may be trying to access your account. " +
"Change your account password and set a new recovery " +
"method immediately in Settings.",
)}</p>
<DialogButtons
primaryButton={_t("Set up Secure Messages")}
onPrimaryButtonClick={this.onSetupClick}
cancelButton={_t("Go to Settings")}
onCancel={this.onGoToSettingsClick}
/>
</div>
</BaseDialog>
);
}
}

View file

@ -41,8 +41,12 @@ export default class AutocompleteProvider {
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
* @param {string} query The query string
* @param {SelectionRange} selection Selection to search
* @param {boolean} force True if the user is forcing completion
* @return {object} { command, range } where both objects fields are null if no match
*/
getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false): ?string {
getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false) {
let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {

View file

@ -60,8 +60,8 @@ const PROVIDER_COMPLETION_TIMEOUT = 3000;
export default class Autocompleter {
constructor(room: Room) {
this.room = room;
this.providers = PROVIDERS.map((p) => {
return new p(room);
this.providers = PROVIDERS.map((Prov) => {
return new Prov(room);
});
}

View file

@ -41,7 +41,7 @@ export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null;
room: Room = null;
constructor(room) {
constructor(room: Room) {
super(USER_REGEX, FORCED_USER_REGEX);
this.room = room;
this.matcher = new QueryMatcher([], {

View file

@ -0,0 +1,137 @@
/*
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";
// derived from code from github.com/noeldelgado/gemini-scrollbar
// Copyright (c) Noel Delgado <pixelia.me@gmail.com> (pixelia.me)
function getScrollbarWidth(alternativeOverflow) {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.width = '100px';
div.style.height = '100px';
div.style.overflow = "scroll";
if (alternativeOverflow) {
div.style.overflow = alternativeOverflow;
}
div.style.msOverflowStyle = '-ms-autohiding-scrollbar';
document.body.appendChild(div);
const scrollbarWidth = (div.offsetWidth - div.clientWidth);
document.body.removeChild(div);
return scrollbarWidth;
}
function install() {
const scrollbarWidth = getScrollbarWidth();
if (scrollbarWidth !== 0) {
const hasForcedOverlayScrollbar = getScrollbarWidth('overlay') === 0;
// overflow: overlay on webkit doesn't auto hide the scrollbar
if (hasForcedOverlayScrollbar) {
document.body.classList.add("mx_scrollbar_overlay_noautohide");
} else {
document.body.classList.add("mx_scrollbar_nooverlay");
const style = document.createElement('style');
style.type = 'text/css';
style.innerText =
`body.mx_scrollbar_nooverlay { --scrollbar-width: ${scrollbarWidth}px; }`;
document.head.appendChild(style);
}
}
}
const installBodyClassesIfNeeded = (function() {
let installed = false;
return function() {
if (!installed) {
install();
installed = true;
}
};
})();
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this.onOverflow = this.onOverflow.bind(this);
this.onUnderflow = this.onUnderflow.bind(this);
this._collectContainerRef = this._collectContainerRef.bind(this);
this._needsOverflowListener = null;
}
onOverflow() {
this.containerRef.classList.add("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.remove("mx_AutoHideScrollbar_underflow");
}
onUnderflow() {
this.containerRef.classList.remove("mx_AutoHideScrollbar_overflow");
this.containerRef.classList.add("mx_AutoHideScrollbar_underflow");
}
checkOverflow() {
if (!this._needsOverflowListener) {
return;
}
if (this.containerRef.scrollHeight > this.containerRef.clientHeight) {
this.onOverflow();
} else {
this.onUnderflow();
}
}
componentDidUpdate() {
this.checkOverflow();
}
componentDidMount() {
installBodyClassesIfNeeded();
this._needsOverflowListener =
document.body.classList.contains("mx_scrollbar_nooverlay");
if (this._needsOverflowListener) {
this.containerRef.addEventListener("overflow", this.onOverflow);
this.containerRef.addEventListener("underflow", this.onUnderflow);
}
this.checkOverflow();
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
}
}
componentWillUnmount() {
if (this._needsOverflowListener && this.containerRef) {
this.containerRef.removeEventListener("overflow", this.onOverflow);
this.containerRef.removeEventListener("underflow", this.onUnderflow);
}
}
render() {
return (<div
ref={this._collectContainerRef}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }
</div>
</div>);
}
}

View file

@ -49,7 +49,7 @@ export default class ContextualMenu extends React.Component {
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func,
menuPaddingTop: PropTypes.number,
@ -113,7 +113,6 @@ export default class ContextualMenu extends React.Component {
render() {
const position = {};
let chevronFace = null;
const props = this.props;
if (props.top) {
@ -137,6 +136,8 @@ export default class ContextualMenu extends React.Component {
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
const hasChevron = chevronFace && chevronFace !== "none";
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
@ -174,11 +175,14 @@ export default class ContextualMenu extends React.Component {
`;
}
const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
const chevron = hasChevron ?
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
undefined;
const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_noChevron': chevronFace === 'none',
'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',

View file

@ -1,284 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
const PresetValues = {
PrivateChat: "private_chat",
PublicChat: "public_chat",
Custom: "custom",
};
module.exports = React.createClass({
displayName: 'CreateRoom',
propTypes: {
onRoomCreated: PropTypes.func,
collapsedRhs: PropTypes.bool,
},
phases: {
CONFIG: "CONFIG", // We're waiting for user to configure and hit create.
CREATING: "CREATING", // We're sending the request.
CREATED: "CREATED", // We successfully created the room.
ERROR: "ERROR", // There was an error while trying to create room.
},
getDefaultProps: function() {
return {
onRoomCreated: function() {},
};
},
getInitialState: function() {
return {
phase: this.phases.CONFIG,
error_string: "",
is_private: true,
share_history: false,
default_preset: PresetValues.PrivateChat,
topic: '',
room_name: '',
invited_users: [],
};
},
onCreateRoom: function() {
const options = {};
if (this.state.room_name) {
options.name = this.state.room_name;
}
if (this.state.topic) {
options.topic = this.state.topic;
}
if (this.state.preset) {
if (this.state.preset != PresetValues.Custom) {
options.preset = this.state.preset;
} else {
options.initial_state = [
{
type: "m.room.join_rules",
content: {
"join_rule": this.state.is_private ? "invite" : "public",
},
},
{
type: "m.room.history_visibility",
content: {
"history_visibility": this.state.share_history ? "shared" : "invited",
},
},
];
}
}
options.invite = this.state.invited_users;
const alias = this.getAliasLocalpart();
if (alias) {
options.room_alias_name = alias;
}
const cli = MatrixClientPeg.get();
if (!cli) {
// TODO: Error.
console.error("Cannot create room: No matrix client.");
return;
}
const deferred = cli.createRoom(options);
if (this.state.encrypt) {
// TODO
}
this.setState({
phase: this.phases.CREATING,
});
const self = this;
deferred.then(function(resp) {
self.setState({
phase: self.phases.CREATED,
});
self.props.onRoomCreated(resp.room_id);
}, function(err) {
self.setState({
phase: self.phases.ERROR,
error_string: err.toString(),
});
});
},
getPreset: function() {
return this.refs.presets.getPreset();
},
getName: function() {
return this.refs.name_textbox.getName();
},
getTopic: function() {
return this.refs.topic.getTopic();
},
getAliasLocalpart: function() {
return this.refs.alias.getAliasLocalpart();
},
getInvitedUsers: function() {
return this.refs.user_selector.getUserIds();
},
onPresetChanged: function(preset) {
switch (preset) {
case PresetValues.PrivateChat:
this.setState({
preset: preset,
is_private: true,
share_history: false,
});
break;
case PresetValues.PublicChat:
this.setState({
preset: preset,
is_private: false,
share_history: true,
});
break;
case PresetValues.Custom:
this.setState({
preset: preset,
});
break;
}
},
onPrivateChanged: function(ev) {
this.setState({
preset: PresetValues.Custom,
is_private: ev.target.checked,
});
},
onShareHistoryChanged: function(ev) {
this.setState({
preset: PresetValues.Custom,
share_history: ev.target.checked,
});
},
onTopicChange: function(ev) {
this.setState({
topic: ev.target.value,
});
},
onNameChange: function(ev) {
this.setState({
room_name: ev.target.value,
});
},
onInviteChanged: function(invited_users) {
this.setState({
invited_users: invited_users,
});
},
onAliasChanged: function(alias) {
this.setState({
alias: alias,
});
},
onEncryptChanged: function(ev) {
this.setState({
encrypt: ev.target.checked,
});
},
render: function() {
const curr_phase = this.state.phase;
if (curr_phase == this.phases.CREATING) {
const Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
);
} else {
let error_box = "";
if (curr_phase == this.phases.ERROR) {
error_box = (
<div className="mx_Error">
{ _t('An error occurred: %(error_string)s', {error_string: this.state.error_string}) }
</div>
);
}
const CreateRoomButton = sdk.getComponent("create_room.CreateRoomButton");
const RoomAlias = sdk.getComponent("create_room.RoomAlias");
const Presets = sdk.getComponent("create_room.Presets");
const UserSelector = sdk.getComponent("elements.UserSelector");
const SimpleRoomHeader = sdk.getComponent("rooms.SimpleRoomHeader");
const domain = MatrixClientPeg.get().getDomain();
return (
<div className="mx_CreateRoom">
<SimpleRoomHeader title={_t("Create Room")} collapsedRhs={this.props.collapsedRhs} />
<div className="mx_CreateRoom_body">
<input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder={_t('Name')} /> <br />
<textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder={_t('Topic')} /> <br />
<RoomAlias ref="alias" alias={this.state.alias} homeserver={domain} onChange={this.onAliasChanged} /> <br />
<UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged} /> <br />
<Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset} /> <br />
<div>
<label>
<input type="checkbox" ref="is_private" checked={this.state.is_private} onChange={this.onPrivateChanged} />
{ _t('Make this room private') }
</label>
</div>
<div>
<label>
<input type="checkbox" ref="share_history" checked={this.state.share_history} onChange={this.onShareHistoryChanged} />
{ _t('Share message history with new users') }
</label>
</div>
<div className="mx_CreateRoom_encrypt">
<label>
<input type="checkbox" ref="encrypt" checked={this.state.encrypt} onChange={this.onEncryptChanged} />
{ _t('Encrypt room') }
</label>
</div>
<div>
<CreateRoomButton onCreateRoom={this.onCreateRoom} /> <br />
</div>
{ error_box }
</div>
</div>
);
}
},
});

View file

@ -24,6 +24,9 @@ import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
import GroupHeaderButtons from '../views/right_panel/GroupHeaderButtons';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import Modal from '../../Modal';
import classnames from 'classnames';
@ -1271,25 +1274,19 @@ export default React.createClass({
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>,
);
if (this.props.collapsedRhs) {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
onClick={this._onShowRhsClick} title={_t('Show panel')} key="_maximiseButton"
>
<TintableSvg src="img/maximise.svg" width="10" height="16" />
</AccessibleButton>,
);
}
}
const rightPanel = !this.props.collapsedRhs ? <RightPanel groupId={this.props.groupId} /> : undefined;
const headerClasses = {
mx_GroupView_header: true,
mx_GroupView_header_view: !this.state.editing,
mx_GroupView_header_isUserMember: this.state.isUserMember,
"mx_GroupView_header": true,
"light-panel": true,
"mx_GroupView_header_view": !this.state.editing,
"mx_GroupView_header_isUserMember": this.state.isUserMember,
};
return (
<div className="mx_GroupView">
<main className="mx_GroupView">
<div className={classnames(headerClasses)}>
<div className="mx_GroupView_header_leftCol">
<div className="mx_GroupView_header_avatar">
@ -1307,12 +1304,15 @@ export default React.createClass({
<div className="mx_GroupView_header_rightCol">
{ rightButtons }
</div>
<GroupHeaderButtons collapsedRhs={this.props.collapsedRhs} />
</div>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</div>
<MainSplit collapsedRhs={this.props.collapsedRhs} panel={rightPanel}>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</MainSplit>
</main>
);
} else if (this.state.error) {
if (this.state.error.httpStatus === 404) {

View file

@ -91,11 +91,15 @@ class HomePage extends React.Component {
this._unmounted = true;
}
onLoginClick() {
onLoginClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_login' });
}
onRegisterClick() {
onRegisterClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_registration' });
}

View file

@ -0,0 +1,73 @@
/*
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 AutoHideScrollbar from "./AutoHideScrollbar";
export default class IndicatorScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
this._collectScrollerComponent = this._collectScrollerComponent.bind(this);
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
}
_collectScroller(scroller) {
if (scroller && !this._scrollElement) {
this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow);
this.checkOverflow();
}
}
_collectScrollerComponent(autoHideScrollbar) {
this._autoHideScrollbar = autoHideScrollbar;
}
checkOverflow() {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_topOverflow");
}
if (hasBottomOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_bottomOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
}
componentWillUnmount() {
if (this._scrollElement) {
this._scrollElement.removeEventListener("scroll", this.checkOverflow);
}
}
render() {
return (<AutoHideScrollbar ref={this._collectScrollerComponent} wrappedRef={this._collectScroller} {... this.props}>
{ this.props.children }
</AutoHideScrollbar>);
}
}

View file

@ -151,8 +151,7 @@ const LeftPanel = React.createClass({
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search") ||
classes.contains("mx_RoomSubList_ellipsis")));
classes.contains("mx_textinput_search")));
if (element) {
element.focus();
@ -171,6 +170,12 @@ const LeftPanel = React.createClass({
this.setState({ searchFilter: term });
},
onSearchCleared: function(source) {
if (source === "keyboard") {
dis.dispatch({action: 'focus_composer'});
}
},
collectRoomList: function(ref) {
this._roomList = ref;
},
@ -178,18 +183,9 @@ const LeftPanel = React.createClass({
render: function() {
const RoomList = sdk.getComponent('rooms.RoomList');
const TagPanel = sdk.getComponent('structures.TagPanel');
const BottomLeftMenu = sdk.getComponent('structures.BottomLeftMenu');
const CallPreview = sdk.getComponent('voip.CallPreview');
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const topBox = <SearchBox collapsed={ this.props.collapsed } onSearch={ this.onSearch } />;
const classes = classNames(
"mx_LeftPanel",
{
"collapsed": this.props.collapsed,
},
);
const CallPreview = sdk.getComponent('voip.CallPreview');
const tagPanelEnabled = !SettingsStore.getValue("TagPanel.disableTagPanel");
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
@ -197,27 +193,32 @@ const LeftPanel = React.createClass({
const containerClasses = classNames(
"mx_LeftPanel_container", "mx_fadable",
{
"mx_LeftPanel_container_collapsed": this.props.collapsed,
"collapsed": this.props.collapsed,
"mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
"mx_fadable_faded": this.props.disabled,
},
);
const searchBox = !this.props.collapsed ?
<SearchBox onSearch={ this.onSearch } onCleared={ this.onSearchCleared } /> :
undefined;
return (
<div className={containerClasses}>
{ tagPanel }
<aside className={classes} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
{ topBox }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
{ searchBox }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList
ref={this.collectRoomList}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
<BottomLeftMenu collapsed={this.props.collapsed} />
</aside>
</div>
);
// <BottomLeftMenu collapsed={this.props.collapsed}/>
},
});

View file

@ -34,7 +34,8 @@ import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'
// 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.
// NB. this is just for server notices rather than pinned messages in general.
@ -61,7 +62,7 @@ const LoggedInView = React.createClass({
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
onRegistered: PropTypes.func,
collapsedRhs: PropTypes.bool,
teamToken: PropTypes.string,
// Used by the RoomView to handle joining rooms
@ -94,6 +95,12 @@ const LoggedInView = React.createClass({
};
},
componentDidMount: function() {
this.resizer = this._createResizer();
this.resizer.attach();
this._loadResizerPreferences();
},
componentWillMount: function() {
// stash the MatrixClient in case we log out before we are unmounted
this._matrixClient = this.props.matrixClient;
@ -123,6 +130,7 @@ const LoggedInView = React.createClass({
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
this.resizer.detach();
},
// Child components assume that the client peg will not be null, so give them some
@ -148,6 +156,39 @@ const LoggedInView = React.createClass({
});
},
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
};
const collapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
this.setState({collapseLhs: collapsed});
if (collapsed) {
window.localStorage.setItem("mx_lhs_size", '0');
}
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
},
};
const resizer = new Resizer(
this.resizeContainer,
CollapseDistributor,
collapseConfig);
resizer.setClassNames(classNames);
return resizer;
},
_loadResizerPreferences() {
const lhsSize = window.localStorage.getItem("mx_lhs_size");
if (lhsSize !== null) {
this.resizer.forHandleAt(0).resize(parseInt(lhsSize, 10));
}
},
onAccountData: function(event) {
if (event.getType() === "im.vector.web.settings") {
this.setState({
@ -364,12 +405,14 @@ const LoggedInView = React.createClass({
this.setState({mouseDown: null});
},
_setResizeContainerRef(div) {
this.resizeContainer = div;
},
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const CreateRoom = sdk.getComponent('structures.CreateRoom');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage');
const GroupView = sdk.getComponent('structures.GroupView');
@ -382,7 +425,6 @@ const LoggedInView = React.createClass({
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element;
let right_panel = '';
switch (this.props.page_type) {
case PageTypes.RoomView:
@ -396,12 +438,9 @@ const LoggedInView = React.createClass({
eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'}
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
/>;
if (!this.props.collapseRhs) {
right_panel = <RightPanel roomId={this.props.currentRoomId} disabled={this.props.rightDisabled} />;
}
break;
case PageTypes.UserSettings:
@ -411,21 +450,12 @@ const LoggedInView = React.createClass({
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.MyGroups:
page_element = <MyGroups />;
break;
case PageTypes.CreateRoom:
page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapseRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
ref="roomDirectory"
@ -451,15 +481,15 @@ const LoggedInView = React.createClass({
case PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel disabled={this.props.rightDisabled} />;
// TODO: fix/remove UserView
// right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break;
case PageTypes.GroupView:
page_element = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs}
collapsedRhs={this.props.collapsedRhs}
/>;
if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;
break;
}
@ -511,15 +541,13 @@ const LoggedInView = React.createClass({
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
collapsed={this.props.collapseLhs || false}
collapsed={this.props.collapseLhs || this.state.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<main className='mx_MatrixChat_middlePanel'>
{ page_element }
</main>
{ right_panel }
<ResizeHandle/>
{ page_element }
</div>
</DragDropContext>
</div>

View file

@ -0,0 +1,101 @@
/*
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 ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, FixedDistributor} from '../../resizer';
export default class MainSplit extends React.Component {
constructor(props) {
super(props);
this._setResizeContainerRef = this._setResizeContainerRef.bind(this);
this._onResized = this._onResized.bind(this);
}
_onResized(size) {
window.localStorage.setItem("mx_rhs_size", size);
}
_createResizer() {
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse",
};
const resizer = new Resizer(
this.resizeContainer,
FixedDistributor,
{onResized: this._onResized},
);
resizer.setClassNames(classNames);
let rhsSize = window.localStorage.getItem("mx_rhs_size");
if (rhsSize !== null) {
rhsSize = parseInt(rhsSize, 10);
} else {
rhsSize = 350;
}
resizer.forHandleAt(0).resize(rhsSize);
resizer.attach();
this.resizer = resizer;
}
_setResizeContainerRef(div) {
this.resizeContainer = div;
}
componentDidMount() {
if (this.props.panel && !this.props.collapsedRhs) {
this._createResizer();
}
}
componentWillUnmount() {
if (this.resizer) {
this.resizer.detach();
this.resizer = null;
}
}
componentDidUpdate(prevProps) {
const wasExpanded = !this.props.collapsedRhs && prevProps.collapsedRhs;
const wasCollapsed = this.props.collapsedRhs && !prevProps.collapsedRhs;
const wasPanelSet = this.props.panel && !prevProps.panel;
const wasPanelCleared = !this.props.panel && prevProps.panel;
if (wasExpanded || wasPanelSet) {
this._createResizer();
} else if (wasCollapsed || wasPanelCleared) {
this.resizer.detach();
this.resizer = null;
}
}
render() {
const bodyView = React.Children.only(this.props.children);
const panelView = this.props.panel;
if (this.props.collapsedRhs || !panelView) {
return bodyView;
} else {
return <div className="mx_MainSplit" ref={this._setResizeContainerRef}>
{ bodyView }
<ResizeHandle reverse={true} />
{ panelView }
</div>;
}
}
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017-2019 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.
@ -163,7 +163,7 @@ export default React.createClass({
viewUserId: null,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true",
leftDisabled: false,
middleDisabled: false,
rightDisabled: false,
@ -579,7 +579,7 @@ export default React.createClass({
break;
case 'view_user':
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
if (this.state.collapseRhs) {
if (this.state.collapsedRhs) {
setTimeout(()=>{
dis.dispatch({
action: 'show_right_panel',
@ -680,13 +680,15 @@ export default React.createClass({
});
break;
case 'hide_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", true);
this.setState({
collapseRhs: true,
collapsedRhs: true,
});
break;
case 'show_right_panel':
window.localStorage.setItem("mx_rhs_collapsed", false);
this.setState({
collapseRhs: false,
collapsedRhs: false,
});
break;
case 'panel_disable': {
@ -697,9 +699,11 @@ export default React.createClass({
});
break;
}
case 'set_theme':
this._onSetTheme(payload.value);
break;
// case 'set_theme':
// disable changing the theme for now
// as other themes are not compatible with dharma
// this._onSetTheme(payload.value);
// break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// NB. This does not touch 'ready' since if our dispatches
@ -923,6 +927,10 @@ export default React.createClass({
},
_viewHome: function() {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
view: VIEWS.LOGGED_IN,
});
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
},
@ -1179,10 +1187,7 @@ export default React.createClass({
* @param {string} teamToken
*/
_onLoggedIn: async function(teamToken) {
this.setState({
view: VIEWS.LOGGED_IN,
});
this.setStateForNewView({view: VIEWS.LOGGED_IN});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
@ -1239,7 +1244,7 @@ export default React.createClass({
view: VIEWS.LOGIN,
ready: false,
collapseLhs: false,
collapseRhs: false,
collapsedRhs: false,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
@ -1430,10 +1435,33 @@ export default React.createClass({
break;
}
});
cli.on("crypto.keyBackupFailed", () => {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
);
cli.on("crypto.keyBackupFailed", async (errcode) => {
let haveNewVersion;
let newVersionInfo;
// if key backup is still enabled, there must be a new backup in place
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
haveNewVersion = true;
} else {
// otherwise check the server to see if there's a new one
try {
newVersionInfo = await MatrixClientPeg.get().getKeyBackupVersion();
if (newVersionInfo !== null) haveNewVersion = true;
} catch (e) {
console.error("Saw key backup error but failed to check backup version!", e);
return;
}
}
if (haveNewVersion) {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method',
import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'),
{ newVersionInfo },
);
} else {
Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed',
import('../../async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog'),
);
}
});
// Fire the tinter right on startup to ensure the default theme is applied

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;
@ -656,6 +664,11 @@ module.exports = React.createClass({
},
);
let whoIsTyping;
if (this.props.room) {
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._scrollDownIfAtBottom} />);
}
return (
<ScrollPanel ref="scrollPanel" className={className}
onScroll={this.props.onScroll}
@ -666,6 +679,7 @@ module.exports = React.createClass({
stickyBottom={this.props.stickyBottom}>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }
{ bottomSpinner }
</ScrollPanel>
);

View file

@ -60,7 +60,6 @@ export default withMatrixClient(React.createClass({
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GroupTile = sdk.getComponent("groups.GroupTile");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
@ -112,7 +111,6 @@ export default withMatrixClient(React.createClass({
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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.
@ -19,97 +20,28 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../languageHandler';
import sdk from '../../index';
import dis from '../../dispatcher';
import { MatrixClient } from 'matrix-js-sdk';
import Analytics from '../../Analytics';
import RateLimitedFunc from '../../ratelimitedfunc';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import { formatCount } from '../../utils/FormattingUtils';
import MatrixClientPeg from "../../MatrixClientPeg";
class HeaderButton extends React.Component {
constructor() {
super();
this.onClick = this.onClick.bind(this);
export default class RightPanel extends React.Component {
static get propTypes() {
return {
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
};
}
onClick(ev) {
Analytics.trackEvent(...this.props.analytics);
dis.dispatch({
action: 'view_right_panel_phase',
phase: this.props.clickPhase,
});
static get contextTypes() {
return {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// XXX: We really shouldn't be hardcoding colors here, but the way TintableSvg
// works kinda prevents us from using normal CSS tactics. We use $warning-color
// here.
// Note: This array gets passed along to the Tinter's forceColors eventually.
const tintableColors = this.props.badgeHighlight ? ["#ff0064"] : null;
const classNames = ["mx_RightPanel_headerButton"];
if (this.props.badgeHighlight) classNames.push("mx_RightPanel_headerButton_badgeHighlight");
return <AccessibleButton
aria-label={this.props.title}
aria-expanded={this.props.isHighlighted}
title={this.props.title}
className={classNames.join(" ")}
onClick={this.onClick} >
<div className="mx_RightPanel_headerButton_badge">
{ this.props.badge ? this.props.badge : <span>&nbsp;</span> }
</div>
<TintableSvg src={this.props.iconSrc} width="25" height="25" forceColors={tintableColors} />
{ this.props.isHighlighted ? <div className="mx_RightPanel_headerButton_highlight" /> : <div /> }
</AccessibleButton>;
}
}
HeaderButton.propTypes = {
// Whether this button is highlighted
isHighlighted: PropTypes.bool.isRequired,
// The phase to swap to when the button is clicked
clickPhase: PropTypes.string.isRequired,
// The source file of the icon to display
iconSrc: PropTypes.string.isRequired,
// The badge to display above the icon
badge: PropTypes.node,
badgeHighlight: PropTypes.bool,
// The parameters to track the click event
analytics: PropTypes.arrayOf(PropTypes.string).isRequired,
// Button title
title: PropTypes.string.isRequired,
};
module.exports = React.createClass({
displayName: 'RightPanel',
propTypes: {
// TODO: We're trying to move away from these being props, but we need to know
// whether we should be displaying a room or group member list
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
collapsed: React.PropTypes.bool, // currently unused property to request for a minimized view of the panel
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
Phase: {
static Phase = Object.freeze({
RoomMemberList: 'RoomMemberList',
GroupMemberList: 'GroupMemberList',
GroupRoomList: 'GroupRoomList',
@ -118,163 +50,102 @@ module.exports = React.createClass({
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
GroupMemberInfo: 'GroupMemberInfo',
},
});
componentWillMount: function() {
constructor(props, context) {
super(props, context);
this.state = {
phase: this.props.groupId ? RightPanel.Phase.GroupMemberList : RightPanel.Phase.RoomMemberList,
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
this._delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate();
}, 500);
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context.matrixClient;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
if (this.context.matrixClient) {
this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember);
}
this._unregisterGroupStore(this.props.groupId);
},
getInitialState: function() {
return {
phase: this.props.groupId ? this.Phase.GroupMemberList : this.Phase.RoomMemberList,
isUserPrivilegedInGroup: null,
};
},
}
componentWillReceiveProps(newProps) {
if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId);
this._initGroupStore(newProps.groupId);
}
},
}
_initGroupStore(groupId) {
if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
},
}
_unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated);
},
}
onGroupStoreUpdated: function() {
onGroupStoreUpdated() {
this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
});
},
}
onCollapseClick: function() {
dis.dispatch({
action: 'hide_right_panel',
});
},
onInviteButtonClick: function() {
if (this.context.matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
// call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.props.roomId,
});
},
onInviteToGroupButtonClick: function() {
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: this.Phase.GroupMemberList,
phase: RightPanel.Phase.GroupMemberList,
});
});
},
}
onAddRoomToGroupButtonClick: function() {
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
},
}
onRoomStateMember: function(ev, state, member) {
onRoomStateMember(ev, state, member) {
if (member.roomId !== this.props.roomId) {
return;
}
// redraw the badge on the membership list
if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
if (this.state.phase === RightPanel.Phase.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate();
} else if (this.state.phase === this.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level)
this._delayedUpdate();
}
},
}
_delayedUpdate: new RateLimitedFunc(function() {
this.forceUpdate(); // eslint-disable-line babel/no-invalid-this
}, 500),
onAction: function(payload) {
if (payload.action === "event_notification") {
// Try and re-caclulate any badge counts we might have
this.forceUpdate();
} else if (payload.action === "view_user") {
dis.dispatch({
action: 'show_right_panel',
});
if (payload.member) {
this.setState({
phase: this.Phase.RoomMemberInfo,
member: payload.member,
});
} else {
if (this.props.roomId) {
this.setState({
phase: this.Phase.RoomMemberList,
});
} else if (this.props.groupId) {
this.setState({
phase: this.Phase.GroupMemberList,
member: payload.member,
});
}
}
} else if (payload.action === "view_group") {
this.setState({
phase: this.Phase.GroupMemberList,
member: null,
});
} else if (payload.action === "view_group_room") {
this.setState({
phase: this.Phase.GroupRoomInfo,
groupRoomId: payload.groupRoomId,
});
} else if (payload.action === "view_group_room_list") {
this.setState({
phase: this.Phase.GroupRoomList,
});
} else if (payload.action === "view_group_member_list") {
this.setState({
phase: this.Phase.GroupMemberList,
});
} else if (payload.action === "view_group_user") {
this.setState({
phase: this.Phase.GroupMemberInfo,
member: payload.member,
});
} else if (payload.action === "view_room") {
this.setState({
phase: this.Phase.RoomMemberList,
});
} else if (payload.action === "view_right_panel_phase") {
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
this.setState({
phase: payload.phase,
groupRoomId: payload.groupRoomId,
groupId: payload.groupId,
member: payload.member,
});
}
},
}
render: function() {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
@ -285,164 +156,42 @@ module.exports = React.createClass({
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let inviteGroup;
let membersBadge;
let membersTitle = _t('Members');
if ((this.state.phase === this.Phase.RoomMemberList || this.state.phase === this.Phase.RoomMemberInfo)
&& this.props.roomId
) {
const cli = this.context.matrixClient;
const room = cli.getRoom(this.props.roomId);
let isUserInRoom;
if (room) {
const numMembers = room.getJoinedMemberCount();
membersTitle = _t('%(count)s Members', { count: numMembers });
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');
}
if (isUserInRoom) {
inviteGroup =
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this room') }</div>
</AccessibleButton>;
}
}
const isPhaseGroup = [
this.Phase.GroupMemberInfo,
this.Phase.GroupMemberList,
].includes(this.state.phase);
let headerButtons = [];
if (this.props.roomId) {
let notifCountBadge;
let notifCount = 0;
MatrixClientPeg.get().getRooms().forEach(r => notifCount += (r.getUnreadNotificationCount('highlight') || 0));
if (notifCount > 0) {
const title = _t("%(count)s Notifications", {count: formatCount(notifCount)});
notifCountBadge = <div title={title}>{ formatCount(notifCount) }</div>;
}
headerButtons = [
<HeaderButton key="_membersButton" title={membersTitle} iconSrc="img/icons-people.svg"
isHighlighted={[this.Phase.RoomMemberList, this.Phase.RoomMemberInfo].includes(this.state.phase)}
clickPhase={this.Phase.RoomMemberList}
badge={membersBadge}
analytics={['Right Panel', 'Member List Button', 'click']}
/>,
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/icons-files.svg"
isHighlighted={this.state.phase === this.Phase.FilePanel}
clickPhase={this.Phase.FilePanel}
analytics={['Right Panel', 'File List Button', 'click']}
/>,
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/icons-notifications.svg"
isHighlighted={this.state.phase === this.Phase.NotificationPanel}
clickPhase={this.Phase.NotificationPanel}
badge={notifCountBadge} badgeHighlight={notifCount > 0}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,
];
} else if (this.props.groupId) {
headerButtons = [
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={isPhaseGroup}
clickPhase={this.Phase.GroupMemberList}
analytics={['Right Panel', 'Group Member List Button', 'click']}
/>,
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room.svg"
isHighlighted={[this.Phase.GroupRoomList, this.Phase.GroupRoomInfo].includes(this.state.phase)}
clickPhase={this.Phase.GroupRoomList}
analytics={['Right Panel', 'Group Room List Button', 'click']}
/>,
];
}
if (this.props.roomId || this.props.groupId) {
// Hiding the right panel hides it completely and relies on an 'expand' button
// being put in the RoomHeader or GroupView header, so only show the minimise
// button on these 2 screens or you won't be able to re-expand the panel.
headerButtons.push(
<AccessibleButton className="mx_RightPanel_headerButton mx_RightPanel_collapsebutton" key="_minimizeButton"
title={_t("Hide panel")} aria-label={_t("Hide panel")} onClick={this.onCollapseClick}
>
<TintableSvg src="img/minimise.svg" width="10" height="16" alt="" />
</AccessibleButton>,
);
}
let panel = <div />;
if (!this.props.collapsed) {
if (this.props.roomId && this.state.phase === this.Phase.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === this.Phase.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === this.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === this.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === this.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
} else if (this.state.phase === this.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === this.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === this.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
}
}
if (!panel) {
panel = <div className="mx_RightPanel_blank" />;
}
if (this.props.groupId && this.state.isUserPrivilegedInGroup) {
inviteGroup = isPhaseGroup ? (
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteToGroupButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
</AccessibleButton>
) : (
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onAddRoomToGroupButtonClick}>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icons-room-add.svg" width="35" height="35" />
</div>
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
</AccessibleButton>
);
if (this.props.roomId && this.state.phase === RightPanel.Phase.RoomMemberList) {
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
} else if (this.props.groupId && this.state.phase === RightPanel.Phase.GroupMemberList) {
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
} else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}
groupId={this.props.groupId}
key={this.state.groupRoomId} />;
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {
"collapsed": this.props.collapsed,
"mx_fadable_faded": this.props.disabled,
"dark-panel": true,
});
return (
<aside className={classes}>
<div className="mx_RightPanel_header">
<div className="mx_RightPanel_headerButtonGroup">
{ headerButtons }
</div>
</div>
{ panel }
<div className="mx_RightPanel_footer">
{ inviteGroup }
</div>
</aside>
);
},
});
}
}

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

@ -19,12 +19,11 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';
import sdk from '../../index';
import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
@ -33,8 +32,6 @@ import PropTypes from 'prop-types';
// turn this on for drop & drag console debugging galore
const debug = false;
const TRUNCATE_AT = 10;
const RoomSubList = React.createClass({
displayName: 'RoomSubList',
@ -44,7 +41,6 @@ const RoomSubList = React.createClass({
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
editable: PropTypes.bool,
order: PropTypes.string.isRequired,
@ -55,21 +51,15 @@ const RoomSubList = React.createClass({
showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
alwaysShowHeader: PropTypes.bool,
incomingCall: PropTypes.object,
onShowMoreRooms: PropTypes.func,
searchFilter: PropTypes.string,
emptyContent: PropTypes.node, // content shown if the list is empty
isFiltered: PropTypes.bool,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
showEmpty: PropTypes.bool,
},
getInitialState: function() {
return {
hidden: this.props.startAsHidden || false,
truncateAt: TRUNCATE_AT,
sortedList: [],
};
},
@ -77,18 +67,12 @@ const RoomSubList = React.createClass({
return {
onHeaderClick: function() {
}, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [],
isInvite: false,
showEmpty: true,
};
},
componentWillMount: function() {
this.setState({
sortedList: this.applySearchFilter(this.props.list, this.props.searchFilter),
});
this.dispatcherRef = dis.register(this.onAction);
},
@ -96,23 +80,6 @@ const RoomSubList = React.createClass({
dis.unregister(this.dispatcherRef);
},
componentWillReceiveProps: function(newProps) {
// order the room list appropriately before we re-render
//if (debug) console.log("received new props, list = " + newProps.list);
this.setState({
sortedList: this.applySearchFilter(newProps.list, newProps.searchFilter),
});
},
applySearchFilter: function(list, filter) {
if (filter === "") return list;
const lcFilter = filter.toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
// The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
@ -143,15 +110,9 @@ const RoomSubList = React.createClass({
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden});
if (isHidden) {
// as good a way as any to reset the truncate state
this.setState({truncateAt: TRUNCATE_AT});
}
this.props.onShowMoreRooms();
this.props.onHeaderClick(isHidden);
this.setState({hidden: isHidden}, () => {
this.props.onHeaderClick(isHidden);
});
} else {
// The header is stuck, so the click is to be interpreted as a scroll to the header
this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
@ -178,10 +139,9 @@ const RoomSubList = React.createClass({
/**
* Total up all the notification counts from the rooms
*
* @param {Number} truncateAt If supplied will only total notifications for rooms outside the truncation number
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/
roomNotificationCount: function(truncateAt) {
roomNotificationCount: function() {
const self = this;
if (this.props.isInvite) {
@ -189,20 +149,18 @@ const RoomSubList = React.createClass({
}
return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
}
return result;
@ -217,15 +175,9 @@ const RoomSubList = React.createClass({
},
makeRoomTiles: function() {
const DNDRoomTile = sdk.getComponent("rooms.DNDRoomTile");
const RoomTile = sdk.getComponent("rooms.RoomTile");
return this.state.sortedList.map((room, index) => {
// XXX: is it evil to pass in this as a prop to RoomTile? Yes.
// We should only use <DNDRoomTile /> when editable
const RoomTileComponent = this.props.editable ? DNDRoomTile : RoomTile;
return <RoomTileComponent
index={index} // For DND
return this.props.list.map((room, index) => {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
@ -246,7 +198,7 @@ const RoomSubList = React.createClass({
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.state.sortedList) {
for (const room of this.props.list) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
@ -269,10 +221,10 @@ const RoomSubList = React.createClass({
e.preventDefault();
e.stopPropagation();
// switch to first room in sortedList as that'll be the top of the list for the user
if (this.state.sortedList && this.state.sortedList.length > 0) {
if (this.props.list && this.props.list.length > 0) {
dis.dispatch({
action: 'view_room',
room_id: this.state.sortedList[0].roomId,
room_id: this.props.list[0].roomId,
});
} else if (this.props.extraTiles && this.props.extraTiles.length > 0) {
// Group Invites are different in that they are all extra tiles and not rooms
@ -287,32 +239,25 @@ const RoomSubList = React.createClass({
},
_getHeaderJsx: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
const roomCount = totalTiles > 0 ? totalTiles : '';
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
let badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
if (!this.props.collapsed) {
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
}
}
// When collapsed, allow a long hover on the header to show user
@ -320,9 +265,6 @@ const RoomSubList = React.createClass({
let title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
title += " [" + roomCount + "]";
}
}
let incomingCall;
@ -333,126 +275,77 @@ const RoomSubList = React.createClass({
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
const tabindex = this.props.searchFilter === "" ? "0" : "-1";
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
);
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const len = this.props.list.length + this.props.extraTiles.length;
let chevron;
if (len) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
chevron = (<div className={chevronClasses}></div>);
}
const tabindex = this.props.isFiltered ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses} />
{badge}
{incomingCall}
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
let content = <div className="mx_RoomSubList_chevronDown" />;
const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
const overflowNotifCount = overflowNotifications[0];
const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount);
checkOverflow: function() {
if (this.refs.scroller) {
this.refs.scroller.checkOverflow();
}
const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
});
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={badgeClasses}>{content}</div>
</AccessibleButton>
);
},
_showFullMemberList: function() {
this.setState({
truncateAt: -1,
});
this.props.onShowMoreRooms();
this.props.onHeaderClick(false);
},
render: function() {
const TruncatedList = sdk.getComponent('elements.TruncatedList');
let content;
if (this.props.showEmpty) {
// this is new behaviour with still controversial UX in that in hiding RoomSubLists the drop zones for DnD
// are also gone so when filtering users can't DnD rooms to some tags but is a lot cleaner otherwise.
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
content = this.props.emptyContent;
const len = this.props.list.length + this.props.extraTiles.length;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": this.state.hidden,
"mx_RoomSubList_nonEmpty": len && !this.state.hidden,
});
if (this.state.hidden) {
return <div className={subListClasses}>
{this._getHeaderJsx()}
</div>;
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles);
return <div className={subListClasses}>
{this._getHeaderJsx()}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
{ tiles }
</IndicatorScrollbar>
</div>;
}
} else {
if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
// if no search filter is applied and there is a placeholder defined then show it, otherwise show nothing
if (!this.props.searchFilter && this.props.emptyContent) {
content = this.props.emptyContent;
} else {
// don't show an empty sublist
return null;
}
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
}
}
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
let subList;
const classes = "mx_RoomSubList";
if (!this.state.hidden) {
subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{content}
</TruncatedList>;
} else {
subList = <TruncatedList className={classes}>
</TruncatedList>;
}
const subListContent = <div>
{this._getHeaderJsx()}
{subList}
</div>;
return this.props.editable ?
<Droppable
droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile"
>
{(provided, snapshot) => (
<div ref={provided.innerRef}>
{subListContent}
</div>
)}
</Droppable> : subListContent;
} else {
const Loader = sdk.getComponent("elements.Spinner");
if (this.props.showSpinner) {
let content;
if (this.props.showSpinner && !this.state.hidden) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ this.state.hidden ? undefined : content }
{ this._getHeaderJsx() }
{ content }
</div>
);
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 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.
@ -44,6 +44,8 @@ const Rooms = require('../../Rooms');
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
@ -609,17 +611,10 @@ module.exports = React.createClass({
}
},
async onRoomRecoveryReminderFinished(backupCreated) {
// If the user cancelled the key backup dialog, it suggests they don't
// want to be reminded anymore.
if (!backupCreated) {
await SettingsStore.setValue(
"showRoomRecoveryReminder",
null,
SettingLevel.ACCOUNT,
false,
);
}
onRoomRecoveryReminderDontAskAgain: function() {
// Called when the option to not ask again is set:
// force an update to hide the recovery reminder
this.forceUpdate();
},
onKeyBackupStatus() {
@ -1141,11 +1136,6 @@ module.exports = React.createClass({
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
if (this.state.searchResults.results === undefined) {
// awaiting results
return [];
}
const ret = [];
if (this.state.searchInProgress) {
@ -1202,7 +1192,7 @@ module.exports = React.createClass({
const roomName = room ? room.name : _t("Unknown room %(roomId)s", { roomId: roomId });
ret.push(<li key={mxEv.getId() + "-room"}>
<h1>{ _t("Room") }: { roomName }</h1>
<h2>{ _t("Room") }: { roomName }</h2>
</li>);
lastRoomId = roomId;
}
@ -1571,18 +1561,20 @@ module.exports = React.createClass({
oobData={this.props.oobData}
collapsedRhs={this.props.collapsedRhs}
/>
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
@ -1616,16 +1608,18 @@ module.exports = React.createClass({
room={this.state.room}
collapsedRhs={this.props.collapsedRhs}
/>
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
@ -1668,7 +1662,6 @@ module.exports = React.createClass({
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={3}
/>;
}
@ -1699,7 +1692,7 @@ module.exports = React.createClass({
aux = <RoomUpgradeWarningBar room={this.state.room} />;
hideCancel = true;
} else if (showRoomRecoveryReminder) {
aux = <RoomRecoveryReminder onFinished={this.onRoomRecoveryReminderFinished} />;
aux = <RoomRecoveryReminder onDontAskAgainSet={this.onRoomRecoveryReminderDontAskAgain} />;
hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
@ -1732,6 +1725,7 @@ module.exports = React.createClass({
const auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
fullHeight={this.state.editingRoomSettings}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
@ -1818,16 +1812,21 @@ module.exports = React.createClass({
let hideMessagePanel = false;
if (this.state.searchResults) {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
</ScrollPanel>
);
// show searching spinner
if (this.state.searchResults.results === undefined) {
searchResultsPanel = (<div className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner" />);
} else {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
</ScrollPanel>
);
}
hideMessagePanel = true;
}
@ -1860,14 +1859,10 @@ module.exports = React.createClass({
let topUnreadMessagesBar = null;
if (this.state.showTopUnreadMessagesBar) {
const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
topUnreadMessagesBar = (
<div className="mx_RoomView_topUnreadMessagesBar">
<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker}
/>
</div>
);
topUnreadMessagesBar = (<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker}
/>);
}
const statusBarAreaClass = classNames(
"mx_RoomView_statusArea",
@ -1883,8 +1878,10 @@ module.exports = React.createClass({
},
);
const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} /> : undefined;
return (
<div className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
editing={this.state.editingRoomSettings}
@ -1899,20 +1896,24 @@ module.exports = React.createClass({
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
/>
{ auxPanel }
<div className={fadableSectionClasses}>
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }
<MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs}>
<div className={fadableSectionClasses}>
{ auxPanel }
<div className="mx_RoomView_timeline">
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }
</div>
</div>
{ messageComposer }
</div>
{ messageComposer }
</div>
</div>
</MainSplit>
</main>
);
},
});

View file

@ -28,8 +28,8 @@ module.exports = React.createClass({
displayName: 'SearchBox',
propTypes: {
collapsed: React.PropTypes.bool,
onSearch: React.PropTypes.func,
onCleared: React.PropTypes.func,
},
getInitialState: function() {
@ -56,7 +56,6 @@ module.exports = React.createClass({
case 'focus_room_filter':
if (this.refs.search) {
this.refs.search.focus();
this.refs.search.select();
}
break;
}
@ -75,86 +74,49 @@ module.exports = React.createClass({
100,
),
onToggleCollapse: function(show) {
if (show) {
dis.dispatch({
action: 'show_left_panel',
});
} else {
dis.dispatch({
action: 'hide_left_panel',
});
}
},
_onKeyDown: function(ev) {
switch (ev.keyCode) {
case KeyCode.ESCAPE:
this._clearSearch();
dis.dispatch({action: 'focus_composer'});
this._clearSearch("keyboard");
break;
}
},
_clearSearch: function() {
_onFocus: function(ev) {
ev.target.select();
},
_clearSearch: function(source) {
this.refs.search.value = "";
this.onChange();
if (this.props.onCleared) {
this.props.onCleared(source);
}
},
render: function() {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const collapseTabIndex = this.refs.search && this.refs.search.value !== "" ? "-1" : "0";
const clearButton = this.state.searchTerm.length > 0 ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button")} }>
</AccessibleButton>) : undefined;
let toggleCollapse;
if (this.props.collapsed) {
toggleCollapse =
<AccessibleButton className="mx_SearchBox_maximise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, true) }>
<TintableSvg src="img/maximise.svg" width="10" height="16" alt={ _t("Expand panel") } />
</AccessibleButton>;
} else {
toggleCollapse =
<AccessibleButton className="mx_SearchBox_minimise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, false) }>
<TintableSvg src="img/minimise.svg" width="10" height="16" alt={ _t("Collapse panel") } />
</AccessibleButton>;
}
let searchControls;
if (!this.props.collapsed) {
searchControls = [
this.state.searchTerm.length > 0 ?
<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ ()=>{ this._clearSearch(); } }>
<TintableSvg
className="mx_SearchBox_searchButton"
src="img/icons-close.svg" width="24" height="24"
/>
</AccessibleButton>
:
<TintableSvg
key="button"
className="mx_SearchBox_searchButton"
src="img/icons-search-copy.svg" width="13" height="13"
/>,
<input
key="searchfield"
type="text"
ref="search"
className="mx_SearchBox_search"
value={ this.state.searchTerm }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
/>,
];
}
const self = this;
return (
<div className="mx_SearchBox">
{ searchControls }
{ toggleCollapse }
<div className="mx_SearchBox mx_textinput">
<input
key="searchfield"
type="text"
ref="search"
className="mx_textinput_icon mx_textinput_search"
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
/>
{ clearButton }
</div>
);
},

View file

@ -23,6 +23,7 @@ import GroupActions from '../../actions/GroupActions';
import sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
@ -47,6 +48,8 @@ const TagPanel = React.createClass({
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this._dispatcherRef = dis.register(this._onAction);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
@ -67,6 +70,9 @@ const TagPanel = React.createClass({
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
}
},
_onGroupMyMembership() {
@ -100,13 +106,21 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'deselect_tags'});
},
_onAction(payload) {
if (payload.action === "show_redesign_feedback_dialog") {
const RedesignFeedbackDialog =
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
Modal.createDialog(RedesignFeedbackDialog);
}
},
render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const ActionButton = sdk.getComponent("elements.ActionButton");
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -162,7 +176,10 @@ const TagPanel = React.createClass({
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton tooltip={true} />
<GroupsButton />
<ActionButton
className="mx_TagPanel_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>
</div>;
},

View file

@ -33,9 +33,13 @@ const ObjectUtils = require('../../ObjectUtils');
const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const READ_MARKER_INVIEW_THRESHOLD_MS = 1 * 1000;
const READ_MARKER_OUTOFVIEW_THRESHOLD_MS = 30 * 1000;
const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
@ -188,6 +192,14 @@ var TimelinePanel = React.createClass({
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;
if (this.props.manageReadReceipts) {
this.updateReadReceiptOnUserActivity();
}
if (this.props.manageReadMarkers) {
this.updateReadMarkerOnUserActivity();
}
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
@ -254,6 +266,14 @@ var TimelinePanel = React.createClass({
//
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
if (this._readReceiptActivityTimer) {
this._readReceiptActivityTimer.abort();
this._readReceiptActivityTimer = null;
}
if (this._readMarkerActivityTimer) {
this._readMarkerActivityTimer.abort();
this._readMarkerActivityTimer = null;
}
dis.unregister(this.dispatcherRef);
@ -362,30 +382,25 @@ var TimelinePanel = React.createClass({
}
if (this.props.manageReadMarkers) {
const rmPosition = this.getReadMarkerPosition();
// we hide the read marker when it first comes onto the screen, but if
// it goes back off the top of the screen (presumably because the user
// clicks on the 'jump to bottom' button), we need to re-enable it.
if (this.getReadMarkerPosition() < 0) {
if (rmPosition < 0) {
this.setState({readMarkerVisible: true});
}
// if read marker position goes between 0 and -1/1,
// (and user is active), switch timeout
const timeout = this._readMarkerTimeout(rmPosition);
// NO-OP when timeout already has set to the given value
this._readMarkerActivityTimer.changeTimeout(timeout);
}
},
onAction: function(payload) {
switch (payload.action) {
case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
this.updateReadMarker();
break;
case 'ignore_state_changed':
this.forceUpdate();
break;
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
},
@ -531,6 +546,38 @@ var TimelinePanel = React.createClass({
this.setState({clientSyncState: state});
},
_readMarkerTimeout(readMarkerPosition) {
return readMarkerPosition === 0 ?
READ_MARKER_INVIEW_THRESHOLD_MS :
READ_MARKER_OUTOFVIEW_THRESHOLD_MS;
},
updateReadMarkerOnUserActivity: async function() {
const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition());
this._readMarkerActivityTimer = new Timer(initialTimeout);
while (this._readMarkerActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
},
updateReadReceiptOnUserActivity: async function() {
this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
while (this._readReceiptActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
},
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
if (!this.props.manageReadReceipts) return;
@ -634,10 +681,11 @@ var TimelinePanel = React.createClass({
// of the screen, so move the marker down to the bottom of the screen.
updateReadMarker: function() {
if (!this.props.manageReadMarkers) return;
if (this.getReadMarkerPosition() !== 0) {
if (this.getReadMarkerPosition() === 1) {
// the read marker is at an event below the viewport,
// we don't want to rewind it.
return;
}
// move the RM to *after* the message at the bottom of the screen. This
// avoids a problem whereby we never advance the RM if there is a huge
// message which doesn't fit on the screen.
@ -654,7 +702,6 @@ var TimelinePanel = React.createClass({
if (lastDisplayedIndex === null) {
return;
}
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs());
@ -749,7 +796,6 @@ var TimelinePanel = React.createClass({
this._loadTimeline(this.state.readMarkerEventId, 0, 1/3);
},
/* update the read-up-to marker to match the read receipt
*/
forgetReadMarker: function() {
@ -822,15 +868,12 @@ var TimelinePanel = React.createClass({
canJumpToReadMarker: function() {
// 1. Do not show jump bar if neither the RM nor the RR are set.
// 2. Only show jump bar if RR !== RM. If they are the same, there are only fully
// read messages and unread messages. We already have a badge count and the bottom
// bar to jump to "live" when we have unread messages.
// 3. We want to show the bar if the read-marker is off the top of the screen.
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
const pos = this.getReadMarkerPosition();
return this.state.readMarkerEventId !== null && // 1.
this.state.readMarkerEventId !== this._getCurrentReadReceipt() && // 2.
const ret = this.state.readMarkerEventId !== null && // 1.
(pos < 0 || pos === null); // 3., 4.
return ret;
},
/**
@ -917,7 +960,6 @@ var TimelinePanel = React.createClass({
}
this.sendReadReceipt();
this.updateReadMarker();
});
};
@ -1154,6 +1196,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,115 @@
/*
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 * as ContextualMenu from './ContextualMenu';
import {TopLeftMenu} from '../views/context_menus/TopLeftMenu';
import AccessibleButton from '../views/elements/AccessibleButton';
import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
const AVATAR_SIZE = 28;
export default class TopLeftMenuButton extends React.Component {
static propTypes = {
collapsed: PropTypes.bool.isRequired,
};
static displayName = 'TopLeftMenuButton';
constructor() {
super();
this.state = {
menuDisplayed: false,
profileInfo: null,
};
this.onToggleMenu = this.onToggleMenu.bind(this);
}
async _getProfileInfo() {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
const profileInfo = await cli.getProfileInfo(userId);
const avatarUrl = Avatar.avatarUrlForUser(
{avatarUrl: profileInfo.avatar_url},
AVATAR_SIZE, AVATAR_SIZE, "crop");
return {
userId,
name: profileInfo.displayname,
avatarUrl,
};
}
async componentDidMount() {
try {
const profileInfo = await this._getProfileInfo();
this.setState({profileInfo});
} catch (ex) {
console.log("could not fetch profile");
console.error(ex);
}
}
render() {
const fallbackUserId = MatrixClientPeg.get().getUserId();
const profileInfo = this.state.profileInfo;
const name = profileInfo ? profileInfo.name : fallbackUserId;
let nameElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
{ name }
</div>;
}
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<BaseAvatar
idName={fallbackUserId}
name={name}
url={profileInfo && profileInfo.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
{ nameElement }
<img className="mx_TopLeftMenuButton_chevron" src="img/topleft-chevron.svg" width="11" height="6" />
</AccessibleButton>
);
}
onToggleMenu(e) {
e.preventDefault();
e.stopPropagation();
const elementRect = e.currentTarget.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;
ContextualMenu.createMenu(TopLeftMenu, {
chevronFace: "none",
left: x,
top: y,
onFinished: () => {
this.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
}
}

View file

@ -82,7 +82,6 @@ const SIMPLE_SETTINGS = [
{ id: "VideoView.flipVideoHorizontally" },
{ id: "TagPanel.disableTagPanel" },
{ id: "enableWidgetScreenshots" },
{ id: "RoomSubList.showEmpty" },
{ id: "pinMentionedRooms" },
{ id: "pinUnreadRooms" },
{ id: "showDeveloperTools" },
@ -129,6 +128,7 @@ const CRYPTO_SETTINGS = [
const THEMES = [
{ label: _td('Light theme'), value: 'light' },
{ label: _td('Dark theme'), value: 'dark' },
{ label: _td('2018 theme'), value: 'dharma' },
{ label: _td('Status.im theme'), value: 'status' },
];
@ -384,32 +384,8 @@ module.exports = React.createClass({
},
onLogoutClicked: function(ev) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Logout E2E Export', '', QuestionDialog, {
title: _t("Sign out"),
description:
<div>
{ _t("For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.") }
</div>,
button: _t("Sign out"),
extraButtons: [
<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
dis.dispatch({action: 'logout'});
if (this.props.onFinished) {
this.props.onFinished();
}
}
},
});
const LogoutDialog = sdk.getComponent("dialogs.LogoutDialog");
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
},
onPasswordChangeError: function(err) {
@ -859,7 +835,7 @@ module.exports = React.createClass({
SettingsStore.getLabsFeatures().forEach((featureId) => {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = async(e) => {
const onChange = async (e) => {
const checked = e.target.checked;
if (featureId === "feature_lazyloading") {
const confirmed = await this._onLazyLoadChanging(checked);

View file

@ -162,6 +162,18 @@ module.exports = React.createClass({
this.setState(newState);
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
showErrorDialog: function(body, title) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
@ -253,10 +265,10 @@ module.exports = React.createClass({
</form>
{ serverConfigSection }
{ errorText }
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ _t('Return to login screen') }
</a>
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
<LanguageSelector />

View file

@ -214,7 +214,10 @@ module.exports = React.createClass({
}).done();
},
_onLoginAsGuestClick: function() {
_onLoginAsGuestClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
const self = this;
self.setState({
busy: true,
@ -297,6 +300,12 @@ module.exports = React.createClass({
});
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
@ -567,7 +576,7 @@ module.exports = React.createClass({
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }

View file

@ -363,6 +363,12 @@ module.exports = React.createClass({
}
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
_makeRegisterRequest: function(auth) {
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
@ -468,7 +474,7 @@ module.exports = React.createClass({
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);

View file

@ -40,38 +40,50 @@ export default class MemberStatusMessageAvatar extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
hasStatus: this.hasStatus,
};
}
componentWillMount() {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
}
}
componentDidMount() {
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
if (this.props.member.user) {
this.setState({message: this.props.member.user._unstable_statusMessage});
} else {
this.setState({message: ""});
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return;
}
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
_onRoomStateEvents = (ev, state) => {
if (ev.getStateKey() !== MatrixClientPeg.get().getUserId()) return;
if (ev.getType() !== "im.vector.user_status") return;
// TODO: We should be relying on `this.props.member.user._unstable_statusMessage`
// We don't currently because the js-sdk doesn't emit a specific event for this
// change, and we don't want to race it. This should be improved when we rip out
// the im.vector.user_status stuff and replace it with a complete solution.
this.setState({message: ev.getContent()["status"]});
componentWillUmount() {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
get hasStatus() {
const { user } = this.props.member;
if (!user) {
return false;
}
return !!user._unstable_statusMessage;
}
_onStatusMessageCommitted = () => {
// The `User` object has observed a status message change.
this.setState({
hasStatus: this.hasStatus,
});
};
_onClick = (e) => {
@ -79,42 +91,43 @@ export default class MemberStatusMessageAvatar extends React.Component {
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
const x = (elementRect.left + window.pageXOffset);
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronOffset = (elementRect.width - chevronWidth) / 2;
const chevronMargin = 1; // Add some spacing away from target
const y = elementRect.top + window.pageYOffset - chevronMargin;
ContextualMenu.createMenu(StatusMessageContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 190,
menuWidth: 226,
user: this.props.member.user,
});
};
render() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return <MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />;
}
const avatar = <MemberAvatar
member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod}
/>;
const hasStatus = this.props.member.user ? !!this.props.member.user._unstable_statusMessage : false;
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return avatar;
}
const classes = classNames({
"mx_MemberStatusMessageAvatar": true,
"mx_MemberStatusMessageAvatar_hasStatus": hasStatus,
"mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus,
});
return <AccessibleButton onClick={this._onClick} className={classes} element="div">
<MemberAvatar member={this.props.member}
width={this.props.width}
height={this.props.height}
resizeMethod={this.props.resizeMethod} />
return <AccessibleButton className={classes}
element="div" onClick={this._onClick}
>
{avatar}
</AccessibleButton>;
}
}

View file

@ -333,7 +333,7 @@ module.exports = React.createClass({
}
return (
<div>
<div className="mx_MessageContextMenu">
{ resendButton }
{ redactButton }
{ cancelButton }

View file

@ -243,7 +243,7 @@ module.exports = React.createClass({
});
return (
<div>
<div className="mx_RoomTileContextMenu">
<div className="mx_RoomTileContextMenu_notif_picker" >
<img src="img/notif-slider.svg" width="20" height="107" />
</div>

View file

@ -18,69 +18,125 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
// True when waiting for status change to complete.
waiting: false,
};
constructor(props, context) {
super(props, context);
this.state = {
message: props.user ? props.user._unstable_statusMessage : "",
message: this.comittedStatusMessage,
};
}
_onClearClick = async (e) => {
await MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({message: ""});
componentWillMount() {
const { user } = this.props;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
componentWillUmount() {
const { user } = this.props;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
get comittedStatusMessage() {
return this.props.user ? this.props.user._unstable_statusMessage : "";
}
_onStatusMessageCommitted = () => {
// The `User` object has observed a status message change.
this.setState({
message: this.comittedStatusMessage,
waiting: false,
});
};
_onClearClick = (e) => {
MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({
waiting: true,
});
};
_onSubmit = (e) => {
e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({
waiting: true,
});
};
_onStatusChange = (e) => {
this.setState({message: e.target.value});
// The input field's value was changed.
this.setState({
message: e.target.value,
});
};
render() {
const formSubmitClasses = classNames({
"mx_StatusMessageContextMenu_submit": true,
"mx_StatusMessageContextMenu_submitFaded": !this.state.message, // no message == faded
});
const Spinner = sdk.getComponent('views.elements.Spinner');
const form = <form className="mx_StatusMessageContextMenu_form" onSubmit={this._onSubmit} autoComplete="off">
<input type="text" key="message" placeholder={_t("Set a new status...")} autoFocus={true}
className="mx_StatusMessageContextMenu_message"
value={this.state.message} onChange={this._onStatusChange} maxLength="60" />
<AccessibleButton onClick={this._onSubmit} element="div" className={formSubmitClasses}>
<img src="img/icons-checkmark.svg" width="22" height="22" />
</AccessibleButton>
let actionButton;
if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick}
>
<span>{_t("Clear status")}</span>
</AccessibleButton>;
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit}
>
<span>{_t("Update status")}</span>
</AccessibleButton>;
}
} else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} onClick={this._onSubmit}
>
<span>{_t("Set status")}</span>
</AccessibleButton>;
}
let spinner = null;
if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />;
}
const form = <form className="mx_StatusMessageContextMenu_form"
autoComplete="off" onSubmit={this._onSubmit}
>
<input type="text" className="mx_StatusMessageContextMenu_message"
key="message" placeholder={_t("Set a new status...")}
autoFocus={true} maxLength="60" value={this.state.message}
onChange={this._onStatusChange}
/>
<div className="mx_StatusMessageContextMenu_actionContainer">
{actionButton}
{spinner}
</div>
</form>;
const clearIcon = this.state.message ? "img/cancel-red.svg" : "img/cancel.svg";
const clearButton = <AccessibleButton onClick={this._onClearClick} disabled={!this.state.message}
className="mx_StatusMessageContextMenu_clear">
<img src={clearIcon} alt={_t('Clear status')} width="12" height="12"
className="mx_filterFlipColor mx_StatusMessageContextMenu_clearIcon" />
<span>{_t("Clear status")}</span>
</AccessibleButton>;
const menuClasses = classNames({
"mx_StatusMessageContextMenu": true,
"mx_StatusMessageContextMenu_hasStatus": this.state.message,
});
return <div className={menuClasses}>
return <div className="mx_StatusMessageContextMenu">
{ form }
<hr />
{ clearButton }
</div>;
}
}

View file

@ -0,0 +1,54 @@
/*
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 dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import LogoutDialog from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
export class TopLeftMenu extends React.Component {
constructor() {
super();
this.openSettings = this.openSettings.bind(this);
this.signOut = this.signOut.bind(this);
}
render() {
return <div className="mx_TopLeftMenu">
<ul className="mx_TopLeftMenu_section">
<li onClick={this.openSettings}>{_t("Settings")}</li>
</ul>
<ul className="mx_TopLeftMenu_section">
<li onClick={this.signOut}>{_t("Sign out")}</li>
</ul>
</div>;
}
openSettings() {
dis.dispatch({action: 'view_user_settings'});
this.closeMenu();
}
signOut() {
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
this.closeMenu();
}
closeMenu() {
if (this.props.onFinished) this.props.onFinished();
}
}

View file

@ -389,6 +389,17 @@ module.exports = React.createClass({
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
const client = MatrixClientPeg.get();
const room = client.getRoom(result.room_id);
if (room) {
const tombstone = room.currentState.getStateEvents('m.room.tombstone', '');
if (tombstone && tombstone.getContent() && tombstone.getContent()["replacement_room"]) {
const replacementRoom = client.getRoom(tombstone.getContent()["replacement_room"]);
// Skip rooms with tombstones where we are also aware of the replacement room.
if (replacementRoom) return;
}
}
suggestedList.push({
addressType: 'mx-room-id',
address: result.room_id,

View file

@ -36,7 +36,7 @@ export default class ChangelogDialog extends React.Component {
for (let i=0; i<REPOS.length; i++) {
const oldVersion = version2[2*i];
const newVersion = version[2*i];
const url = `https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`;
const url = `https://riot.im/github/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`;
request(url, (err, response, body) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
this.setState({ [REPOS[i]]: response.statusText });

View file

@ -0,0 +1,164 @@
/*
Copyright 2018, 2019 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 Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component {
constructor() {
super();
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
}
_onSettingsLinkClick() {
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onExportE2eKeysClicked() {
Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
}
_onFinished(confirmed) {
if (confirmed) {
dis.dispatch({action: 'logout'});
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onSetRecoveryMethodClick() {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
);
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
_onLogoutConfirm() {
dis.dispatch({action: 'logout'});
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
}
render() {
let description;
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
description = <div>
<p>{_t(
"When you log out, you'll lose your secure message history. To prevent " +
"this, set up a recovery method.",
)}</p>
<p>{_t(
"Alternatively, advanced users can also manually export encryption keys in " +
"<a>Settings</a> before logging out.", {},
{
a: sub => <a href='#/settings' onClick={this._onSettingsLinkClick}>{sub}</a>,
},
)}</p>
</div>;
} else {
description = <div>{_t(
"For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.",
)}</div>;
}
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("Warning!")}
contentId='mx_Dialog_content'
hasCancel={false}
onFinsihed={this._onFinished}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ description }
</div>
<DialogButtons primaryButton={_t('Set a Recovery Method')}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
{_t("I understand, log out without")}
</button>
</DialogButtons>
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
// TODO: This is made up by me and would need to also mention verifying
// once you can restorew a backup by verifying a device
description={_t(
"When signing in again, you can access encrypted chat history by " +
"restoring your key backup. You'll need your recovery key.",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
/>);
}
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
description={description}
button={_t("Sign out")}
extraButtons={[
(<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>),
]}
onFinished={this._onFinished}
/>);
}
}
}

View file

@ -0,0 +1,51 @@
/*
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 QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
"?q=is%3Aopen+is%3Aissue+label%3Aredesign+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new" +
"?assignees=&labels=redesign&template=redesign_issue.md&title=";
const description1 =
_t("Thanks for testing the Riot Redesign. " +
"If you run into any bugs or visual issues, " +
"please let us know on GitHub.");
const description2 = _t("To help avoid duplicate issues, " +
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +
"if you can't find it.", {},
{
existingIssuesLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={existingIssuesUrl}>{ sub }</a>;
},
newIssueLink: (sub) => {
return <a target="_blank" rel="noreferrer noopener" href={newIssueUrl}>{ sub }</a>;
},
});
return (<QuestionDialog
hasCancelButton={false}
title={_t("Report bugs & give feedback")}
description={<div><p>{description1}</p><p>{description2}</p></div>}
button={_t("Go back")}
onFinished={props.onFinished}
/>);
};

View file

@ -30,7 +30,8 @@ export default React.createClass({
action: PropTypes.string.isRequired,
mouseOverAction: PropTypes.string,
label: PropTypes.string.isRequired,
iconPath: PropTypes.string.isRequired,
iconPath: PropTypes.string,
className: PropTypes.string,
},
getDefaultProps: function() {
@ -72,14 +73,23 @@ export default React.createClass({
tooltip = <RoomTooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
}
const icon = this.props.iconPath ?
(<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />) :
undefined;
const classNames = ["mx_RoleButton"];
if (this.props.className) {
classNames.push(this.props.className);
}
return (
<AccessibleButton className="mx_RoleButton"
<AccessibleButton className={classNames.join(" ")}
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
aria-label={this.props.label}
>
<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />
{ icon }
{ tooltip }
</AccessibleButton>
);

View file

@ -22,18 +22,16 @@ import { _t } from '../../../languageHandler';
const GroupsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_my_groups"
<ActionButton className="mx_GroupsButton" action="view_my_groups"
label={_t("Communities")}
iconPath="img/icons-groups.svg"
size={props.size}
tooltip={props.tooltip}
tooltip={true}
/>
);
};
GroupsButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default GroupsButton;

View file

@ -91,7 +91,7 @@ export default class ManageIntegsButton extends React.Component {
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses} onClick={this.onManageIntegrations} title={_t('Manage Integrations')}>
<TintableSvg src="img/icons-apps.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/grid.svg" width="20" height="20" />
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>

View file

@ -271,7 +271,7 @@ const Pill = React.createClass({
break;
}
const classes = classNames(pillClass, {
const classes = classNames("mx_Pill", pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
"mx_UserPill_selected": this.props.isSelected,
});

View file

@ -0,0 +1,27 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
//see src/resizer for the actual resizing code, this is just the DOM for the resize handle
const ResizeHandle = (props) => {
const classNames = ['mx_ResizeHandle'];
if (props.vertical) {
classNames.push('mx_ResizeHandle_vertical');
} else {
classNames.push('mx_ResizeHandle_horizontal');
}
if (props.reverse) {
classNames.push('mx_ResizeHandle_reverse');
}
return (
<div className={classNames.join(' ')} data-id={props.id}><div /></div>
);
};
ResizeHandle.propTypes = {
vertical: PropTypes.bool,
reverse: PropTypes.bool,
id: PropTypes.string,
};
export default ResizeHandle;

View file

@ -157,7 +157,7 @@ export default React.createClass({
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
const profile = this.state.profile || {};
const name = profile.name || this.props.tag;
const avatarHeight = 35;
const avatarHeight = 40;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",

View file

@ -29,7 +29,6 @@ var TintableSvg = React.createClass({
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
className: PropTypes.string,
forceColors: PropTypes.arrayOf(PropTypes.string),
},
statics: {
@ -51,12 +50,6 @@ var TintableSvg = React.createClass({
delete TintableSvg.mounts[this.id];
},
componentDidUpdate: function(prevProps, prevState) {
if (prevProps.forceColors !== this.props.forceColors) {
this.calcAndApplyFixups(this.refs.svgContainer);
}
},
tint: function() {
// TODO: only bother running this if the global tint settings have changed
// since we loaded!
@ -64,13 +57,8 @@ var TintableSvg = React.createClass({
},
onLoad: function(event) {
this.calcAndApplyFixups(event.target);
},
calcAndApplyFixups: function(target) {
if (!target) return;
// console.log("TintableSvg.calcAndApplyFixups for " + this.props.src);
this.fixups = Tinter.calcSvgFixups([target], this.props.forceColors);
// console.log("TintableSvg.onLoad for " + this.props.src);
this.fixups = Tinter.calcSvgFixups([event.target]);
Tinter.applySvgFixups(this.fixups);
},
@ -83,7 +71,6 @@ var TintableSvg = React.createClass({
height={this.props.height}
onLoad={this.onLoad}
tabIndex="-1"
ref="svgContainer"
/>
);
},

View file

@ -17,8 +17,13 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
import { showGroupInviteDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
import RightPanel from '../../structures/RightPanel';
const INITIAL_LOAD_NUM_MEMBERS = 30;
@ -154,6 +159,16 @@ export default React.createClass({
</TruncatedList>;
},
onInviteToGroupButtonClick() {
showGroupInviteDialog(this.props.groupId).then(() => {
dis.dispatch({
action: 'view_right_panel_phase',
phase: RightPanel.Phase.GroupMemberList,
groupId: this.props.groupId,
});
});
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.fetching || this.state.fetchingInvitedMembers) {
@ -164,11 +179,9 @@ export default React.createClass({
}
const inputBox = (
<form autoComplete="off">
<input className="mx_GroupMemberList_query" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} />
</form>
<input className="mx_GroupMemberList_query mx_textinput" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community members')} autoComplete="off" />
);
const joined = this.state.members ? <div className="mx_MemberList_joined">
@ -192,13 +205,29 @@ export default React.createClass({
)
}
</div> : <div />;
let inviteButton;
if (GroupStore.isUserPrivileged(this.props.groupId)) {
inviteButton = (
<AccessibleButton
className="mx_RightPanel_invite"
onClick={this.onInviteToGroupButtonClick}
>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icon-invite-people.svg" width="18" height="14" />
</div>
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
</AccessibleButton>);
}
return (
<div className="mx_MemberList">
{ inputBox }
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
{ joined }
{ invited }
</GeminiScrollbarWrapper>
{ inputBox }
</div>
);
},

View file

@ -18,6 +18,9 @@ import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import GroupStore from '../../../stores/GroupStore';
import PropTypes from 'prop-types';
import { showGroupAddRoomDialog } from '../../../GroupAddressPicker';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
const INITIAL_LOAD_NUM_ROOMS = 30;
@ -90,6 +93,12 @@ export default React.createClass({
this.setState({ searchQuery: ev.target.value });
},
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
},
makeGroupRoomTiles: function(query) {
const GroupRoomTile = sdk.getComponent("groups.GroupRoomTile");
query = (query || "").toLowerCase();
@ -120,25 +129,38 @@ export default React.createClass({
return null;
}
let inviteButton;
if (GroupStore.isUserPrivileged(this.props.groupId)) {
inviteButton = (
<AccessibleButton
className="mx_RightPanel_invite"
onClick={this.onAddRoomToGroupButtonClick}
>
<div className="mx_RightPanel_icon" >
<TintableSvg src="img/icons-room-add.svg" width="18" height="14" />
</div>
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
</AccessibleButton>
);
}
const inputBox = (
<form autoComplete="off">
<input className="mx_GroupRoomList_query" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} />
</form>
<input className="mx_GroupRoomList_query mx_textinput" id="mx_GroupRoomList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter community rooms')} autoComplete="off" />
);
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return (
<div className="mx_GroupRoomList">
{ inputBox }
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true} className="mx_GroupRoomList_joined mx_GroupRoomList_outerWrapper">
<TruncatedList className="mx_GroupRoomList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{ this.makeGroupRoomTiles(this.state.searchQuery) }
</TruncatedList>
</GeminiScrollbarWrapper>
{ inputBox }
</div>
);
},

View file

@ -56,6 +56,6 @@ export default class DateSeparator extends React.Component {
}
render() {
return <h2 className="mx_DateSeparator">{ this.getLabel() }</h2>;
return <h2 className="mx_DateSeparator"><hr /><date>{ this.getLabel() }</date><hr /></h2>;
}
}

View file

@ -23,6 +23,7 @@ import sdk from '../../../index';
import Flair from '../elements/Flair.js';
import FlairStore from '../../../stores/FlairStore';
import { _t } from '../../../languageHandler';
import {hashCode} from '../../../utils/FormattingUtils';
export default React.createClass({
displayName: 'SenderProfile',
@ -96,6 +97,7 @@ export default React.createClass({
render() {
const EmojiText = sdk.getComponent('elements.EmojiText');
const {mxEvent} = this.props;
const colorNumber = hashCode(mxEvent.getSender()) % 8;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const {msgtype} = mxEvent.getContent();
@ -119,7 +121,7 @@ export default React.createClass({
// Name + flair
const nameFlair = <span>
<span className="mx_SenderProfile_name">
<span className={`mx_SenderProfile_name mx_SenderProfile_color${colorNumber}`}>
{ nameElem }
</span>
{ flair }

View file

@ -0,0 +1,80 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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 { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import HeaderButton from './HeaderButton';
import HeaderButtons from './HeaderButtons';
import RightPanel from '../../structures/RightPanel';
export default class GroupHeaderButtons extends HeaderButtons {
constructor(props) {
super(props, RightPanel.Phase.GroupMemberList);
}
onAction(payload) {
super.onAction(payload);
if (payload.action === "view_user") {
dis.dispatch({
action: 'show_right_panel',
});
if (payload.member) {
this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member});
} else {
this.setPhase(RightPanel.Phase.GroupMemberList);
}
} else if (payload.action === "view_group") {
this.setPhase(RightPanel.Phase.GroupMemberList);
} else if (payload.action === "view_group_room") {
this.setPhase(RightPanel.Phase.GroupRoomInfo, {groupRoomId: payload.groupRoomId, groupId: payload.groupId});
} else if (payload.action === "view_group_room_list") {
this.setPhase(RightPanel.Phase.GroupRoomList);
} else if (payload.action === "view_group_member_list") {
this.setPhase(RightPanel.Phase.GroupMemberList);
} else if (payload.action === "view_group_user") {
this.setPhase(RightPanel.Phase.GroupMemberInfo, {member: payload.member});
}
}
renderButtons() {
const groupPhases = [
RightPanel.Phase.GroupMemberInfo,
RightPanel.Phase.GroupMemberList,
];
const roomPhases = [
RightPanel.Phase.GroupRoomList,
RightPanel.Phase.GroupRoomInfo,
];
return [
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
isHighlighted={this.isPhase(groupPhases)}
clickPhase={RightPanel.Phase.GroupMemberList}
analytics={['Right Panel', 'Group Member List Button', 'click']}
/>,
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room-nobg.svg"
isHighlighted={this.isPhase(roomPhases)}
clickPhase={RightPanel.Phase.GroupRoomList}
analytics={['Right Panel', 'Group Room List Button', 'click']}
/>,
];
}
}

View file

@ -0,0 +1,75 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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 classNames from 'classnames';
import dis from '../../../dispatcher';
import Analytics from '../../../Analytics';
import AccessibleButton from '../elements/AccessibleButton';
import TintableSvg from '../elements/TintableSvg';
export default class HeaderButton extends React.Component {
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(ev) {
Analytics.trackEvent(...this.props.analytics);
dis.dispatch({
action: 'view_right_panel_phase',
phase: this.props.clickPhase,
fromHeader: true,
});
}
render() {
const classes = classNames({
mx_RightPanel_headerButton: true,
mx_RightPanel_headerButton_highlight: this.props.isHighlighted,
});
return <AccessibleButton
aria-label={this.props.title}
aria-expanded={this.props.isHighlighted}
title={this.props.title}
className={classes}
onClick={this.onClick} >
<TintableSvg src={this.props.iconSrc} width="20" height="20" />
</AccessibleButton>;
}
}
HeaderButton.propTypes = {
// Whether this button is highlighted
isHighlighted: PropTypes.bool.isRequired,
// The phase to swap to when the button is clicked
clickPhase: PropTypes.string.isRequired,
// The source file of the icon to display
iconSrc: PropTypes.string.isRequired,
// The badge to display above the icon
badge: PropTypes.node,
// The parameters to track the click event
analytics: PropTypes.arrayOf(PropTypes.string).isRequired,
// Button title
title: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,100 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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 dis from '../../../dispatcher';
export default class HeaderButtons extends React.Component {
constructor(props, initialPhase) {
super(props);
this.state = {
phase: props.collapsedRhs ? null : initialPhase,
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
setPhase(phase, extras) {
// TODO: delay?
dis.dispatch(Object.assign({
action: 'view_right_panel_phase',
phase: phase,
}, extras));
}
isPhase(phases) {
if (this.props.collapsedRhs) {
return false;
}
if (Array.isArray(phases)) {
return phases.includes(this.state.phase);
} else {
return phases === this.state.phase;
}
}
onAction(payload) {
if (payload.action === "view_right_panel_phase") {
// only actions coming from header buttons should collapse the right panel
if (this.state.phase === payload.phase && payload.fromHeader) {
dis.dispatch({
action: 'hide_right_panel',
});
this.setState({
phase: null,
});
} else {
if (this.props.collapsedRhs && payload.fromHeader) {
dis.dispatch({
action: 'show_right_panel',
});
// emit payload again as the RightPanel didn't exist up
// till show_right_panel, just without the fromHeader flag
// as that would hide the right panel again
dis.dispatch(Object.assign({}, payload, {fromHeader: false}));
}
this.setState({
phase: payload.phase,
});
}
}
}
render() {
// inline style as this will be swapped around in future commits
return <div style={{display: 'flex'}}>
{ this.renderButtons() }
</div>;
}
}
HeaderButtons.propTypes = {
collapsedRhs: PropTypes.bool,
};

View file

@ -0,0 +1,72 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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 { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import HeaderButton from './HeaderButton';
import HeaderButtons from './HeaderButtons';
import RightPanel from '../../structures/RightPanel';
export default class RoomHeaderButtons extends HeaderButtons {
constructor(props) {
super(props, RightPanel.Phase.RoomMemberList);
}
onAction(payload) {
super.onAction(payload);
if (payload.action === "view_user") {
dis.dispatch({
action: 'show_right_panel',
});
if (payload.member) {
this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member});
} else {
this.setPhase(RightPanel.Phase.RoomMemberList);
}
} else if (payload.action === "view_room") {
this.setPhase(RightPanel.Phase.RoomMemberList);
}
}
renderButtons() {
const membersPhases = [
RightPanel.Phase.RoomMemberList,
RightPanel.Phase.RoomMemberInfo,
];
return [
<HeaderButton key="_membersButton" title={_t('Members')} iconSrc="img/feather-icons/user.svg"
isHighlighted={this.isPhase(membersPhases)}
clickPhase={RightPanel.Phase.RoomMemberList}
analytics={['Right Panel', 'Member List Button', 'click']}
/>,
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/feather-icons/files.svg"
isHighlighted={this.isPhase(RightPanel.Phase.FilePanel)}
clickPhase={RightPanel.Phase.FilePanel}
analytics={['Right Panel', 'File List Button', 'click']}
/>,
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/feather-icons/notifications.svg"
isHighlighted={this.isPhase(RightPanel.Phase.NotificationPanel)}
clickPhase={RightPanel.Phase.NotificationPanel}
analytics={['Right Panel', 'Notification List Button', 'click']}
/>,
];
}
}

View file

@ -190,6 +190,10 @@ module.exports = React.createClass({
/>);
});
if (apps.length == 0) {
return <div></div>;
}
let addWidget;
if (this.props.showApps &&
this._canUserModify()

View file

@ -23,6 +23,9 @@ import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t } from '../../../languageHandler';
import classNames from 'classnames';
import RateLimitedFunc from '../../../ratelimitedfunc';
import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
@ -51,6 +54,7 @@ module.exports = React.createClass({
// a callback which is called when the content of the aux panel changes
// content in a way that is likely to make it change size.
onResize: PropTypes.func,
fullHeight: PropTypes.bool,
},
defaultProps: {
@ -58,6 +62,22 @@ module.exports = React.createClass({
hideAppsDrawer: false,
},
getInitialState: function() {
return { counters: this._computeCounters() };
},
componentDidMount: function() {
const cli = MatrixClientPeg.get();
cli.on("RoomState.events", this._rateLimitedUpdate);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this._rateLimitedUpdate);
}
},
shouldComponentUpdate: function(nextProps, nextState) {
return (!ObjectUtils.shallowEqual(this.props, nextProps) ||
!ObjectUtils.shallowEqual(this.state, nextState));
@ -80,6 +100,43 @@ module.exports = React.createClass({
ev.preventDefault();
},
_rateLimitedUpdate: new RateLimitedFunc(function() {
if (SettingsStore.isFeatureEnabled("feature_state_counters")) {
this.setState({counters: this._computeCounters()});
}
}, 500),
_computeCounters: function() {
let counters = [];
if (this.props.room && SettingsStore.isFeatureEnabled("feature_state_counters")) {
const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter');
stateEvs.sort((a, b) => {
return a.getStateKey() < b.getStateKey();
});
stateEvs.forEach((ev, idx) => {
const title = ev.getContent().title;
const value = ev.getContent().value;
const link = ev.getContent().link;
const severity = ev.getContent().severity || "normal";
const stateKey = ev.getStateKey();
if (title && value && severity) {
counters.push({
"title": title,
"value": value,
"link": link,
"severity": severity,
"stateKey": stateKey
})
}
});
}
return counters;
},
render: function() {
const CallView = sdk.getComponent("voip.CallView");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -143,8 +200,70 @@ module.exports = React.createClass({
hide={this.props.hideAppsDrawer}
/>;
let stateViews = null;
if (this.state.counters && SettingsStore.isFeatureEnabled("feature_state_counters")) {
let counters = [];
this.state.counters.forEach((counter, idx) => {
const title = counter.title;
const value = counter.value;
const link = counter.link;
const severity = counter.severity;
const stateKey = counter.stateKey;
if (title && value && severity) {
let span = <span>{ title }: { value }</span>
if (link) {
span = (
<a href={link} target="_blank" rel="noopener">
{ span }
</a>
);
}
span = (
<span
className="m_RoomView_auxPanel_stateViews_span"
data-severity={severity}
key={ "x-" + stateKey }
>
{span}
</span>
);
counters.push(span);
counters.push(
<span
className="m_RoomView_auxPanel_stateViews_delim"
key={"delim" + idx}
> </span>
);
}
});
if (counters.length > 0) {
counters.pop(); // remove last deliminator
stateViews = (
<div className="m_RoomView_auxPanel_stateViews">
{ counters }
</div>
);
}
}
const classes = classNames({
"mx_RoomView_auxPanel": true,
"mx_RoomView_auxPanel_fullHeight": this.props.fullHeight,
});
const style = {};
if (!this.props.fullHeight) {
style.maxHeight = this.props.maxHeight;
}
return (
<div className="mx_RoomView_auxPanel" style={{maxHeight: this.props.maxHeight}} >
<div className={classes} style={style} >
{ stateViews }
{ appsDrawer }
{ fileDropTarget }
{ callView }

View file

@ -1,65 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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 { Draggable } from 'react-beautiful-dnd';
import RoomTile from '../../../components/views/rooms/RoomTile';
import classNames from 'classnames';
export default class DNDRoomTile extends React.PureComponent {
constructor() {
super();
this.getClassName = this.getClassName.bind(this);
}
getClassName(isDragging) {
return classNames({
"mx_DNDRoomTile": true,
"mx_DNDRoomTile_dragging": isDragging,
});
}
render() {
const props = this.props;
return <div>
<Draggable
key={props.room.roomId}
draggableId={props.tagName + '_' + props.room.roomId}
index={props.index}
type="draggable-RoomTile"
>
{ (provided, snapshot) => {
return (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={this.getClassName(snapshot.isDragging)}>
<RoomTile {...props} />
</div>
</div>
{ provided.placeholder }
</div>
);
} }
</Draggable>
</div>;
}
}

View file

@ -135,7 +135,6 @@ const EntityTile = React.createClass({
}
nameEl = (
<div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12" />
<EmojiText element="div" className={nameClasses} dir="auto">
{ name }
</EmojiText>

View file

@ -62,6 +62,7 @@ const stateEventTileTypes = {
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
'm.room.tombstone': 'messages.TextualEvent',
};
function getHandlerTile(ev) {

View file

@ -714,7 +714,7 @@ module.exports = withMatrixClient(React.createClass({
if (!member || !member.membership || member.membership === 'leave') {
const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId();
const onInviteUserButton = async() => {
const onInviteUserButton = async () => {
try {
// We use a MultiInviter to re-use the invite logic, even though
// we're only inviting one user.
@ -947,38 +947,49 @@ module.exports = withMatrixClient(React.createClass({
</div>;
}
const avatarUrl = this.props.member.getMxcAvatarUrl();
let avatarElement;
if (avatarUrl) {
const httpUrl = this.props.matrixClient.mxcUrlToHttp(avatarUrl, 800, 800);
avatarElement = <div className="mx_MemberInfo_avatar">
<img src={httpUrl} />
</div>
}
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<div className="mx_MemberInfo">
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
<div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src="img/minimise.svg" width="10" height="16" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
<EmojiText element="h2">{ memberName }</EmojiText>
</div>
{ avatarElement }
<div className="mx_MemberInfo_container">
<EmojiText element="h2">{ memberName }</EmojiText>
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{ this.props.member.userId }
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{ this.props.member.userId }
</div>
{ roomMemberDetails }
</div>
{ roomMemberDetails }
</div>
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberInfo_scrollContainer">
<div className="mx_MemberInfo_container">
{ this._renderUserOptions() }
{ this._renderUserOptions() }
{ adminTools }
{ adminTools }
{ startChat }
{ startChat }
{ this._renderDevices() }
{ this._renderDevices() }
{ spinner }
</GeminiScrollbarWrapper>
{ spinner }
</div>
</GeminiScrollbarWrapper>
</div>
);
},

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
const MatrixClientPeg = require("../../../MatrixClientPeg");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
@ -67,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);
},
@ -80,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
@ -131,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
}
@ -180,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,
@ -188,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 [];
@ -266,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>") + ")";
}
},
@ -274,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) {
@ -420,42 +441,59 @@ module.exports = React.createClass({
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let invitedSection = null;
if (this._getChildCountInvited() > 0) {
invitedSection = (
<div className="mx_MemberList_invited">
<h2>{ _t("Invited") }</h2>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>
</div>
</div>
);
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
if (room && room.getMyMembership() === 'join') {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick}>
<span>{ _t('Invite to this room') }</span>
</AccessibleButton>;
}
const inputBox = (
<form autoComplete="off">
<input className="mx_MemberList_query" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
</form>
);
let invitedHeader;
let invitedSection;
if (this._getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
}
return (
<div className="mx_MemberList">
{ inputBox }
<GeminiScrollbarWrapper autoshow={true} className="mx_MemberList_joined">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAtJoined}
{ inviteButton }
<GeminiScrollbarWrapper autoshow={true}>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined}
/>
{ invitedSection }
getChildCount={this._getChildCountJoined} />
{ invitedHeader }
{ invitedSection }
</div>
</GeminiScrollbarWrapper>
<input className="mx_MemberList_query mx_textinput_icon mx_textinput_search" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
</div>
);
},
onInviteButtonClick: function() {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
// call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.props.roomId,
});
},
});

View file

@ -21,10 +21,8 @@ import SettingsStore from "../../../settings/SettingsStore";
const React = require('react');
import PropTypes from 'prop-types';
const MatrixClientPeg = require('../../../MatrixClientPeg');
const sdk = require('../../../index');
const dis = require('../../../dispatcher');
const Modal = require("../../../Modal");
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
@ -42,7 +40,46 @@ module.exports = React.createClass({
},
getInitialState: function() {
return {};
return {
statusMessage: this.getStatusMessage(),
};
},
componentDidMount() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return;
}
const { user } = this.props.member;
if (!user) {
return;
}
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
},
componentWillUmount() {
const { user } = this.props.member;
if (!user) {
return;
}
user.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
},
getStatusMessage() {
const { user } = this.props.member;
if (!user) {
return "";
}
return user._unstable_statusMessage;
},
_onStatusMessageCommitted() {
// The `User` object has observed a status message change.
this.setState({
statusMessage: this.getStatusMessage(),
});
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -74,22 +111,23 @@ module.exports = React.createClass({
},
getPowerLabel: function() {
return _t("%(userName)s (power %(powerLevelNumber)s)", {userName: this.props.member.userId, powerLevelNumber: this.props.member.powerLevel});
return _t("%(userName)s (power %(powerLevelNumber)s)", {
userName: this.props.member.userId,
powerLevelNumber: this.props.member.powerLevel,
});
},
render: function() {
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EntityTile = sdk.getComponent('rooms.EntityTile');
const member = this.props.member;
const name = this._getDisplayName();
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;
statusMessage = this.state.statusMessage;
}
const av = (

View file

@ -282,10 +282,21 @@ export default class MessageComposer extends React.Component {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId);
let createEventId = null;
if (replacementRoom) {
const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', '');
if (createEvent && createEvent.getId()) createEventId = createEvent.getId();
}
dis.dispatch({
action: 'view_room',
highlighted: true,
event_id: createEventId,
room_id: replacementRoomId,
// Try to join via the server that sent the event. This converts $something:example.org
// into a server domain by splitting on colons and ignoring the first entry ("$something").
via_servers: [this.state.tombstone.getId().split(':').splice(1).join(':')],
});
}
@ -333,16 +344,16 @@ export default class MessageComposer extends React.Component {
if (this.props.callState && this.props.callState !== 'ended') {
hangupButton =
<AccessibleButton key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
<img src="img/hangup.svg" alt={_t('Hangup')} title={_t('Hangup')} width="25" height="26" />
<img src="img/hangup.svg" alt={_t('Hangup')} title={_t('Hangup')} width="25" height="25" />
</AccessibleButton>;
} else {
callButton =
<AccessibleButton key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title={_t('Voice call')}>
<TintableSvg src="img/icon-call.svg" width="35" height="35" />
<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="35" height="35" />
<TintableSvg src="img/feather-icons/video.svg" width="20" height="20" />
</AccessibleButton>;
}
@ -384,7 +395,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="35" height="35" />
<TintableSvg src="img/feather-icons/paperclip.svg" width="20" height="20" />
<input ref="uploadInput" type="file"
style={uploadInputStyle}
multiple

View file

@ -15,13 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import { Editor } from 'slate-react';
import { getEventTransfer } from 'slate-react';
import { Value, Document, Block, Inline, Text, Range, Node } from 'slate';
import { Value, Block, Inline, Range } from 'slate';
import type { Change } from 'slate';
import Html from 'slate-html-serializer';
@ -30,7 +28,6 @@ import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
import classNames from 'classnames';
import Promise from 'bluebird';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -38,7 +35,7 @@ import {processCommandInput} from '../../../SlashCommands';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import Analytics from '../../../Analytics';
import dis from '../../../dispatcher';
@ -51,10 +48,12 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
import {
asciiRegexp, unicodeRegexp, shortnameToUnicode,
asciiList, mapUnicodeToShort, toShort,
} from 'emojione';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {makeUserPermalink} from "../../../matrix-to";
import ReplyPreview from "./ReplyPreview";
@ -62,17 +61,12 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g');
const TYPING_USER_TIMEOUT = 10000; const TYPING_SERVER_TIMEOUT = 30000;
const ENTITY_TYPES = {
AT_ROOM_PILL: 'ATROOMPILL',
};
// the Slate node type to default to for unstyled text
const DEFAULT_NODE = 'paragraph';
@ -357,7 +351,6 @@ export default class MessageComposerInput extends React.Component {
}
onAction = (payload) => {
const editor = this._editor;
const editorState = this.state.editorState;
switch (payload.action) {
@ -854,7 +847,7 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const newState: ?Value = null;
//const newState: ?Value = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if (this.state.isRichTextEnabled) {
@ -1105,7 +1098,9 @@ export default class MessageComposerInput extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Server error', '', ErrorDialog, {
title: _t("Server error"),
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
description: ((err && err.message) ? err.message : _t(
"Server unavailable, overloaded, or something else went wrong.",
)),
});
});
} else if (cmd.error) {
@ -1260,7 +1255,7 @@ export default class MessageComposerInput extends React.Component {
}
};
selectHistory = async(up) => {
selectHistory = async (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
@ -1308,7 +1303,7 @@ export default class MessageComposerInput extends React.Component {
return true;
};
onTab = async(e) => {
onTab = async (e) => {
this.setState({
someCompletions: null,
});
@ -1330,7 +1325,7 @@ export default class MessageComposerInput extends React.Component {
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
};
onEscape = async(e) => {
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
@ -1349,7 +1344,7 @@ export default class MessageComposerInput extends React.Component {
/* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/
setDisplayedCompletion = async(displayedCompletion: ?Completion): boolean => {
setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
@ -1484,7 +1479,9 @@ export default class MessageComposerInput extends React.Component {
});
const style = {};
if (props.selected) style.border = '1px solid blue';
return <img className={ className } src={ uri } title={ shortname } alt={ emojiUnicode } style={style} />;
return <img className={ className } src={ uri }
title={ shortname } alt={ emojiUnicode } style={style}
/>;
}
}
};
@ -1538,7 +1535,6 @@ export default class MessageComposerInput extends React.Component {
getSelectionRange(editorState: Value) {
let beginning = false;
const query = this.getAutocompleteQuery(editorState);
const firstChild = editorState.document.nodes.get(0);
const firstGrandChild = firstChild && firstChild.nodes.get(0);
beginning = (firstChild && firstGrandChild &&

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';
@ -33,6 +32,7 @@ import AccessibleButton from '../elements/AccessibleButton';
import ManageIntegsButton from '../elements/ManageIntegsButton';
import {CancelButton} from './SimpleRoomHeader';
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
linkifyMatrix(linkify);
@ -145,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, {
@ -302,18 +298,17 @@ module.exports = React.createClass({
topic = ev.getContent().topic;
}
}
if (topic) {
topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
}
topicElement =
<div className="mx_RoomHeader_topic" ref="topic" title={topic} dir="auto">{ topic }</div>;
}
let roomAvatar = null;
const avatarSize = 28;
if (canSetRoomAvatar) {
roomAvatar = (
<div className="mx_RoomHeader_avatarPicker">
<div onClick={this.onAvatarPickerClick}>
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={48} height={48} />
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={avatarSize} height={avatarSize} />
</div>
<div className="mx_RoomHeader_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
@ -334,7 +329,7 @@ module.exports = React.createClass({
);
} else if (this.props.room || (this.props.oobData && this.props.oobData.name)) {
roomAvatar = (
<RoomAvatar room={this.props.room} width={48} height={48} oobData={this.props.oobData}
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} oobData={this.props.oobData}
viewAvatarOnClick={true} />
);
}
@ -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="35" height="35" />
<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" 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>;
}
@ -412,33 +399,27 @@ module.exports = React.createClass({
if (!this.props.editing) {
rightRow =
<div className="mx_RoomHeader_rightRow">
<div className="mx_RoomHeader_buttons">
{ settingsButton }
{ pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton }
{ forgetButton }
{ searchButton }
{ rightPanelButtons }
</div>;
}
return (
<div className={"mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "")}>
<div className={"mx_RoomHeader light-panel " + (this.props.editing ? "mx_RoomHeader_editing" : "")}>
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_leftRow">
<div className="mx_RoomHeader_avatar">
{ roomAvatar }
</div>
<div className="mx_RoomHeader_info">
{ name }
{ topicElement }
</div>
</div>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ name }
{ topicElement }
{ spinner }
{ saveButton }
{ cancelButton }
{ rightRow }
<RoomHeaderButtons collapsedRhs={this.props.collapsedRhs} />
</div>
</div>
);

View file

@ -33,7 +33,10 @@ const Receipt = require('../../../utils/Receipt');
import TagOrderStore from '../../../stores/TagOrderStore';
import RoomListStore from '../../../stores/RoomListStore';
import GroupStore from '../../../stores/GroupStore';
import RoomSubList from '../../structures/RoomSubList';
import ResizeHandle from '../elements/ResizeHandle';
import {Resizer, RoomSubListDistributor} from '../../../resizer'
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
@ -67,6 +70,15 @@ 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) : {};
this.collapsedState = collapsedJson ? JSON.parse(collapsedJson) : {};
return {
isLoadingLeftRooms: false,
totalRoomCount: null,
@ -74,6 +86,7 @@ module.exports = React.createClass({
incomingCallTag: null,
incomingCall: null,
selectedTags: [],
hover: false,
};
},
@ -89,6 +102,7 @@ module.exports = React.createClass({
cli.on("Event.decrypted", this.onEventDecrypted);
cli.on("accountData", this.onAccountData);
cli.on("Group.myMembership", this._onGroupMyMembership);
cli.on("RoomState.events", this.onRoomStateEvents);
const dmRoomMap = DMRoomMap.shared();
// A map between tags which are group IDs and the room IDs of rooms that should be kept
@ -132,18 +146,54 @@ module.exports = React.createClass({
this._delayedRefreshRoomListLoopCount = 0;
},
_onSubListResize: function(newSize, id) {
if (!id) {
return;
}
if (typeof newSize === "string") {
newSize = Number.MAX_SAFE_INTEGER;
}
if (newSize === null) {
delete this.subListSizes[id];
} else {
this.subListSizes[id] = newSize;
}
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.subListSizes));
// update overflow indicators
this._checkSubListsOverflow();
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
// Initialise the stickyHeaders when the component is created
this._updateStickyHeaders(true);
const cfg = {
onResized: this._onSubListResize,
};
this.resizer = new Resizer(this.resizeContainer, RoomSubListDistributor, cfg);
this.resizer.setClassNames({
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
});
// load stored sizes
Object.keys(this.subListSizes).forEach((key) => {
this._restoreSubListSize(key);
});
this._checkSubListsOverflow();
this.resizer.attach();
this.mounted = true;
},
componentDidUpdate: function() {
// Reinitialise the stickyHeaders when the component is updated
this._updateStickyHeaders(true);
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) {
@ -181,6 +231,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
if (this._tagStoreToken) {
@ -204,6 +255,12 @@ module.exports = React.createClass({
this.updateVisibleRooms();
},
onRoomStateEvents: function(ev, state) {
if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") {
this.updateVisibleRooms();
}
},
onDeleteRoom: function(roomId) {
this.updateVisibleRooms();
},
@ -212,10 +269,6 @@ module.exports = React.createClass({
if (!isHidden) {
const self = this;
this.setState({ isLoadingLeftRooms: true });
// Try scrolling to position
this._updateStickyHeaders(true, scrollToPosition);
// we don't care about the response since it comes down via "Room"
// events.
MatrixClientPeg.get().syncLeftRooms().catch(function(err) {
@ -227,11 +280,6 @@ module.exports = React.createClass({
}
},
onSubListHeaderClick: function(isHidden, scrollToPosition) {
// The scroll area has expanded or contracted, so re-calculate sticky headers positions
this._updateStickyHeaders(true, scrollToPosition);
},
onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us
@ -259,6 +307,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),
@ -311,6 +370,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.
@ -326,6 +390,11 @@ 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();
@ -401,7 +470,6 @@ module.exports = React.createClass({
_whenScrolling: function(e) {
this._hideTooltip(e);
this._repositionIncomingCallBox(e, false);
this._updateStickyHeaders(false);
},
_hideTooltip: function(e) {
@ -435,169 +503,6 @@ module.exports = React.createClass({
}
},
// Doing the sticky headers as raw DOM, for speed, as it gets very stuttery if done
// properly through React
_initAndPositionStickyHeaders: function(initialise, scrollToPosition) {
const scrollArea = this._getScrollNode();
if (!scrollArea) return;
// Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the componet from the window
// as this is used to calculate the CSS fixed top position for the stickies
const scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
if (initialise) {
// Get a collection of sticky header containers references
this.stickies = document.getElementsByClassName("mx_RoomSubList_labelContainer");
if (!this.stickies.length) return;
// Make sure there is sufficient space to do sticky headers: 120px plus all the sticky headers
this.scrollAreaSufficient = (120 + (this.stickies[0].getBoundingClientRect().height * this.stickies.length)) < scrollAreaHeight;
// Initialise the sticky headers
if (typeof this.stickies === "object" && this.stickies.length > 0) {
// Initialise the sticky headers
Array.prototype.forEach.call(this.stickies, function(sticky, i) {
// Save the positions of all the stickies within scroll area.
// These positions are relative to the LHS Panel top
sticky.dataset.originalPosition = sticky.offsetTop - scrollArea.offsetTop;
// Save and set the sticky heights
const originalHeight = sticky.getBoundingClientRect().height;
sticky.dataset.originalHeight = originalHeight;
sticky.style.height = originalHeight;
return sticky;
});
}
}
if (!this.stickies) return;
const self = this;
let scrollStuckOffset = 0;
// Scroll to the passed in position, i.e. a header was clicked and in a scroll to state
// rather than a collapsable one (see RoomSubList.isCollapsableOnClick method for details)
if (scrollToPosition !== undefined) {
scrollArea.scrollTop = scrollToPosition;
}
// Stick headers to top and bottom, or free them
Array.prototype.forEach.call(this.stickies, function(sticky, i, stickyWrappers) {
const stickyPosition = sticky.dataset.originalPosition;
const stickyHeight = sticky.dataset.originalHeight;
const stickyHeader = sticky.childNodes[0];
const topStuckHeight = stickyHeight * i;
const bottomStuckHeight = stickyHeight * (stickyWrappers.length - i);
if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) {
// Top stickies
sticky.dataset.stuck = "top";
stickyHeader.classList.add("mx_RoomSubList_fixed");
stickyHeader.style.top = scrollAreaOffset + topStuckHeight + "px";
// If stuck at top adjust the scroll back down to take account of all the stuck headers
if (scrollToPosition !== undefined && stickyPosition === scrollToPosition) {
scrollStuckOffset = topStuckHeight;
}
} else if (self.scrollAreaSufficient && stickyPosition > ((scrollArea.scrollTop + scrollAreaHeight) - bottomStuckHeight)) {
/// Bottom stickies
sticky.dataset.stuck = "bottom";
stickyHeader.classList.add("mx_RoomSubList_fixed");
stickyHeader.style.top = (scrollAreaOffset + scrollAreaHeight) - bottomStuckHeight + "px";
} else {
// Not sticky
sticky.dataset.stuck = "none";
stickyHeader.classList.remove("mx_RoomSubList_fixed");
stickyHeader.style.top = null;
}
});
// Adjust the scroll to take account of top stuck headers
if (scrollToPosition !== undefined) {
scrollArea.scrollTop -= scrollStuckOffset;
}
},
_updateStickyHeaders: function(initialise, scrollToPosition) {
const self = this;
if (initialise) {
// Useing setTimeout to ensure that the code is run after the painting
// of the newly rendered object as using requestAnimationFrame caused
// artefacts to appear on screen briefly
window.setTimeout(function() {
self._initAndPositionStickyHeaders(initialise, scrollToPosition);
});
} else {
this._initAndPositionStickyHeaders(initialise, scrollToPosition);
}
},
onShowMoreRooms: function() {
// kick gemini in the balls to get it to wake up
// XXX: uuuuuuugh.
if (!this._gemScroll) return;
this._gemScroll.forceUpdate();
},
_getEmptyContent: function(section) {
if (this.state.selectedTags.length > 0) {
return null;
}
const RoomDropTarget = sdk.getComponent('rooms.RoomDropTarget');
if (this.props.collapsed) {
return <RoomDropTarget label="" />;
}
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
let tip = null;
switch (section) {
case 'im.vector.fake.direct':
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"Press <StartChatButton> to start a chat with someone",
{},
{ 'StartChatButton': <StartChatButton size="16" callout={true} /> },
) }
</div>;
break;
case 'im.vector.fake.recent':
tip = <div className="mx_RoomList_emptySubListTip">
{ _t(
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or"+
" <RoomDirectoryButton> to browse the directory",
{},
{
'CreateRoomButton': <CreateRoomButton size="16" callout={true} />,
'RoomDirectoryButton': <RoomDirectoryButton size="16" callout={true} />,
},
) }
</div>;
break;
}
if (tip) {
return <div className="mx_RoomList_emptySubListTip_container">
{ tip }
</div>;
}
// We don't want to display drop targets if there are no room tiles to drag'n'drop
if (this.state.totalRoomCount === 0) {
return null;
}
const labelText = phraseForSection(section);
return <RoomDropTarget label={labelText} />;
},
_getHeaderItems: function(section) {
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
@ -632,161 +537,195 @@ module.exports = React.createClass({
return ret;
},
_collectGemini(gemScroll) {
this._gemScroll = gemScroll;
_applySearchFilter: function(list, filter) {
if (filter === "") return list;
const lcFilter = filter.toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
_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) {
const defaultProps = {
collapsed: this.props.collapsed,
isFiltered: !!this.props.searchFilter,
incomingCall: this.state.incomingCall,
};
subListsProps.forEach((p) => {
p.list = this._applySearchFilter(p.list, this.props.searchFilter);
});
subListsProps = subListsProps.filter((props => {
const len = props.list.length + (props.extraTiles ? props.extraTiles.length : 0);
return len !== 0 || (props.onAddRoom && !this.props.searchFilter);
}));
return subListsProps.reduce((components, props, i) => {
props = Object.assign({}, defaultProps, props);
const isLast = i === subListsProps.length - 1;
const {key, label, onHeaderClick, ... otherProps} = props;
const chosenKey = key || label;
const onSubListHeaderClick = (collapsed) => {
this._handleCollapsedState(chosenKey, collapsed);
if (onHeaderClick) {
onHeaderClick(collapsed);
}
};
const startAsHidden = props.startAsHidden || this.collapsedState[chosenKey];
let subList = (<RoomSubList
ref={this._subListRef.bind(this, chosenKey)}
startAsHidden={startAsHidden}
onHeaderClick={onSubListHeaderClick}
key={chosenKey}
label={label}
{...otherProps} />);
if (!isLast) {
return components.concat(
subList,
<ResizeHandle key={chosenKey+"-resizer"} vertical={true} id={chosenKey} />
);
} else {
return components.concat(subList);
}
}, []);
},
_collectResizeContainer: function(el) {
this.resizeContainer = el;
},
render: function() {
const RoomSubList = sdk.getComponent('structures.RoomSubList');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// XXX: we can't detect device-level (localStorage) settings onChange as the SettingsStore does not notify
// so checking on every render is the sanest thing at this time.
const showEmpty = SettingsStore.getValue('RoomSubList.showEmpty');
const incomingCallIfTaggedAs = (tagName) => {
if (!this.state.incomingCall) return null;
if (this.state.incomingCallTag !== tagName) return null;
return this.state.incomingCall;
};
const self = this;
let subLists = [
{
list: [],
extraTiles: this._makeGroupInviteTiles(this.props.searchFilter),
label: _t('Community Invites'),
order: "recent",
isInvite: true,
},
{
list: this.state.lists['im.vector.fake.invite'],
label: _t('Invites'),
order: "recent",
incomingCall: incomingCallIfTaggedAs('im.vector.fake.invite'),
isInvite: true,
},
{
list: this.state.lists['m.favourite'],
label: _t('Favourites'),
tagName: "m.favourite",
order: "manual",
incomingCall: incomingCallIfTaggedAs('m.favourite'),
},
{
list: this.state.lists['im.vector.fake.direct'],
label: _t('People'),
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'})},
},
{
list: this.state.lists['im.vector.fake.recent'],
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'})},
},
];
const tagSubLists = Object.keys(this.state.lists)
.filter((tagName) => {
return !tagName.match(STANDARD_TAGS_REGEX);
}).map((tagName) => {
return {
list: this.state.lists[tagName],
key: tagName,
label: labelForTagName(tagName),
tagName: tagName,
order: "manual",
incomingCall: incomingCallIfTaggedAs(tagName),
};
});
subLists = subLists.concat(tagSubLists);
subLists = subLists.concat([
{
list: this.state.lists['m.lowpriority'],
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,
},
{
list: this.state.lists['m.server_notice'],
label: _t('System Alerts'),
tagName: "m.lowpriority",
order: "recent",
incomingCall: incomingCallIfTaggedAs('m.server_notice'),
},
]);
const subListComponents = this._mapSubListProps(subLists);
return (
<GeminiScrollbarWrapper className="mx_RoomList_scrollbar"
autoshow={true} onScroll={self._whenScrolling} onResize={self._whenScrolling} wrappedRef={this._collectGemini}>
<div className="mx_RoomList">
<RoomSubList list={[]}
extraTiles={this._makeGroupInviteTiles(self.props.searchFilter)}
label={_t('Community Invites')}
editable={false}
order="recent"
isInvite={true}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['im.vector.fake.invite']}
label={_t('Invites')}
editable={false}
order="recent"
isInvite={true}
incomingCall={incomingCallIfTaggedAs('im.vector.fake.invite')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['m.favourite']}
label={_t('Favourites')}
tagName="m.favourite"
emptyContent={this._getEmptyContent('m.favourite')}
editable={true}
order="manual"
incomingCall={incomingCallIfTaggedAs('m.favourite')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.direct']}
label={_t('People')}
tagName="im.vector.fake.direct"
emptyContent={this._getEmptyContent('im.vector.fake.direct')}
headerItems={this._getHeaderItems('im.vector.fake.direct')}
editable={true}
order="recent"
incomingCall={incomingCallIfTaggedAs('im.vector.fake.direct')}
collapsed={self.props.collapsed}
alwaysShowHeader={true}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.recent']}
label={_t('Rooms')}
editable={true}
emptyContent={this._getEmptyContent('im.vector.fake.recent')}
headerItems={this._getHeaderItems('im.vector.fake.recent')}
order="recent"
incomingCall={incomingCallIfTaggedAs('im.vector.fake.recent')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
{ Object.keys(self.state.lists).map((tagName) => {
if (!tagName.match(STANDARD_TAGS_REGEX)) {
return <RoomSubList list={self.state.lists[tagName]}
key={tagName}
label={labelForTagName(tagName)}
tagName={tagName}
emptyContent={this._getEmptyContent(tagName)}
editable={true}
order="manual"
incomingCall={incomingCallIfTaggedAs(tagName)}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />;
}
}) }
<RoomSubList list={self.state.lists['m.lowpriority']}
label={_t('Low priority')}
tagName="m.lowpriority"
emptyContent={this._getEmptyContent('m.lowpriority')}
editable={true}
order="recent"
incomingCall={incomingCallIfTaggedAs('m.lowpriority')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.archived']}
emptyContent={self.props.collapsed ? null :
<div className="mx_RoomList_emptySubListTip_container">
<div className="mx_RoomList_emptySubListTip">
{ _t('You have no historical rooms') }
</div>
</div>
}
label={_t('Historical')}
editable={false}
order="recent"
collapsed={self.props.collapsed}
alwaysShowHeader={true}
startAsHidden={true}
showSpinner={self.state.isLoadingLeftRooms}
onHeaderClick={self.onArchivedHeaderClick}
incomingCall={incomingCallIfTaggedAs('im.vector.fake.archived')}
searchFilter={self.props.searchFilter}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['m.server_notice']}
label={_t('System Alerts')}
tagName="m.lowpriority"
editable={false}
order="recent"
incomingCall={incomingCallIfTaggedAs('m.server_notice')}
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={false} />
<div ref={this._collectResizeContainer} className="mx_RoomList"
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{ subListComponents }
</div>
</GeminiScrollbarWrapper>
);
},
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 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.
@ -20,10 +20,16 @@ import sdk from "../../../index";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
export default class RoomRecoveryReminder extends React.PureComponent {
static propTypes = {
onFinished: PropTypes.func.isRequired,
// called if the user sets the option to suppress this reminder in the future
onDontAskAgainSet: PropTypes.func,
}
static defaultProps = {
onDontAskAgainSet: function() {},
}
constructor(props) {
@ -82,7 +88,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: this.state.unverifiedDevice,
onFinished: this.props.onFinished,
});
return;
}
@ -91,9 +96,6 @@ export default class RoomRecoveryReminder extends React.PureComponent {
// 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,
},
);
}
@ -103,10 +105,14 @@ export default class RoomRecoveryReminder extends React.PureComponent {
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);
onDontAskAgain: async () => {
await SettingsStore.setValue(
"showRoomRecoveryReminder",
null,
SettingLevel.ACCOUNT,
false,
);
this.props.onDontAskAgainSet();
},
onSetup: () => {
this.showSetupDialog();

View file

@ -63,6 +63,7 @@ module.exports = React.createClass({
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
notificationCount: this.props.room.getUnreadNotificationCount(),
selected: this.props.room.roomId === RoomViewStore.getRoomId(),
statusMessage: this._getStatusMessage(),
});
},
@ -80,6 +81,33 @@ module.exports = React.createClass({
return Boolean(dmRooms);
},
_shouldShowStatusMessage() {
if (!SettingsStore.isFeatureEnabled("feature_custom_status")) {
return false;
}
const isInvite = this.props.room.getMyMembership() === "invite";
const isJoined = this.props.room.getMyMembership() === "join";
const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2;
return !isInvite && isJoined && looksLikeDm;
},
_getStatusMessageUser() {
const selfId = MatrixClientPeg.get().getUserId();
const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0];
if (!otherMember) {
return null;
}
return otherMember.user;
},
_getStatusMessage() {
const statusUser = this._getStatusMessageUser();
if (!statusUser) {
return "";
}
return statusUser._unstable_statusMessage;
},
onRoomTimeline: function(ev, room) {
if (room !== this.props.room) return;
this.setState({
@ -113,7 +141,13 @@ module.exports = React.createClass({
this.setState({
notificationCount: this.props.room.getUnreadNotificationCount(),
});
break;
break;
// RoomTiles are one of the few components that may show custom status and
// also remain on screen while in Settings toggling the feature. This ensures
// you can clearly see the status hide and show when toggling the feature.
case 'feature_custom_status_changed':
this.forceUpdate();
break;
}
},
@ -129,6 +163,16 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.name", this.onRoomName);
ActiveRoomObserver.addListener(this.props.room.roomId, this._onActiveRoomChange);
this.dispatcherRef = dis.register(this.onAction);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
statusUser.on(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
}
},
componentWillUnmount: function() {
@ -140,6 +184,16 @@ module.exports = React.createClass({
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this._onActiveRoomChange);
dis.unregister(this.dispatcherRef);
if (this._shouldShowStatusMessage()) {
const statusUser = this._getStatusMessageUser();
if (statusUser) {
statusUser.removeListener(
"User._unstable_statusMessage",
this._onStatusMessageCommitted,
);
}
}
},
componentWillReceiveProps: function(props) {
@ -167,6 +221,13 @@ module.exports = React.createClass({
return false;
},
_onStatusMessageCommitted() {
// The status message `User` object has observed a message change.
this.setState({
statusMessage: this._getStatusMessage(),
});
},
onClick: function(ev) {
if (this.props.onClick) {
this.props.onClick(this.props.room.roomId, ev);
@ -221,7 +282,7 @@ module.exports = React.createClass({
this.setState( { badgeHover: false } );
},
onBadgeClicked: function(e) {
onOpenMenu: function(e) {
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
// Only allow non-guests to access the context menu
@ -252,15 +313,9 @@ 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;
}
if (this._shouldShowStatusMessage()) {
subtext = this.state.statusMessage;
}
const classes = classNames({
@ -289,19 +344,14 @@ module.exports = React.createClass({
if (name == undefined || name == null) name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badgeContent;
if (this.state.badgeHover || this.state.menuDisplayed) {
badgeContent = "\u00B7\u00B7\u00B7";
} else if (badges) {
let badge;
if (badges) {
const limitedCount = FormattingUtils.formatCount(notificationCount);
badgeContent = notificationCount ? limitedCount : '!';
} else {
badgeContent = '\u200B';
const badgeContent = notificationCount ? limitedCount : '!';
badge = <div className={badgeClasses}>{ badgeContent }</div>;
}
const badge = <div className={badgeClasses} onClick={this.onBadgeClicked}>{ badgeContent }</div>;
const EmojiText = sdk.getComponent('elements.EmojiText');
let label;
let subtextLabel;
@ -333,6 +383,11 @@ module.exports = React.createClass({
// incomingCallBox = <IncomingCallBox incomingCall={ this.props.incomingCall }/>;
//}
let contextMenuButton;
if (!MatrixClientPeg.get().isGuest()) {
contextMenuButton = <AccessibleButton className="mx_RoomTile_menuButton" onClick={this.onOpenMenu} />;
}
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
let dmIndicator;
@ -354,8 +409,11 @@ module.exports = React.createClass({
</div>
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ subtextLabel }
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }

View file

@ -33,11 +33,11 @@ module.exports = React.createClass({
},
onThisRoomClick: function() {
this.setState({ scope: 'Room' });
this.setState({ scope: 'Room' }, () => this._searchIfQuery());
},
onAllRoomsClick: function() {
this.setState({ scope: 'All' });
this.setState({ scope: 'All' }, () => this._searchIfQuery());
},
onSearchChange: function(e) {
@ -49,6 +49,12 @@ module.exports = React.createClass({
}
},
_searchIfQuery: function() {
if (this.refs.search_term.value) {
this.onSearch();
}
},
onSearch: function() {
this.props.onSearch(this.refs.search_term.value, this.state.scope);
},
@ -60,11 +66,13 @@ module.exports = React.createClass({
return (
<div className="mx_SearchBar">
<input ref="search_term" className="mx_SearchBar_input" type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange} />
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch}><img src="img/search-button.svg" width="37" height="37" alt={_t("Search")} /></AccessibleButton>
<AccessibleButton className={ thisRoomClasses } onClick={this.onThisRoomClick}>{_t("This Room")}</AccessibleButton>
<AccessibleButton className={ allRoomsClasses } onClick={this.onAllRoomsClick}>{_t("All Rooms")}</AccessibleButton>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
<div className="mx_SearchBar_input mx_textinput">
<input ref="search_term" type="text" autoFocus={true} placeholder={_t("Search…")} onKeyDown={this.onSearchChange} />
<AccessibleButton className={ searchButtonClasses } onClick={this.onSearch}></AccessibleButton>
</div>
<AccessibleButton className="mx_SearchBar_cancel" onClick={this.props.onCancelClick}></AccessibleButton>
</div>
);
},

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-hide-stickers.svg" width="35" height="35" />
<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-show-stickers.svg" width="35" height="35" />
<TintableSvg src="img/feather-icons/face.svg" width="20" height="20" />
</AccessibleButton>;
}
return <div>

View file

@ -21,6 +21,8 @@ const React = require('react');
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import {formatCount} from '../../../utils/FormattingUtils';
const sdk = require('../../../index');
module.exports = React.createClass({
@ -28,28 +30,15 @@ module.exports = React.createClass({
propTypes: {
onScrollUpClick: PropTypes.func,
onCloseClick: PropTypes.func,
},
render: function() {
return (
<div className="mx_TopUnreadMessagesBar">
<AccessibleButton className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollto.svg" width="24" height="24"
// No point on setting up non empty alt on this image
// as it only complements the text which follows it.
alt=""
title={_t('Scroll to unread messages')}
// In order not to use this title attribute for accessible name
// calculation of the parent button set the role presentation
role="presentation" />
{ _t("Jump to first unread message.") }
title={_t('Jump to first unread message.')}
onClick={this.props.onScrollUpClick}>
</AccessibleButton>
<AccessibleButton element='img' className="mx_TopUnreadMessagesBar_close mx_filterFlipColor"
src="img/cancel.svg" width="18" height="18"
alt={_t("Close")} title={_t("Close")}
onClick={this.props.onCloseClick} />
</div>
);
},

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

@ -21,13 +21,15 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
export default class KeyBackupPanel extends React.Component {
export default class KeyBackupPanel extends React.PureComponent {
constructor(props) {
super(props);
this._startNewBackup = this._startNewBackup.bind(this);
this._deleteBackup = this._deleteBackup.bind(this);
this._verifyDevice = this._verifyDevice.bind(this);
this._onKeyBackupSessionsRemaining =
this._onKeyBackupSessionsRemaining.bind(this);
this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this);
this._restoreBackup = this._restoreBackup.bind(this);
@ -36,6 +38,7 @@ export default class KeyBackupPanel extends React.Component {
loading: true,
error: null,
backupInfo: null,
sessionsRemaining: 0,
};
}
@ -43,6 +46,10 @@ export default class KeyBackupPanel extends React.Component {
this._loadBackupStatus();
MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatus);
MatrixClientPeg.get().on(
'crypto.keyBackupSessionsRemaining',
this._onKeyBackupSessionsRemaining,
);
}
componentWillUnmount() {
@ -50,9 +57,19 @@ export default class KeyBackupPanel extends React.Component {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatus);
MatrixClientPeg.get().removeListener(
'crypto.keyBackupSessionsRemaining',
this._onKeyBackupSessionsRemaining,
);
}
}
_onKeyBackupSessionsRemaining(sessionsRemaining) {
this.setState({
sessionsRemaining,
});
}
_onKeyBackupStatus() {
this._loadBackupStatus();
}
@ -144,57 +161,70 @@ export default class KeyBackupPanel extends React.Component {
} else if (this.state.backupInfo) {
let clientBackupStatus;
if (MatrixClientPeg.get().getKeyBackupEnabled()) {
clientBackupStatus = _t("This device is uploading keys to this backup");
clientBackupStatus = _t("This device is using key backup");
} else {
// XXX: display why and how to fix it
clientBackupStatus = _t(
"This device is <b>not</b> uploading keys to this backup", {},
"This device is <b>not</b> using key backup", {},
{b: x => <b>{x}</b>},
);
}
let uploadStatus;
const { sessionsRemaining } = this.state;
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
// No upload status to show when backup disabled.
uploadStatus = "";
} else if (sessionsRemaining > 0) {
uploadStatus = <div>
{_t("Backing up %(sessionsRemaining)s keys...", { sessionsRemaining })} <br />
</div>;
} else {
uploadStatus = <div>
{_t("All keys backed up")} <br />
</div>;
}
let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => {
const deviceName = sig.device.getDisplayName() || sig.device.deviceId;
const sigStatusSubstitutions = {
validity: sub =>
<span className={sig.valid ? 'mx_KeyBackupPanel_sigValid' : 'mx_KeyBackupPanel_sigInvalid'}>
{sub}
</span>,
verify: sub =>
<span className={sig.device.isVerified() ? 'mx_KeyBackupPanel_deviceVerified' : 'mx_KeyBackupPanel_deviceNotVerified'}>
{sub}
</span>,
device: sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>,
};
const validity = sub =>
<span className={sig.valid ? 'mx_KeyBackupPanel_sigValid' : 'mx_KeyBackupPanel_sigInvalid'}>
{sub}
</span>;
const verify = sub =>
<span className={sig.device.isVerified() ? 'mx_KeyBackupPanel_deviceVerified' : 'mx_KeyBackupPanel_deviceNotVerified'}>
{sub}
</span>;
const device = sub => <span className="mx_KeyBackupPanel_deviceName">{deviceName}</span>;
let sigStatus;
if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from this device",
{}, sigStatusSubstitutions,
{}, { validity },
);
} else if (sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>verified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has a <validity>valid</validity> signature from " +
"<verify>unverified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (!sig.valid && sig.device.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>verified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
} else if (!sig.valid && !sig.device.isVerified()) {
sigStatus = _t(
"Backup has an <validity>invalid</validity> signature from " +
"<verify>unverified</verify> device <device></device>",
{}, sigStatusSubstitutions,
{}, { validity, verify, device },
);
}
@ -219,6 +249,7 @@ export default class KeyBackupPanel extends React.Component {
{_t("Backup version: ")}{this.state.backupInfo.version}<br />
{_t("Algorithm: ")}{this.state.backupInfo.algorithm}<br />
{clientBackupStatus}<br />
{uploadStatus}
<div>{backupSigStatuses}</div><br />
<br />
<AccessibleButton className="mx_UserSettings_button"

View file

@ -135,6 +135,7 @@
"Changes your display nickname": "Changes your display nickname",
"Changes colour scheme of current room": "Changes colour scheme of current room",
"Sets the room topic": "Sets the room topic",
"Sets the room name": "Sets the room name",
"Invites user with given id to current room": "Invites user with given id to current room",
"Joins room with given alias": "Joins room with given alias",
"Leave room": "Leave room",
@ -184,6 +185,7 @@
"%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".",
"%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.",
"%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.",
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.",
@ -213,10 +215,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",
"This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.",
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.",
"Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> to continue using the service.",
@ -264,6 +266,8 @@
"Custom user status messages": "Custom user status messages",
"Increase performance by only loading room members on first view": "Increase performance by only loading room members on first view",
"Backup of encryption keys to server": "Backup of encryption keys to server",
"Render simple counters in room header": "Render simple counters in room header",
"Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu": "Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu",
"Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
"Hide removed messages": "Hide removed messages",
@ -294,7 +298,6 @@
"Pin rooms I'm mentioned in to the top of the room list": "Pin rooms I'm mentioned in to the top of the room list",
"Pin unread rooms to the top of the room list": "Pin unread rooms to the top of the room list",
"Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets",
"Show empty room list headings": "Show empty room list headings",
"Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs",
"Show developer tools": "Show developer tools",
"Collecting app version information": "Collecting app version information",
@ -356,8 +359,10 @@
"Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history": "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history",
"Delete backup": "Delete backup",
"Unable to load key backup status": "Unable to load key backup status",
"This device is uploading keys to this backup": "This device is uploading keys to this backup",
"This device is <b>not</b> uploading keys to this backup": "This device is <b>not</b> uploading keys to this backup",
"This device is using key backup": "This device is using key backup",
"This device is <b>not</b> using key backup": "This device is <b>not</b> using key backup",
"Backing up %(sessionsRemaining)s keys...": "Backing up %(sessionsRemaining)s keys...",
"All keys backed up": "All keys backed up",
"Backup has a <validity>valid</validity> signature from this device": "Backup has a <validity>valid</validity> signature from this device",
"Backup has a <validity>valid</validity> signature from <verify>verified</verify> device <device></device>": "Backup has a <validity>valid</validity> signature from <verify>verified</verify> device <device></device>",
"Backup has a <validity>valid</validity> signature from <verify>unverified</verify> device <device></device>": "Backup has a <validity>valid</validity> signature from <verify>unverified</verify> device <device></device>",
@ -463,6 +468,7 @@
"Close": "Close",
"and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...",
"Invite to this room": "Invite to this room",
"Invited": "Invited",
"Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
@ -538,21 +544,18 @@
"Forget room": "Forget room",
"Search": "Search",
"Share room": "Share room",
"Show panel": "Show panel",
"Toggle right panel": "Toggle right panel",
"Drop here to favourite": "Drop here to favourite",
"Drop here to tag direct chat": "Drop here to tag direct chat",
"Drop here to restore": "Drop here to restore",
"Drop here to demote": "Drop here to demote",
"Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Press <StartChatButton> to start a chat with someone": "Press <StartChatButton> to start a chat with someone",
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory": "You're not in any rooms yet! Press <CreateRoomButton> to make a room or <RoomDirectoryButton> to browse the directory",
"Community Invites": "Community Invites",
"Invites": "Invites",
"Favourites": "Favourites",
"People": "People",
"Rooms": "Rooms",
"Low priority": "Low priority",
"You have no historical rooms": "You have no historical rooms",
"Historical": "Historical",
"System Alerts": "System Alerts",
"Joining room...": "Joining room...",
@ -652,7 +655,6 @@
"Stickerpack": "Stickerpack",
"Hide Stickers": "Hide Stickers",
"Show Stickers": "Show Stickers",
"Scroll to unread messages": "Scroll to unread messages",
"Jump to first unread message.": "Jump to first unread message.",
"Invalid alias format": "Invalid alias format",
"'%(alias)s' is not a valid format for an alias": "'%(alias)s' is not a valid format for an alias",
@ -679,6 +681,9 @@
"In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.",
"URL Previews": "URL Previews",
"When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.",
"Members": "Members",
"Files": "Files",
"Notifications": "Notifications",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -754,6 +759,7 @@
"Failed to remove user from community": "Failed to remove user from community",
"Failed to load group members": "Failed to load group members",
"Filter community members": "Filter community members",
"Invite to this community": "Invite to this community",
"Flair will appear if enabled in room settings": "Flair will appear if enabled in room settings",
"Flair will not appear": "Flair will not appear",
"Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?",
@ -766,6 +772,7 @@
"Visibility in Room List": "Visibility in Room List",
"Visible to everyone": "Visible to everyone",
"Only visible to community members": "Only visible to community members",
"Add rooms to this community": "Add rooms to this community",
"Filter community rooms": "Filter community rooms",
"Something went wrong when trying to get your communities.": "Something went wrong when trying to get your communities.",
"Display your community flair in rooms configured to show it.": "Display your community flair in rooms configured to show it.",
@ -974,6 +981,16 @@
"Clear cache and resync": "Clear cache and resync",
"Riot now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "Riot now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!",
"Updating Riot": "Updating Riot",
"When you log out, you'll lose your secure message history. To prevent this, set up a recovery method.": "When you log out, you'll lose your secure message history. To prevent this, set up a recovery method.",
"Alternatively, advanced users can also manually export encryption keys in <a>Settings</a> before logging out.": "Alternatively, advanced users can also manually export encryption keys in <a>Settings</a> before logging out.",
"For security, logging out will delete any end-to-end encryption keys from this browser. If you want to be able to decrypt your conversation history from future Riot sessions, please export your room keys for safe-keeping.": "For security, logging out will delete any end-to-end encryption keys from this browser. If you want to be able to decrypt your conversation history from future Riot sessions, please export your room keys for safe-keeping.",
"Set a Recovery Method": "Set a Recovery Method",
"I understand, log out without": "I understand, log out without",
"When signing in again, you can access encrypted chat history by restoring your key backup. You'll need your recovery key.": "When signing in again, you can access encrypted chat history by restoring your key backup. You'll need your recovery key.",
"Thanks for testing the Riot Redesign. If you run into any bugs or visual issues, please let us know on GitHub.": "Thanks for testing the Riot Redesign. If you run into any bugs or visual issues, please let us know on GitHub.",
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.",
"Report bugs & give feedback": "Report bugs & give feedback",
"Go back": "Go back",
"Failed to upgrade room": "Failed to upgrade room",
"The room upgrade could not be completed": "The room upgrade could not be completed",
"Upgrade this room to version %(version)s": "Upgrade this room to version %(version)s",
@ -1072,8 +1089,11 @@
"Forget": "Forget",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat",
"Set a new status...": "Set a new status...",
"Clear status": "Clear status",
"Update status": "Update status",
"Set status": "Set status",
"Set a new status...": "Set a new status...",
"View as Grid": "View as Grid",
"View Community": "View Community",
"Sorry, your browser is <b>not</b> able to run Riot.": "Sorry, your browser is <b>not</b> able to run Riot.",
"Riot uses many advanced browser features, some of which are not available or experimental in your current browser.": "Riot uses many advanced browser features, some of which are not available or experimental in your current browser.",
@ -1081,14 +1101,10 @@
"<safariLink>Safari</safariLink> and <operaLink>Opera</operaLink> work too.": "<safariLink>Safari</safariLink> and <operaLink>Opera</operaLink> work too.",
"With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!",
"I understand the risks and wish to continue": "I understand the risks and wish to continue",
"Name": "Name",
"Topic": "Topic",
"Make this room private": "Make this room private",
"Share message history with new users": "Share message history with new users",
"Encrypt room": "Encrypt room",
"You must <a>register</a> to use this functionality": "You must <a>register</a> to use this functionality",
"You must join the room to see its files": "You must join the room to see its files",
"There are no visible files in this room": "There are no visible files in this room",
"No room in this tile yet.": "No room in this tile yet.",
"<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n": "<h1>HTML for your community's page</h1>\n<p>\n Use the long description to introduce new members to the community, or distribute\n some important <a href=\"foo\">links</a>\n</p>\n<p>\n You can even use 'img' tags\n</p>\n",
"Add rooms to the community summary": "Add rooms to the community summary",
"Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?",
@ -1114,7 +1130,6 @@
"Community Settings": "Community Settings",
"Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> might not be seen by other users for up to 30 minutes.": "Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> might not be seen by other users for up to 30 minutes.",
"These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.",
"Add rooms to this community": "Add rooms to this community",
"Featured Rooms:": "Featured Rooms:",
"Featured Users:": "Featured Users:",
"%(inviter)s has invited you to join this community": "%(inviter)s has invited you to join this community",
@ -1157,16 +1172,6 @@
"Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"You have no visible notifications": "You have no visible notifications",
"Members": "Members",
"%(count)s Members|other": "%(count)s Members",
"%(count)s Members|one": "%(count)s Member",
"Invite to this room": "Invite to this room",
"%(count)s Notifications|other": "%(count)s Notifications",
"%(count)s Notifications|one": "%(count)s Notification",
"Files": "Files",
"Notifications": "Notifications",
"Hide panel": "Hide panel",
"Invite to this community": "Invite to this community",
"Failed to get protocol list from Home Server": "Failed to get protocol list from Home Server",
"The Home Server may be too old to support third party networks": "The Home Server may be too old to support third party networks",
"Failed to get public room list": "Failed to get public room list",
@ -1201,7 +1206,6 @@
"%(count)s new messages|one": "%(count)s new message",
"Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"more": "more",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"File is too big. Maximum file size is %(fileSize)s": "File is too big. Maximum file size is %(fileSize)s",
@ -1219,8 +1223,6 @@
"Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio",
"Expand panel": "Expand panel",
"Collapse panel": "Collapse panel",
"Filter room names": "Filter room names",
"Clear filter": "Clear filter",
"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.",
@ -1231,10 +1233,10 @@
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Light theme": "Light theme",
"Dark theme": "Dark theme",
"2018 theme": "2018 theme",
"Status.im theme": "Status.im theme",
"Can't load user settings": "Can't load user settings",
"Server may be unavailable or overloaded": "Server may be unavailable or overloaded",
"For security, logging out will delete any end-to-end encryption keys from this browser. If you want to be able to decrypt your conversation history from future Riot sessions, please export your room keys for safe-keeping.": "For security, logging out will delete any end-to-end encryption keys from this browser. If you want to be able to decrypt your conversation history from future Riot sessions, please export your room keys for safe-keeping.",
"Success": "Success",
"Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them": "Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them",
"Remove Contact Information?": "Remove Contact Information?",
@ -1353,6 +1355,7 @@
"unknown device": "unknown device",
"NOT verified": "NOT verified",
"verified": "verified",
"Name": "Name",
"Verification": "Verification",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"User ID": "User ID",
@ -1396,33 +1399,36 @@
"Your Recovery Key": "Your Recovery Key",
"Copy to clipboard": "Copy to clipboard",
"Download": "Download",
"I've made a copy": "I've made a copy",
"Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:": "Your Recovery Key has been <b>copied to your clipboard</b>, paste it to:",
"Your Recovery Key is in your <b>Downloads</b> folder.": "Your Recovery Key is in your <b>Downloads</b> folder.",
"<b>Print it</b> and store it somewhere safe": "<b>Print it</b> and store it somewhere safe",
"<b>Save it</b> on a USB key or backup drive": "<b>Save it</b> on a USB key or backup drive",
"<b>Copy it</b> to your personal cloud storage": "<b>Copy it</b> to your personal cloud storage",
"Got it": "Got it",
"Backup created": "Backup created",
"Your encryption keys are now being backed up to your Homeserver.": "Your encryption keys are now being backed up to your Homeserver.",
"Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.": "Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.",
"Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.",
"Set up Secure Message Recovery": "Set up Secure Message Recovery",
"Create a Recovery Passphrase": "Create a Recovery Passphrase",
"Confirm Recovery Passphrase": "Confirm Recovery Passphrase",
"Recovery Key": "Recovery Key",
"Keep it safe": "Keep it safe",
"Backing up...": "Backing up...",
"Starting backup...": "Starting backup...",
"Backup Started": "Backup Started",
"Create Key Backup": "Create Key Backup",
"Unable to create key backup": "Unable to create key backup",
"Retry": "Retry",
"Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.",
"If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.",
"New Recovery Method": "New Recovery Method",
"A new recovery passphrase and key for Secure Messages has been detected.": "A new recovery passphrase and key for Secure Messages has been detected.",
"Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.",
"A new recovery passphrase and key for Secure Messages have been detected.": "A new recovery passphrase and key for Secure Messages have been detected.",
"If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.",
"Set up Secure Messages": "Set up Secure Messages",
"This device is encrypting history using the new recovery method.": "This device is encrypting history using the new recovery method.",
"Go to Settings": "Go to Settings",
"Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.",
"Set up Secure Messages": "Set up Secure Messages",
"Recovery Method Removed": "Recovery Method Removed",
"This device has detected that your recovery passphrase and key for Secure Messages have been removed.": "This device has detected that your recovery passphrase and key for Secure Messages have been removed.",
"If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.",
"If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.",
"Failed to set direct chat tag": "Failed to set direct chat tag",
"Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
"Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room"

View file

@ -677,6 +677,7 @@
"Integrations Error": "Integrations Error",
"NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted",
"Sets the room topic": "Sets the room topic",
"Sets the room name": "Sets the room name",
"The maximum permitted number of widgets have already been added to this room.": "The maximum permitted number of widgets have already been added to this room.",
"To get started, please pick a username!": "To get started, please pick a username!",
"Unable to create widget.": "Unable to create widget.",

View file

@ -0,0 +1,53 @@
/*
Copyright 2019 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 FixedDistributor from "./fixed";
import ResizeItem from "../item";
class CollapseItem extends ResizeItem {
notifyCollapsed(collapsed) {
const callback = this.resizer.config.onCollapsed;
if (callback) {
callback(collapsed, this.id, this.domNode);
}
}
}
export default class CollapseDistributor extends FixedDistributor {
static createItem(resizeHandle, resizer, sizer) {
return new CollapseItem(resizeHandle, resizer, sizer);
}
constructor(item, config) {
super(item);
this.toggleSize = config && config.toggleSize;
this.isCollapsed = false;
}
resize(newSize) {
const isCollapsedSize = newSize < this.toggleSize;
if (isCollapsedSize && !this.isCollapsed) {
this.isCollapsed = true;
this.item.notifyCollapsed(true);
} else if (!isCollapsedSize && this.isCollapsed) {
this.item.notifyCollapsed(false);
this.isCollapsed = false;
}
if (!isCollapsedSize) {
super.resize(newSize);
}
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2019 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 ResizeItem from "../item";
import Sizer from "../sizer";
/**
distributors translate a moving cursor into
CSS/DOM changes by calling the sizer
they have two methods:
`resize` receives then new item size
`resizeFromContainerOffset` receives resize handle location
within the container bounding box. For internal use.
This method usually ends up calling `resize` once the start offset is subtracted.
*/
export default class FixedDistributor {
static createItem(resizeHandle, resizer, sizer) {
return new ResizeItem(resizeHandle, resizer, sizer);
}
static createSizer(containerElement, vertical, reverse) {
return new Sizer(containerElement, vertical, reverse);
}
constructor(item) {
this.item = item;
this.beforeOffset = item.offset();
}
resize(size) {
this.item.setSize(size);
}
resizeFromContainerOffset(offset) {
this.resize(offset - this.beforeOffset);
}
start() {}
finish() {}
}

View file

@ -0,0 +1,132 @@
/*
Copyright 2019 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 Sizer from "../sizer";
import ResizeItem from "../item";
class RoomSizer extends Sizer {
setItemSize(item, size) {
item.style.maxHeight = `${Math.round(size)}px`;
item.classList.add("resized-sized");
}
clearItemSize(item) {
item.style.maxHeight = null;
item.classList.remove("resized-sized");
}
}
class RoomSubListItem extends ResizeItem {
isCollapsed() {
return this.domNode.classList.contains("mx_RoomSubList_hidden");
}
maxSize() {
const header = this.domNode.querySelector(".mx_RoomSubList_labelContainer");
const scrollItem = this.domNode.querySelector(".mx_RoomSubList_scroll");
const headerHeight = this.sizer.getItemSize(header);
return headerHeight + (scrollItem ? scrollItem.scrollHeight : 0);
}
minSize() {
const isNotEmpty = this.domNode.classList.contains("mx_RoomSubList_nonEmpty");
return isNotEmpty ? 74 : 31; //size of header + 1? room tile (see room sub list css)
}
isSized() {
return this.domNode.classList.contains("resized-sized");
}
}
export default class RoomSubListDistributor {
static createItem(resizeHandle, resizer, sizer) {
return new RoomSubListItem(resizeHandle, resizer, sizer);
}
static createSizer(containerElement, vertical, reverse) {
return new RoomSizer(containerElement, vertical, reverse);
}
constructor(item) {
this.item = item;
}
_handleSize() {
return 1;
}
resize(size) {
//console.log("*** starting resize session with size", size);
let item = this.item;
while (item) {
const minSize = item.minSize();
if (item.isCollapsed()) {
item = item.previous();
} else if (size <= minSize) {
//console.log(" - resizing", item.id, "to min size", minSize);
item.setSize(minSize);
const remainder = minSize - size;
item = item.previous();
if (item) {
size = item.size() - remainder - this._handleSize();
}
} else {
const maxSize = item.maxSize();
if (size > maxSize) {
// console.log(" - resizing", item.id, "to maxSize", maxSize);
item.setSize(maxSize);
const remainder = size - maxSize;
item = item.previous();
if (item) {
size = item.size() + remainder; // todo: handle size here?
}
} else {
//console.log(" - resizing", item.id, "to size", size);
item.setSize(size);
item = null;
size = 0;
}
}
}
//console.log("*** ending resize session");
}
resizeFromContainerOffset(containerOffset) {
this.resize(containerOffset - this.item.offset());
}
start() {
// set all max-height props to the actual height.
let item = this.item.first();
const sizes = [];
while (item) {
if (!item.isCollapsed()) {
sizes.push(item.size());
} else {
sizes.push(100);
}
item = item.next();
}
item = this.item.first();
sizes.forEach((size) => {
item.setSize(size);
item = item.next();
});
}
finish() {
}
}

27
src/resizer/index.js Normal file
View file

@ -0,0 +1,27 @@
/*
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 FixedDistributor from "./distributors/fixed";
import CollapseDistributor from "./distributors/collapse";
import RoomSubListDistributor from "./distributors/roomsublist";
import Resizer from "./resizer";
module.exports = {
Resizer,
FixedDistributor,
CollapseDistributor,
RoomSubListDistributor,
};

107
src/resizer/item.js Normal file
View file

@ -0,0 +1,107 @@
/*
Copyright 2019 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.
*/
export default class ResizeItem {
constructor(handle, resizer, sizer) {
const id = handle.getAttribute("data-id");
const reverse = resizer.isReverseResizeHandle(handle);
const domNode = reverse ? handle.nextElementSibling : handle.previousElementSibling;
this.domNode = domNode;
this.id = id;
this.reverse = reverse;
this.resizer = resizer;
this.sizer = sizer;
}
_copyWith(handle, resizer, sizer) {
const Ctor = this.constructor;
return new Ctor(handle, resizer, sizer);
}
_advance(forwards) {
// opposite direction from fromResizeHandle to get back to handle
let handle = this.reverse ?
this.domNode.previousElementSibling :
this.domNode.nextElementSibling;
const moveNext = forwards !== this.reverse; // xor
// iterate at least once to avoid infinite loop
do {
if (moveNext) {
handle = handle.nextElementSibling;
} else {
handle = handle.previousElementSibling;
}
} while (handle && !this.resizer.isResizeHandle(handle));
if (handle) {
const nextHandle = this._copyWith(handle, this.resizer, this.sizer);
nextHandle.reverse = this.reverse;
return nextHandle;
}
}
next() {
return this._advance(true);
}
previous() {
return this._advance(false);
}
size() {
return this.sizer.getItemSize(this.domNode);
}
offset() {
return this.sizer.getItemOffset(this.domNode);
}
setSize(size) {
this.sizer.setItemSize(this.domNode, size);
const callback = this.resizer.config.onResized;
if (callback) {
callback(size, this.id, this.domNode);
}
}
clearSize() {
this.sizer.clearItemSize(this.domNode);
const callback = this.resizer.config.onResized;
if (callback) {
callback(null, this.id, this.domNode);
}
}
first() {
const firstHandle = Array.from(this.domNode.parentElement.children).find(el => {
return this.resizer.isResizeHandle(el);
});
if (firstHandle) {
return this._copyWith(firstHandle, this.resizer, this.sizer);
}
}
last() {
const lastHandle = Array.from(this.domNode.parentElement.children).reverse().find(el => {
return this.resizer.isResizeHandle(el);
});
if (lastHandle) {
return this._copyWith(lastHandle, this.resizer, this.sizer);
}
}
}

143
src/resizer/resizer.js Normal file
View file

@ -0,0 +1,143 @@
/*
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.
*/
/*
classNames:
// class on resize-handle
handle: string
// class on resize-handle
reverse: string
// class on resize-handle
vertical: string
// class on container
resizing: string
*/
export default class Resizer {
// TODO move vertical/horizontal to config option/container class
// as it doesn't make sense to mix them within one container/Resizer
constructor(container, distributorCtor, config) {
this.container = container;
this.distributorCtor = distributorCtor;
this.config = config;
this.classNames = {
handle: "resizer-handle",
reverse: "resizer-reverse",
vertical: "resizer-vertical",
resizing: "resizer-resizing",
};
this._onMouseDown = this._onMouseDown.bind(this);
}
setClassNames(classNames) {
this.classNames = classNames;
}
attach() {
this.container.addEventListener("mousedown", this._onMouseDown, false);
}
detach() {
this.container.removeEventListener("mousedown", this._onMouseDown, false);
}
/**
Gives the distributor for a specific resize handle, as if you would have started
to drag that handle. Can be used to manipulate the size of an item programmatically.
@param {number} handleIndex the index of the resize handle in the container
@return {Distributor} a new distributor for the given handle
*/
forHandleAt(handleIndex) {
const handles = this._getResizeHandles();
const handle = handles[handleIndex];
if (handle) {
const {distributor} = this._createSizerAndDistributor(handle);
return distributor;
}
}
forHandleWithId(id) {
const handles = this._getResizeHandles();
const handle = handles.find((h) => h.getAttribute("data-id") === id);
if (handle) {
const {distributor} = this._createSizerAndDistributor(handle);
return distributor;
}
}
isReverseResizeHandle(el) {
return el && el.classList.contains(this.classNames.reverse);
}
isResizeHandle(el) {
return el && el.classList.contains(this.classNames.handle);
}
_onMouseDown(event) {
// use closest in case the resize handle contains
// child dom nodes that can be the target
const resizeHandle = event.target && event.target.closest(`.${this.classNames.handle}`);
if (!resizeHandle || resizeHandle.parentElement !== this.container) {
return;
}
// prevent starting a drag operation
event.preventDefault();
// mark as currently resizing
if (this.classNames.resizing) {
this.container.classList.add(this.classNames.resizing);
}
const {sizer, distributor} = this._createSizerAndDistributor(resizeHandle);
distributor.start();
const onMouseMove = (event) => {
const offset = sizer.offsetFromEvent(event);
distributor.resizeFromContainerOffset(offset);
};
const body = document.body;
const finishResize = () => {
if (this.classNames.resizing) {
this.container.classList.remove(this.classNames.resizing);
}
distributor.finish();
body.removeEventListener("mouseup", finishResize, false);
document.removeEventListener("mouseleave", finishResize, false);
body.removeEventListener("mousemove", onMouseMove, false);
};
body.addEventListener("mouseup", finishResize, false);
document.addEventListener("mouseleave", finishResize, false);
body.addEventListener("mousemove", onMouseMove, false);
}
_createSizerAndDistributor(resizeHandle) {
const vertical = resizeHandle.classList.contains(this.classNames.vertical);
const reverse = this.isReverseResizeHandle(resizeHandle);
const Distributor = this.distributorCtor;
const sizer = Distributor.createSizer(this.container, vertical, reverse);
const item = Distributor.createItem(resizeHandle, this, sizer);
const distributor = new Distributor(item, this.config);
return {sizer, distributor};
}
_getResizeHandles() {
return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(el);
});
}
}

88
src/resizer/sizer.js Normal file
View file

@ -0,0 +1,88 @@
/*
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.
*/
/**
implements DOM/CSS operations for resizing.
The sizer determines what CSS mechanism is used for sizing items, like flexbox, ...
*/
export default class Sizer {
constructor(container, vertical, reverse) {
this.container = container;
this.reverse = reverse;
this.vertical = vertical;
}
/**
@param {Element} item the dom element being resized
@return {number} how far the edge of the item is from the edge of the container
*/
getItemOffset(item) {
const offset = (this.vertical ? item.offsetTop : item.offsetLeft) - this._getOffset();
if (this.reverse) {
return this.getTotalSize() - (offset + this.getItemSize(item));
} else {
return offset;
}
}
/**
@param {Element} item the dom element being resized
@return {number} the width/height of an item in the container
*/
getItemSize(item) {
return this.vertical ? item.offsetHeight : item.offsetWidth;
}
/** @return {number} the width/height of the container */
getTotalSize() {
return this.vertical ? this.container.offsetHeight : this.container.offsetWidth;
}
/** @return {number} container offset to offsetParent */
_getOffset() {
return this.vertical ? this.container.offsetTop : this.container.offsetLeft;
}
setItemSize(item, size) {
if (this.vertical) {
item.style.height = `${Math.round(size)}px`;
} else {
item.style.width = `${Math.round(size)}px`;
}
}
clearItemSize(item) {
if (this.vertical) {
item.style.height = null;
} else {
item.style.width = null;
}
}
/**
@param {MouseEvent} event the mouse event
@return {number} the distance between the cursor and the edge of the container,
along the applicable axis (vertical or horizontal)
*/
offsetFromEvent(event) {
const pos = this.vertical ? event.pageY : event.pageX;
if (this.reverse) {
return (this._getOffset() + this.getTotalSize()) - pos;
} else {
return pos - this._getOffset();
}
}
}

View file

@ -22,6 +22,7 @@ import {
NotificationsEnabledController,
} from "./controllers/NotificationControllers";
import LazyLoadingController from "./controllers/LazyLoadingController";
import CustomStatusController from "./controllers/CustomStatusController";
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config'];
@ -88,6 +89,7 @@ export const SETTINGS = {
displayName: _td("Custom user status messages"),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new CustomStatusController(),
},
"feature_lazyloading": {
isFeature: true,
@ -102,6 +104,12 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_state_counters": {
isFeature: true,
displayName: _td("Render simple counters in room header"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"MessageComposerInput.dontSuggestEmoji": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Disable Emoji suggestions while typing'),
@ -206,8 +214,8 @@ export const SETTINGS = {
default: false,
},
"theme": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: "light",
supportedLevels: ['config'],
default: "dharma",
},
"webRtcForceTURN": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
@ -312,11 +320,6 @@ export const SETTINGS = {
supportedLevels: ['room-device'],
default: false,
},
"RoomSubList.showEmpty": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Show empty room list headings'),
default: true,
},
"promptBeforeInviteUnknownUsers": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Prompt before sending invites to potentially invalid matrix IDs'),

View file

@ -0,0 +1,28 @@
/*
Copyright 2019 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 SettingController from "./SettingController";
import dis from "../../dispatcher";
export default class CustomStatusController extends SettingController {
onChange(level, roomId, newValue) {
// Dispatch setting change so that some components that are still visible when the
// Settings page is open (such as RoomTiles) can reflect the change.
dis.dispatch({
action: "feature_custom_status_changed",
});
}
}

View file

@ -27,7 +27,7 @@ export default class ConfigSettingsHandler extends SettingsHandler {
// Special case themes
if (settingName === "theme") {
return config["default_theme"];
return "dharma"; // config["default_theme"];
}
const settingsConfig = config["settingDefaults"];

View file

@ -224,9 +224,9 @@ class RoomListStore extends Store {
}
}
// ignore any m. tag names we don't know about
// ignore tags we don't know about
tagNames = tagNames.filter((t) => {
return !t.startsWith('m.') || lists[t] !== undefined;
return lists[t] !== undefined;
});
if (tagNames.length) {
@ -277,7 +277,7 @@ class RoomListStore extends Store {
const roomCache = this._state.roomCache;
if (!roomCache[roomId]) roomCache[roomId] = {};
if (value) roomCache[roomId][type] = value;
if (typeof value !== "undefined") roomCache[roomId][type] = value;
else delete roomCache[roomId][type];
this._setState({roomCache});

View file

@ -37,3 +37,24 @@ export function formatCount(count) {
export function formatCryptoKey(key) {
return key.match(/.{1,4}/g).join(" ");
}
/**
* calculates a numeric hash for a given string
*
* @param {string} str string to hash
*
* @return {number}
*/
export function hashCode(str) {
let hash = 0;
let i;
let chr;
if (str.length === 0) {
return hash;
}
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return Math.abs(hash);
}

126
src/utils/Timer.js Normal file
View file

@ -0,0 +1,126 @@
/*
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.
*/
/**
A countdown timer, exposing a promise api.
A timer starts in a non-started state,
and needs to be started by calling `start()`` on it first.
Timers can be `abort()`-ed which makes the promise reject prematurely.
Once a timer is finished or aborted, it can't be started again
(because the promise should not be replaced). Instead, create
a new one through `clone()` or `cloneIfRun()`.
*/
export default class Timer {
constructor(timeout) {
this._timeout = timeout;
this._onTimeout = this._onTimeout.bind(this);
this._setNotStarted();
}
_setNotStarted() {
this._timerHandle = null;
this._startTs = null;
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
}).finally(() => {
this._timerHandle = null;
});
}
_onTimeout() {
const now = Date.now();
const elapsed = now - this._startTs;
if (elapsed >= this._timeout) {
this._resolve();
this._setNotStarted();
} else {
const delta = this._timeout - elapsed;
this._timerHandle = setTimeout(this._onTimeout, delta);
}
}
changeTimeout(timeout) {
if (timeout === this._timeout) {
return;
}
const isSmallerTimeout = timeout < this._timeout;
this._timeout = timeout;
if (this.isRunning() && isSmallerTimeout) {
clearTimeout(this._timerHandle);
this._onTimeout();
}
}
/**
* if not started before, starts the timer.
* @returns {Timer} the same timer
*/
start() {
if (!this.isRunning()) {
this._startTs = Date.now();
this._timerHandle = setTimeout(this._onTimeout, this._timeout);
}
return this;
}
/**
* (re)start the timer. If it's running, reset the timeout. If not, start it.
* @returns {Timer} the same timer
*/
restart() {
if (this.isRunning()) {
// don't clearTimeout here as this method
// can be called in fast succession,
// instead just take note and compare
// when the already running timeout expires
this._startTs = Date.now();
return this;
} else {
return this.start();
}
}
/**
* if the timer is running, abort it,
* and reject the promise for this timer.
* @returns {Timer} the same timer
*/
abort() {
if (this.isRunning()) {
clearTimeout(this._timerHandle);
this._reject(new Error("Timer was aborted."));
this._setNotStarted();
}
return this;
}
/**
*promise that will resolve when the timer elapses,
*or is rejected when abort is called
*@return {Promise}
*/
finished() {
return this._promise;
}
isRunning() {
return this._timerHandle !== null;
}
}