Merge branch 'develop' into matthew/retina

This commit is contained in:
Bruno Windels 2019-04-05 13:35:54 +02:00
commit f1b00dff35
622 changed files with 40536 additions and 16475 deletions

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import OpenRoomsStore from './stores/OpenRoomsStore';
import RoomViewStore from './stores/RoomViewStore';
/**
* Consumes changes from the OpenRoomsStore and notifies specific things
* Consumes changes from the RoomViewStore and notifies specific things
* about when the active room changes. Unlike listening for RoomViewStore
* changes, you can subscribe to only changes relevant to a particular
* room.
@ -28,15 +28,11 @@ import OpenRoomsStore from './stores/OpenRoomsStore';
class ActiveRoomObserver {
constructor() {
this._listeners = {};
const roomStore = OpenRoomsStore.getActiveRoomStore();
this._activeRoomId = roomStore && roomStore.getRoomId();
this._activeRoomId = RoomViewStore.getRoomId();
// TODO: We could self-destruct when the last listener goes away, or at least
// stop listening.
this._roomStoreToken = OpenRoomsStore.addListener(this._onOpenRoomsStoreUpdate.bind(this));
}
getActiveRoomId() {
return this._activeRoomId;
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
}
addListener(roomId, listener) {
@ -55,23 +51,23 @@ class ActiveRoomObserver {
}
}
_emit(roomId, newActiveRoomId) {
_emit(roomId) {
if (!this._listeners[roomId]) return;
for (const l of this._listeners[roomId]) {
l.call(l, newActiveRoomId);
l.call();
}
}
_onOpenRoomsStoreUpdate() {
const activeRoomStore = OpenRoomsStore.getActiveRoomStore();
const newActiveRoomId = activeRoomStore && activeRoomStore.getRoomId();
_onRoomViewStoreUpdate() {
// emit for the old room ID
if (this._activeRoomId) this._emit(this._activeRoomId, newActiveRoomId);
if (this._activeRoomId) this._emit(this._activeRoomId);
// update our cache
this._activeRoomId = newActiveRoomId;
this._activeRoomId = RoomViewStore.getRoomId();
// and emit for the new one
if (this._activeRoomId) this._emit(this._activeRoomId, this._activeRoomId);
if (this._activeRoomId) this._emit(this._activeRoomId);
}
}

View file

@ -19,14 +19,14 @@ import MatrixClientPeg from './MatrixClientPeg';
import { _t } from './languageHandler';
/**
* Allows a user to add a third party identifier to their Home Server and,
* Allows a user to add a third party identifier to their homeserver and,
* optionally, the identity servers.
*
* This involves getting an email token from the identity server to "prove" that
* the client owns the given email address, which is then passed to the
* add threepid API on the homeserver.
*/
class AddThreepid {
export default class AddThreepid {
constructor() {
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
}
@ -124,5 +124,3 @@ class AddThreepid {
});
}
}
module.exports = AddThreepid;

View file

@ -66,7 +66,7 @@ const customVariables = {
},
'User Type': {
id: 3,
expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'),
expl: _td('Whether or not you\'re logged in (we don\'t record your username)'),
example: 'Logged In',
},
'Chosen Language': {
@ -267,7 +267,7 @@ class Analytics {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div className="mx_UserSettings_analyticsModal">
description: <div className="mx_AnalyticsModal">
<div>
{ _t('The information being sent to us to help make Riot.im better includes:') }
</div>

View file

@ -51,11 +51,11 @@ module.exports = {
},
defaultAvatarUrlForString: function(s) {
const images = ['76cfa6', '50e2c2', 'f4c371'];
const images = ['03b381', '368bd6', 'ac3ba8'];
let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
return 'img/' + images[total % images.length] + '.png';
return require('../res/img/' + images[total % images.length] + '.png');
},
};

View file

@ -113,4 +113,29 @@ export default class BasePlatform {
reload() {
throw new Error("reload not implemented!");
}
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): boolean {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): boolean {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
}

View file

@ -69,4 +69,16 @@ export default {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
},
getAudioOutput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput");
},
getAudioInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audioinput");
},
getVideoInput: function() {
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
},
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -17,18 +18,18 @@ limitations under the License.
'use strict';
import Promise from 'bluebird';
const extend = require('./extend');
const dis = require('./dispatcher');
const MatrixClientPeg = require('./MatrixClientPeg');
const sdk = require('./index');
import extend from './extend';
import dis from './dispatcher';
import MatrixClientPeg from './MatrixClientPeg';
import sdk from './index';
import { _t } from './languageHandler';
const Modal = require('./Modal');
const encrypt = require("browser-encrypt-attachment");
const png_chunks_extract = require("png-chunks-extract");
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment";
import png_chunks_extract = from "png-chunks-extract";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
require("blueimp-canvas-to-blob");
import "blueimp-canvas-to-blob";
const MAX_WIDTH = 800;
const MAX_HEIGHT = 600;
@ -95,7 +96,7 @@ function createThumbnail(element, inputWidth, inputHeight, mimeType) {
/**
* Load a file into a newly created image element.
*
* @param {File} file The file to load in an image element.
* @param {File} imageFile The file to load in an image element.
* @return {Promise} A promise that resolves with the html image element.
*/
function loadImageElement(imageFile) {
@ -147,7 +148,7 @@ function loadImageElement(imageFile) {
*
* @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with.
* @param {String} roomId The ID of the room the image will be uploaded in.
* @param {File} The image to read and thumbnail.
* @param {File} imageFile The image to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info.
*/
function infoForImageFile(matrixClient, roomId, imageFile) {
@ -172,7 +173,7 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
/**
* Load a file into a newly created video element.
*
* @param {File} file The file to load in an video element.
* @param {File} videoFile The file to load in an video element.
* @return {Promise} A promise that resolves with the video image element.
*/
function loadVideoElement(videoFile) {
@ -207,7 +208,7 @@ function loadVideoElement(videoFile) {
*
* @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with.
* @param {String} roomId The ID of the room the video will be uploaded to.
* @param {File} The video to read and thumbnail.
* @param {File} videoFile The video to read and thumbnail.
* @return {Promise} A promise that resolves with the attachment info.
*/
function infoForVideoFile(matrixClient, roomId, videoFile) {
@ -228,6 +229,7 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
/**
* Read the file as an ArrayBuffer.
* @param {File} file The file to read
* @return {Promise} A promise that resolves with an ArrayBuffer when the file
* is read.
*/
@ -297,11 +299,43 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
}
}
class ContentMessages {
export default class ContentMessages {
constructor() {
this.inprogress = [];
this.nextId = 0;
this._mediaConfig = null;
}
static sharedInstance() {
if (global.mx_ContentMessages === undefined) {
global.mx_ContentMessages = new ContentMessages();
}
return global.mx_ContentMessages;
}
_isFileSizeAcceptable(file) {
if (this._mediaConfig !== null &&
this._mediaConfig["m.upload.size"] !== undefined &&
file.size > this._mediaConfig["m.upload.size"]) {
return false;
}
return true;
}
_ensureMediaConfigFetched() {
if (this._mediaConfig !== null) return;
console.log("[Media Config] Fetching");
return MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
this._mediaConfig = config;
});
}
sendStickerContentToRoom(url, roomId, info, text, matrixClient) {
@ -311,7 +345,90 @@ class ContentMessages {
});
}
sendContentToRoom(file, roomId, matrixClient) {
getUploadLimit() {
if (this._mediaConfig !== null && this._mediaConfig["m.upload.size"] !== undefined) {
return this._mediaConfig["m.upload.size"];
} else {
return null;
}
}
async sendContentListToRoom(files, roomId, matrixClient) {
if (matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
if (isQuoting) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const shouldUpload = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Reply Warning', '', QuestionDialog, {
title: _t('Replying With Files'),
description: (
<div>{_t(
'At this time it is not possible to reply with a file. ' +
'Would you like to upload this file without replying?',
)}</div>
),
hasCancelButton: true,
button: _t("Continue"),
onFinished: (shouldUpload) => {
resolve(shouldUpload);
},
});
});
if (!shouldUpload) return;
}
await this._ensureMediaConfigFetched();
const tooBigFiles = [];
const okFiles = [];
for (let i = 0; i < files.length; ++i) {
if (this._isFileSizeAcceptable(files[i])) {
okFiles.push(files[i]);
} else {
tooBigFiles.push(files[i]);
}
}
if (tooBigFiles.length > 0) {
const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog");
const uploadFailureDialogPromise = new Promise((resolve) => {
Modal.createTrackedDialog('Upload Failure', '', UploadFailureDialog, {
badFiles: tooBigFiles,
totalFiles: files.length,
contentMessages: this,
onFinished: (shouldContinue) => {
resolve(shouldContinue);
},
});
});
const shouldContinue = await uploadFailureDialogPromise;
if (!shouldContinue) return;
}
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue) => {
resolve(shouldContinue);
},
});
});
if (!shouldContinue) break;
this._sendContentToRoom(file, roomId, matrixClient);
}
}
_sendContentToRoom(file, roomId, matrixClient) {
const content = {
body: file.name || 'Attachment',
info: {
@ -385,9 +502,12 @@ class ContentMessages {
}, function(err) {
error = err;
if (!upload.canceled) {
let desc = _t('The file \'%(fileName)s\' failed to upload', {fileName: upload.fileName}) + '.';
let desc = _t("The file '%(fileName)s' failed to upload.", {fileName: upload.fileName});
if (err.http_status == 413) {
desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName});
desc = _t(
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
{fileName: upload.fileName},
);
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
@ -405,9 +525,16 @@ class ContentMessages {
}
}
if (error) {
// 413: File was too big or upset the server in some way:
// clear the media size limit so we fetch it again next time
// we try to upload
if (error && error.http_status === 413) {
this._mediaConfig = null;
}
dis.dispatch({action: 'upload_failed', upload, error});
} else {
dis.dispatch({action: 'upload_finished', upload});
dis.dispatch({action: 'message_sent'});
}
});
}
@ -432,9 +559,3 @@ class ContentMessages {
}
}
}
if (global.mx_ContentMessage === undefined) {
global.mx_ContentMessage = new ContentMessages();
}
module.exports = global.mx_ContentMessage;

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 Travis Ralston
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
@ -20,17 +21,19 @@ import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
const WIDGET_API_VERSION = '0.0.1'; // Current API version
const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
'0.0.2',
];
const INBOUND_API_NAME = 'fromWidget';
// Listen for and handle incomming requests using the 'fromWidget' postMessage
// Listen for and handle incoming requests using the 'fromWidget' postMessage
// API and initiate responses
export default class FromWidgetPostMessageApi {
constructor() {
this.widgetMessagingEndpoints = [];
this.widgetListeners = {}; // {action: func[]}
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
@ -45,6 +48,32 @@ export default class FromWidgetPostMessageApi {
window.removeEventListener('message', this.onPostMessage);
}
/**
* Adds a listener for a given action
* @param {string} action The action to listen for.
* @param {Function} callbackFn A callback function to be called when the action is
* encountered. Called with two parameters: the interesting request information and
* the raw event received from the postMessage API. The raw event is meant to be used
* for sendResponse and similar functions.
*/
addListener(action, callbackFn) {
if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
this.widgetListeners[action].push(callbackFn);
}
/**
* Removes a listener for a given action.
* @param {string} action The action that was subscribed to.
* @param {Function} callbackFn The original callback function that was used to subscribe
* to updates.
*/
removeListener(action, callbackFn) {
if (!this.widgetListeners[action]) return;
const idx = this.widgetListeners[action].indexOf(callbackFn);
if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
}
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
@ -117,6 +146,13 @@ export default class FromWidgetPostMessageApi {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
// Call any listeners we have registered
if (this.widgetListeners[event.data.action]) {
for (const fn of this.widgetListeners[event.data.action]) {
fn(event.data, event);
}
}
// Although the requestId is required, we don't use it. We'll be nice and process the message
// if the property is missing, but with a warning for widget developers.
if (!event.data.requestId) {
@ -164,6 +200,8 @@ export default class FromWidgetPostMessageApi {
if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
}
} else if (action === 'get_openid') {
// Handled by caller
} else {
console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'});

View file

@ -19,16 +19,21 @@ limitations under the License.
import ReplyThread from "./components/views/elements/ReplyThread";
const React = require('react');
const sanitizeHtml = require('sanitize-html');
const highlight = require('highlight.js');
const linkifyMatrix = require('./linkify-matrix');
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import highlight from 'highlight.js';
import * as linkify from 'linkifyjs';
import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import escape from 'lodash/escape';
import emojione from 'emojione';
import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg';
import url from 'url';
linkifyMatrix(linkify);
emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG)
emojione.imagePathPNG = 'emojione/png/';
@ -43,6 +48,12 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
// (with plenty of false positives, but that's OK)
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// Regex pattern for Zero-Width joiner unicode characters
const ZWJ_REGEX = new RegExp("\u200D|\u2003", "g");
// Regex pattern for whitespace characters
const WHITESPACE_REGEX = new RegExp("\\s", "g");
// And this is emojione's complete regex
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
@ -63,8 +74,10 @@ export function containsEmoji(str) {
/* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js
* because we want to include emoji shortnames in title text
*/
function unicodeToImage(str) {
let replaceWith; let unicode; let alt; let short; let fname;
function unicodeToImage(str, addAlt) {
if (addAlt === undefined) addAlt = true;
let replaceWith; let unicode; let short; let fname;
const mappedUnicode = emojione.mapUnicodeToShort();
str = str.replace(emojione.regUnicode, function(unicodeChar) {
@ -79,10 +92,14 @@ function unicodeToImage(str) {
fname = emojione.emojioneList[short].fname;
// depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
const title = mappedUnicode[unicode];
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
if (addAlt) {
const alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
} else {
replaceWith = `<img class="mx_emojione" src="${emojione.imagePathSVG}${fname}.svg${emojione.cacheBustParam}"/>`;
}
return replaceWith;
}
});
@ -220,7 +237,7 @@ const transformTags = { // custom to matrix
'code': function(tagName, attribs) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s+/).filter(function(cl) {
const classes = attribs.class.split(/\s/).filter(function(cl) {
return cl.startsWith('language-');
});
attribs.class = classes.join(' ');
@ -487,7 +504,17 @@ export function bodyToHtml(content, highlights, opts={}) {
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0;
const contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
// Ignore spaces in body text. Emojis with spaces in between should
// still be counted as purely emoji messages.
contentBodyTrimmed = contentBodyTrimmed.replace(WHITESPACE_REGEX, '');
// Remove zero width joiner characters from emoji messages. This ensures
// that emojis that are made up of multiple unicode characters are still
// presented as large.
contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, '');
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
}
@ -503,8 +530,39 @@ export function bodyToHtml(content, highlights, opts={}) {
<span className={className} dir="auto">{ strippedBody }</span>;
}
export function emojifyText(text) {
export function emojifyText(text, addAlt) {
return {
__html: unicodeToImage(escape(text)),
__html: unicodeToImage(escape(text), addAlt),
};
}
/**
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
*
* @param {string} str
* @returns {string}
*/
export function linkifyString(str) {
return _linkifyString(str);
}
/**
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
*
* @param {object} element DOM element to linkify
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
export function linkifyElement(element, options = linkifyMatrix.options) {
return _linkifyElement(element, options);
}
/**
* Linkify the given string and sanitize the HTML afterwards.
*
* @param {string} dirtyHtml The HTML string to sanitize and linkify
* @returns {string}
*/
export function linkifyAndSanitizeHtml(dirtyHtml) {
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
}

View file

@ -51,6 +51,11 @@ export default class IntegrationManager {
*/
static async open(integName, integId, onFinished) {
await IntegrationManager._init();
if (global.mxIntegrationManager.client) {
await global.mxIntegrationManager.client.connect();
} else {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
if (global.mxIntegrationManager.error ||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {

View file

@ -27,12 +27,12 @@ import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import RtsClient from './RtsClient';
import Modal from './Modal';
import sdk from './index';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import PlatformPeg from "./PlatformPeg";
import {sendLoginRequest} from "./Login";
import { sendLoginRequest } from "./Login";
import * as StorageManager from './utils/StorageManager';
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -103,13 +103,29 @@ export async function loadSession(opts) {
return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to login screen
// fall back to welcome screen
return false;
} catch (e) {
if (e instanceof AbortLoginAndRebuildStorage) {
// If we're aborting login because of a storage inconsistency, we don't
// need to show the general failure dialog. Instead, just go back to welcome.
return false;
}
return _handleLoadSessionFailure(e);
}
}
/**
* Gets the user ID of the persisted session, if one exists. This does not validate
* that the user's credentials still work, just that they exist and that a user ID
* is associated with them. The session is not loaded.
* @returns {String} The persisted session's owner, if an owner exists. Null otherwise.
*/
export function getStoredSessionOwner() {
const {hsUrl, userId, accessToken} = _getLocalStorageSessionVars();
return hsUrl && userId && accessToken ? userId : null;
}
/**
* @param {Object} queryParams string->string map of the
* query-parameters extracted from the real query-string of the starting
@ -215,6 +231,16 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
});
}
function _getLocalStorageSessionVars() {
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const accessToken = localStorage.getItem("mx_access_token");
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
return {hsUrl, isUrl, accessToken, userId, deviceId};
}
// returns a promise which resolves to true if a session is found in
// localstorage
//
@ -224,16 +250,13 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
//
// The plan is to gradually move the localStorage access done here into
// SessionStore to avoid bugs where the view becomes out-of-sync with
// localStorage (e.g. teamToken, isGuest etc.)
// localStorage (e.g. isGuest etc.)
async function _restoreFromLocalStorage() {
if (!localStorage) {
return false;
}
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const accessToken = localStorage.getItem("mx_access_token");
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
const {hsUrl, isUrl, accessToken, userId, deviceId} = _getLocalStorageSessionVars();
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
@ -261,7 +284,7 @@ async function _restoreFromLocalStorage() {
}
function _handleLoadSessionFailure(e) {
console.log("Unable to load session", e);
console.error("Unable to load session", e);
const def = Promise.defer();
const SessionRestoreErrorDialog =
@ -286,15 +309,6 @@ function _handleLoadSessionFailure(e) {
});
}
let rtsClient = null;
export function initRtsClient(url) {
if (url) {
rtsClient = new RtsClient(url);
} else {
rtsClient = null;
}
}
/**
* Transitions to a logged-in state using the given credentials.
*
@ -333,7 +347,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
);
// This is dispatched to indicate that the user is still in the process of logging in
// because `teamPromise` may take some time to resolve, breaking the assumption that
// because async code may take some time to resolve, breaking the assumption that
// `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms
// later than MatrixChat might assume.
//
@ -345,12 +359,24 @@ async function _doSetLoggedIn(credentials, clearStorage) {
await _clearStorage();
}
const results = await StorageManager.checkConsistency();
// If there's an inconsistency between account data in local storage and the
// crypto store, we'll be generally confused when handling encrypted data.
// Show a modal recommending a full reset of storage.
if (results.dataInLocalStorage && !results.dataInCryptoStore) {
const signOut = await _showStorageEvictedDialog();
if (signOut) {
await _clearStorage();
// This error feels a bit clunky, but we want to make sure we don't go any
// further and instead head back to sign in.
throw new AbortLoginAndRebuildStorage(
"Aborting login in progress because of storage inconsistency",
);
}
}
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl);
// Resolves by default
let teamPromise = Promise.resolve(null);
if (localStorage) {
try {
_persistCredentialsToLocalStorage(credentials);
@ -367,32 +393,31 @@ async function _doSetLoggedIn(credentials, clearStorage) {
} catch (e) {
console.warn("Error using local storage: can't persist session!", e);
}
if (rtsClient && !credentials.guest) {
teamPromise = rtsClient.login(credentials.userId).then((body) => {
if (body.team_token) {
localStorage.setItem("mx_team_token", body.team_token);
}
return body.team_token;
}, (err) => {
console.warn(`Failed to get team token on login: ${err}` );
return null;
});
}
} else {
console.warn("No local storage available: can't persist session!");
}
MatrixClientPeg.replaceUsingCreds(credentials);
teamPromise.then((teamToken) => {
dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
});
dis.dispatch({ action: 'on_logged_in' });
await startMatrixClient();
return MatrixClientPeg.get();
}
function _showStorageEvictedDialog() {
const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog');
return new Promise(resolve => {
Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, {
onFinished: resolve,
});
});
}
// Note: Babel 6 requires the `transform-builtin-extend` plugin for this to satisfy
// `instanceof`. Babel 7 supports this natively in their class handling.
class AbortLoginAndRebuildStorage extends Error { }
function _persistCredentialsToLocalStorage(credentials) {
localStorage.setItem("mx_hs_url", credentials.homeserverUrl);
localStorage.setItem("mx_is_url", credentials.identityServerUrl);
@ -467,7 +492,7 @@ async function startMatrixClient() {
dis.dispatch({action: 'will_start_client'}, true);
Notifier.start();
UserActivity.start();
UserActivity.sharedInstance().start();
Presence.start();
DMRoomMap.makeShared().start();
ActiveWidgetStore.start();
@ -513,7 +538,7 @@ function _clearStorage() {
*/
export function stopMatrixClient() {
Notifier.stop();
UserActivity.stop();
UserActivity.sharedInstance().stop();
Presence.stop();
ActiveWidgetStore.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();

View file

@ -49,6 +49,7 @@ export default class Login {
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
* @returns {MatrixClient}
*/
_createTemporaryClient() {
return Matrix.createClient({
@ -144,8 +145,8 @@ export default class Login {
const tryFallbackHs = (originalError) => {
return sendLoginRequest(
self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams,
).catch((fallback_error) => {
console.log("fallback HS login failed", fallback_error);
).catch((fallbackError) => {
console.log("fallback HS login failed", fallbackError);
// throw the original error
throw originalError;
});

View file

@ -29,6 +29,8 @@ import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
import {phasedRollOutExpiredForUser} from "./PhasedRollOut";
import Modal from './Modal';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
interface MatrixClientCreds {
homeserverUrl: string,
@ -84,7 +86,7 @@ class MatrixClientPeg {
/**
* Replace this MatrixClientPeg's client with a client instance that has
* Home Server / Identity Server URLs and active credentials
* homeserver / identity server URLs and active credentials
*/
replaceUsingCreds(creds: MatrixClientCreds) {
this._currentClientCreds = creds;
@ -101,7 +103,7 @@ class MatrixClientPeg {
} catch (err) {
if (dbType === 'indexeddb') {
console.error('Error starting matrixclient store - falling back to memory store', err);
this.matrixClient.store = new Matrix.MatrixInMemoryStore({
this.matrixClient.store = new Matrix.MemoryStore({
localStorage: global.localStorage,
});
} else {
@ -118,7 +120,7 @@ class MatrixClientPeg {
await this.matrixClient.initCrypto();
}
} catch (e) {
if (e.name === 'InvalidCryptoStoreError') {
if (e && e.name === 'InvalidCryptoStoreError') {
// The js-sdk found a crypto DB too new for it to use
const CryptoStoreTooNewDialog =
sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog");
@ -128,23 +130,17 @@ class MatrixClientPeg {
}
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
console.warn("Unable to initialise e2e: " + e);
console.warn("Unable to initialise e2e", e);
}
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
opts.lazyLoadMembers = true;
const LAZY_LOADING_FEATURE = "feature_lazyloading";
if (SettingsStore.isFeatureEnabled(LAZY_LOADING_FEATURE)) {
const userId = this.matrixClient.credentials.userId;
if (phasedRollOutExpiredForUser(userId, LAZY_LOADING_FEATURE, Date.now())) {
opts.lazyLoadMembers = true;
}
}
// Connect the matrix client to the dispatcher
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;
console.log(`MatrixClientPeg: really starting MatrixClient`);
await this.get().startClient(opts);
@ -163,19 +159,19 @@ class MatrixClientPeg {
}
/**
* Return the server name of the user's home server
* Throws an error if unable to deduce the home server name
* Return the server name of the user's homeserver
* Throws an error if unable to deduce the homeserver name
* (eg. if the user is not logged in)
*/
getHomeServerName() {
const matches = /^@.+:(.+)$/.exec(this.matrixClient.credentials.userId);
if (matches === null || matches.length < 1) {
throw new Error("Failed to derive home server name from user ID!");
throw new Error("Failed to derive homeserver name from user ID!");
}
return matches[1];
}
_createClient(creds: MatrixClientCreds, useIndexedDb) {
_createClient(creds: MatrixClientCreds) {
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
@ -183,10 +179,11 @@ class MatrixClientPeg {
userId: creds.userId,
deviceId: creds.deviceId,
timelineSupport: true,
forceTURN: SettingsStore.getValue('webRtcForceTURN', false),
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
verificationMethods: [verificationMethods.SAS]
};
this.matrixClient = createMatrixClient(opts, useIndexedDb);
this.matrixClient = createMatrixClient(opts);
// we're going to add eventlisteners for each matrix event tile, so the
// potential number of event listeners is quite high.

View file

@ -26,6 +26,7 @@ import dis from './dispatcher';
import { _t } from './languageHandler';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
/**
* Wrap an asynchronous loader function with a react component which shows a
@ -106,7 +107,12 @@ class ModalManager {
// this modal. Remove all other modals from the stack when this modal
// is closed.
this._priorityModal = null;
// The modal to keep open underneath other modals if possible. Useful
// for cases like Settings where the modal should remain open while the
// user is prompted for more information/errors.
this._staticModal = null;
// A list of the modals we have stacked up, with the most recent at [0]
// Neither the static nor priority modal will be in this list.
this._modals = [
/* {
elem: React component for this dialog
@ -118,6 +124,10 @@ class ModalManager {
this.closeAll = this.closeAll.bind(this);
}
hasDialogs() {
return this._priorityModal || this._staticModal || this._modals.length > 0;
}
getOrCreateContainer() {
let container = document.getElementById(DIALOG_CONTAINER_ID);
@ -130,6 +140,18 @@ class ModalManager {
return container;
}
getOrCreateStaticContainer() {
let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID);
if (!container) {
container = document.createElement("div");
container.id = STATIC_DIALOG_CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
createTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
return this.createDialog(...rest);
@ -166,8 +188,13 @@ class ModalManager {
* of other modals that are currently in the stack.
* Also, when closed, all modals will be removed
* from the stack.
* @param {boolean} isStaticModal if true, this modal will be displayed under other
* modals in the stack. When closed, all modals will
* also be removed from the stack. This is not compatible
* with being a priority modal. Only one modal can be
* static at a time.
*/
createDialogAsync(prom, props, className, isPriorityModal) {
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) {
const self = this;
const modal = {};
@ -188,6 +215,13 @@ class ModalManager {
self._modals = [];
}
if (self._staticModal === modal) {
self._staticModal = null;
// XXX: This is destructive
self._modals = [];
}
self._reRender();
};
@ -207,6 +241,9 @@ class ModalManager {
if (isPriorityModal) {
// XXX: This is destructive
this._priorityModal = modal;
} else if (isStaticModal) {
// This is intentionally destructive
this._staticModal = modal;
} else {
this._modals.unshift(modal);
}
@ -216,12 +253,18 @@ class ModalManager {
}
closeAll() {
const modals = this._modals;
const modalsToClose = [...this._modals, this._priorityModal];
this._modals = [];
this._priorityModal = null;
for (let i = 0; i < modals.length; i++) {
const m = modals[i];
if (m.onFinished) {
if (this._staticModal && modalsToClose.length === 0) {
modalsToClose.push(this._staticModal);
this._staticModal = null;
}
for (let i = 0; i < modalsToClose.length; i++) {
const m = modalsToClose[i];
if (m && m.onFinished) {
m.onFinished(false);
}
}
@ -230,13 +273,14 @@ class ModalManager {
}
_reRender() {
if (this._modals.length == 0 && !this._priorityModal) {
if (this._modals.length === 0 && !this._priorityModal && !this._staticModal) {
// If there is no modal to render, make all of Riot available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
return;
}
@ -247,17 +291,45 @@ class ModalManager {
action: 'aria_hide_main_app',
});
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>
<div className="mx_Dialog">
{ modal.elem }
</div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
</div>
);
if (this._staticModal) {
const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper "
+ (this._staticModal.className ? this._staticModal.className : '');
ReactDOM.render(dialog, this.getOrCreateContainer());
const staticDialog = (
<div className={classes}>
<div className="mx_Dialog">
{ this._staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.closeAll}></div>
</div>
);
ReactDOM.render(staticDialog, this.getOrCreateStaticContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer());
}
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
if (modal) {
const classes = "mx_Dialog_wrapper "
+ (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '')
+ (modal.className ? modal.className : '');
const dialog = (
<div className={classes}>
<div className="mx_Dialog">
{modal.elem}
</div>
<div className="mx_Dialog_background" onClick={this.closeAll}></div>
</div>
);
ReactDOM.render(dialog, this.getOrCreateContainer());
} else {
// This is safe to call repeatedly if we happen to do that
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
}
}
}

View file

@ -220,7 +220,17 @@ const Notifier = {
}
},
isToolbarHidden: function() {
shouldShowToolbar: function() {
const client = MatrixClientPeg.get();
if (!client) {
return false;
}
const isGuest = client.isGuest();
return !isGuest && this.supportsDesktopNotifications() &&
!this.isEnabled() && !this._isToolbarHidden();
},
_isToolbarHidden: function() {
// Check localStorage for any such meta data
if (global.localStorage) {
return global.localStorage.getItem("notifications_hidden") === "true";

View file

@ -19,8 +19,6 @@ limitations under the License.
export default {
HomePage: "home_page",
RoomView: "room_view",
GroupGridView: "group_grid_view",
UserSettings: "user_settings",
RoomDirectory: "room_directory",
UserView: "user_view",
GroupView: "group_view",

View file

@ -33,7 +33,7 @@ class Presence {
}
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the Home Server.
* Any state change will be sent to the homeserver.
*/
async start() {
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
@ -78,7 +78,7 @@ class Presence {
/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* If the state has changed, the homeserver will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
async setState(newState) {

View file

@ -22,9 +22,9 @@ limitations under the License.
import dis from './dispatcher';
import sdk from './index';
import MatrixClientPeg from './MatrixClientPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
// import MatrixClientPeg from './MatrixClientPeg';
// Regex for what a "safe" or "Matrix-looking" localpart would be.
// TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514
@ -35,30 +35,36 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/;
* on what the HS supports
*
* @param {object} options
* @param {bool} options.go_home_on_cancel If true, goes to
* the hame page if the user cancels the action
* @param {bool} options.go_home_on_cancel
* If true, goes to the home page if the user cancels the action
* @param {bool} options.go_welcome_on_cancel
* If true, goes to the welcome page if the user cancels the action
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
const flows = await _getRegistrationFlows();
// look for an ILAG compatible flow. We define this as one
// which has only dummy or recaptcha flows. In practice it
// would support any stage InteractiveAuth supports, just not
// ones like email & msisdn which require the user to supply
// the relevant details in advance. We err on the side of
// caution though.
const hasIlagFlow = flows.some((flow) => {
return flow.stages.every((stage) => {
return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
});
});
if (hasIlagFlow) {
dis.dispatch({
action: 'view_set_mxid',
go_home_on_cancel: options.go_home_on_cancel,
});
} else {
// XXX: ILAG is disabled for now,
// see https://github.com/vector-im/riot-web/issues/8222
// const flows = await _getRegistrationFlows();
// const hasIlagFlow = flows.some((flow) => {
// return flow.stages.every((stage) => {
// return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage);
// });
// });
// if (hasIlagFlow) {
// dis.dispatch({
// action: 'view_set_mxid',
// go_home_on_cancel: options.go_home_on_cancel,
// });
//} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
title: _t("Registration Required"),
@ -69,28 +75,30 @@ export async function startAnyRegistrationFlow(options) {
dis.dispatch({action: 'start_registration'});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
} else if (options.go_welcome_on_cancel) {
dis.dispatch({action: 'view_welcome_page'});
}
},
});
}
//}
}
async function _getRegistrationFlows() {
try {
await MatrixClientPeg.get().register(
null,
null,
undefined,
{},
{},
);
console.log("Register request succeeded when it should have returned 401!");
} catch (e) {
if (e.httpStatus === 401) {
return e.data.flows;
}
throw e;
}
throw new Error("Register request succeeded when it should have returned 401!");
}
// async function _getRegistrationFlows() {
// try {
// await MatrixClientPeg.get().register(
// null,
// null,
// undefined,
// {},
// {},
// );
// console.log("Register request succeeded when it should have returned 401!");
// } catch (e) {
// if (e.httpStatus === 401) {
// return e.data.flows;
// }
// throw e;
// }
// throw new Error("Register request succeeded when it should have returned 401!");
// }

View file

@ -65,6 +65,24 @@ export function showRoomInviteDialog(roomId) {
});
}
/**
* Checks if the given MatrixEvent is a valid 3rd party user invite.
* @param {MatrixEvent} event The event to check
* @returns {boolean} True if valid, false otherwise
*/
export function isValid3pidInvite(event) {
if (!event || event.getType() !== "m.room.third_party_invite") return false;
// any events without these keys are not valid 3pid invites, so we ignore them
const requiredKeys = ['key_validity_url', 'public_key', 'display_name'];
for (let i = 0; i < requiredKeys.length; ++i) {
if (!event.getContent()[requiredKeys[i]]) return false;
}
// Valid enough by our standards
return true;
}
function _onStartChatFinished(shouldInvite, addrs) {
if (!shouldInvite) return;

View file

@ -23,6 +23,47 @@ export const ALL_MESSAGES = 'all_messages';
export const MENTIONS_ONLY = 'mentions_only';
export const MUTE = 'mute';
function _shouldShowNotifBadge(roomNotifState) {
const showBadgeInStates = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
return showBadgeInStates.indexOf(roomNotifState) > -1;
}
function _shouldShowMentionBadge(roomNotifState) {
return roomNotifState !== MUTE;
}
export function aggregateNotificationCount(rooms) {
return rooms.reduce((result, room, index) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
if (badges) {
result.count += notificationCount;
if (highlight) {
result.highlight = true;
}
}
return result;
}, {count: 0, highlight: false});
}
export function getRoomHasBadge(room) {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
return notifBadges || mentionBadges;
}
export function getRoomNotifsState(roomId) {
if (MatrixClientPeg.get().isGuest()) return ALL_MESSAGES;

View file

@ -159,6 +159,10 @@ export function setDMRoom(roomId, userId) {
/**
* Given a room, estimate which of its members is likely to
* be the target if the room were a DM room and return that user.
*
* @param {Object} room Target room
* @param {string} myUserId User ID of the current user
* @returns {string} User ID of the user that the room is probably a DM with
*/
function guessDMRoomTargetId(room, myUserId) {
let oldestTs;
@ -173,7 +177,7 @@ function guessDMRoomTargetId(room, myUserId) {
oldestTs = user.events.member.getTs();
}
}
if (oldestUser) return oldestUser;
if (oldestUser) return oldestUser.userId;
// if there are no joined members other than us, use the oldest member
for (const user of room.currentState.getMembers()) {
@ -186,5 +190,5 @@ function guessDMRoomTargetId(room, myUserId) {
}
if (oldestUser === undefined) return myUserId;
return oldestUser;
return oldestUser.userId;
}

View file

@ -1,104 +0,0 @@
import 'whatwg-fetch';
let fetchFunction = fetch;
function checkStatus(response) {
if (!response.ok) {
return response.text().then((text) => {
throw new Error(text);
});
}
return response;
}
function parseJson(response) {
return response.json();
}
function encodeQueryParams(params) {
return '?' + Object.keys(params).map((k) => {
return k + '=' + encodeURIComponent(params[k]);
}).join('&');
}
const request = (url, opts) => {
if (opts && opts.qs) {
url += encodeQueryParams(opts.qs);
delete opts.qs;
}
if (opts && opts.body) {
if (!opts.headers) {
opts.headers = {};
}
opts.body = JSON.stringify(opts.body);
opts.headers['Content-Type'] = 'application/json';
}
return fetchFunction(url, opts)
.then(checkStatus)
.then(parseJson);
};
export default class RtsClient {
constructor(url) {
this._url = url;
}
getTeamsConfig() {
return request(this._url + '/teams');
}
/**
* Track a referral with the Riot Team Server. This should be called once a referred
* user has been successfully registered.
* @param {string} referrer the user ID of one who referred the user to Riot.
* @param {string} sid the sign-up identity server session ID .
* @param {string} clientSecret the sign-up client secret.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
trackReferral(referrer, sid, clientSecret) {
return request(this._url + '/register',
{
body: {
referrer: referrer,
session_id: sid,
client_secret: clientSecret,
},
method: 'POST',
},
);
}
getTeam(teamToken) {
return request(this._url + '/teamConfiguration',
{
qs: {
team_token: teamToken,
},
},
);
}
/**
* Signal to the RTS that a login has occurred and that a user requires their team's
* token.
* @param {string} userId the user ID of the user who is a member of a team.
* @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon
* success.
*/
login(userId) {
return request(this._url + '/login',
{
qs: {
user_id: userId,
},
},
);
}
// allow fetch to be replaced, for testing.
static setFetch(fn) {
fetchFunction = fn;
}
}

View file

@ -21,6 +21,9 @@ const request = require('browser-request');
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require('./MatrixClientPeg');
// The version of the integration manager API we're intending to work with
const imApiVersion = "1.1";
class ScalarAuthClient {
constructor() {
this.scalarToken = null;
@ -66,7 +69,7 @@ class ScalarAuthClient {
request({
method: "GET",
uri: url,
qs: {scalar_token: token},
qs: {scalar_token: token, v: imApiVersion},
json: true,
}, (err, response, body) => {
if (err) {
@ -100,6 +103,7 @@ class ScalarAuthClient {
request({
method: 'POST',
uri: scalar_rest_url+'/register',
qs: {v: imApiVersion},
body: openid_token_object,
json: true,
}, (err, response, body) => {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const DEFAULTS = {
export const DEFAULTS = {
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server

View file

@ -27,7 +27,9 @@ import SettingsStore, {SettingLevel} from './settings/SettingsStore';
import {MATRIXTO_URL_PATTERN} from "./linkify-matrix";
import * as querystring from "querystring";
import MultiInviter from './utils/MultiInviter';
import { linkifyAndSanitizeHtml } from './HtmlUtils';
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import WidgetUtils from "./utils/WidgetUtils";
class Command {
constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
@ -70,6 +72,19 @@ function success(promise) {
/* eslint-disable babel/no-invalid-this */
export const CommandMap = {
shrug: new Command({
name: 'shrug',
args: '<message>',
description: _td('Prepends ¯\\_(ツ)_/¯ to a plain-text message'),
runFn: function(roomId, args) {
let message = '¯\\_(ツ)_/¯';
if (args) {
message = message + ' ' + args;
}
return success(MatrixClientPeg.get().sendTextMessage(roomId, message));
},
}),
ddg: new Command({
name: 'ddg',
args: '<query>',
@ -86,6 +101,83 @@ export const CommandMap = {
hideCompletionAfterSpace: true,
}),
upgraderoom: new Command({
name: 'upgraderoom',
args: '<new_version>',
description: _td('Upgrades a room to a new version'),
runFn: function(roomId, args) {
if (args) {
const room = MatrixClientPeg.get().getRoom(roomId);
Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation',
QuestionDialog, {
title: _t('Room upgrade confirmation'),
description: (
<div>
<p>{_t("Upgrading a room can be destructive and isn't always necessary.")}</p>
<p>
{_t(
"Room upgrades are usually recommended when a room version is considered " +
"<i>unstable</i>. Unstable room versions might have bugs, missing features, or " +
"security vulnerabilities.",
{}, {
"i": (sub) => <i>{sub}</i>,
},
)}
</p>
<p>
{_t(
"Room upgrades usually only affect <i>server-side</i> processing of the " +
"room. If you're having problems with your Riot client, please file an issue " +
"with <issueLink />.",
{}, {
"i": (sub) => <i>{sub}</i>,
"issueLink": () => {
return <a href="https://github.com/vector-im/riot-web/issues/new/choose"
target="_blank" rel="noopener">
https://github.com/vector-im/riot-web/issues/new/choose
</a>;
},
},
)}
</p>
<p>
{_t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room " +
"members to the new version of the room.</i> We'll post a link to the new room " +
"in the old version of the room - room members will have to click this link to " +
"join the new room.",
{}, {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
},
)}
</p>
<p>
{_t(
"Please confirm that you'd like to go forward with upgrading this room " +
"from <oldVersion /> to <newVersion />",
{},
{
oldVersion: () => <code>{room ? room.getVersion() : "1"}</code>,
newVersion: () => <code>{args}</code>,
},
)}
</p>
</div>
),
button: _t("Upgrade"),
onFinished: (confirm) => {
if (!confirm) return;
MatrixClientPeg.get().upgradeRoom(roomId, args);
},
});
return success();
}
return reject(this.getUsage());
},
}),
nick: new Command({
name: 'nick',
args: '<display_name>',
@ -98,6 +190,24 @@ export const CommandMap = {
},
}),
roomnick: new Command({
name: 'roomnick',
args: '<display_name>',
description: _td('Changes your display nickname in the current room only'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const ev = cli.getRoom(roomId).currentState.getStateEvents('m.room.member', cli.getUserId());
const content = {
...ev ? ev.getContent() : { membership: 'join' },
displayname: args,
};
return success(cli.sendStateEvent(roomId, 'm.room.member', content, cli.getUserId()));
}
return reject(this.getUsage());
},
}),
tint: new Command({
name: 'tint',
args: '<color1> [<color2>]',
@ -125,13 +235,26 @@ export const CommandMap = {
topic: new Command({
name: 'topic',
args: '<topic>',
description: _td('Sets the room topic'),
args: '[<topic>]',
description: _td('Gets or sets the room topic'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
if (args) {
return success(MatrixClientPeg.get().setRoomTopic(roomId, args));
return success(cli.setRoomTopic(roomId, args));
}
return reject(this.getUsage());
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
const topic = topicEvents && topicEvents.getContent().topic;
const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.');
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
title: room.name,
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
});
return success();
},
}),
@ -309,7 +432,7 @@ export const CommandMap = {
if (!targetRoomId) targetRoomId = roomId;
return success(
cli.leave(targetRoomId).then(function() {
cli.leaveRoomChain(targetRoomId).then(function() {
dis.dispatch({action: 'view_next_room'});
}),
);
@ -379,13 +502,12 @@ export const CommandMap = {
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, {
title: _t('Ignored user'),
description: <div>
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
@ -411,13 +533,12 @@ export const CommandMap = {
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, {
title: _t('Unignored user'),
description: <div>
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
@ -486,6 +607,26 @@ export const CommandMap = {
},
}),
addwidget: new Command({
name: 'addwidget',
args: '<url>',
description: _td('Adds a custom widget by URL to the room'),
runFn: function(roomId, args) {
if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) {
return reject(_t("Please supply a https:// or http:// widget URL"));
}
if (WidgetUtils.canUserModifyWidgets(roomId)) {
const userId = MatrixClientPeg.get().getUserId();
const nowMs = (new Date()).getTime();
const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`);
return success(WidgetUtils.setRoomWidget(
roomId, widgetId, "m.custom", args, "Custom Widget", {}));
} else {
return reject(_t("You cannot modify widgets in this room."));
}
},
}),
// Verify a user, device, and pubkey tuple
verify: new Command({
name: 'verify',
@ -534,8 +675,8 @@ export const CommandMap = {
return cli.setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, {
title: _t('Verified key'),
description: <div>
<p>
@ -546,7 +687,6 @@ export const CommandMap = {
}
</p>
</div>,
hasCancelButton: false,
});
}),
);

View file

@ -17,6 +17,7 @@ import MatrixClientPeg from './MatrixClientPeg';
import CallHandler from './CallHandler';
import { _t } from './languageHandler';
import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
@ -134,6 +135,68 @@ function textForTombstoneEvent(ev) {
return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName});
}
function textForJoinRulesEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) {
case "public":
return _t('%(senderDisplayName)s made the room public to whoever knows the link.', {senderDisplayName});
case "invite":
return _t('%(senderDisplayName)s made the room invite only.', {senderDisplayName});
default:
// The spec supports "knock" and "private", however nothing implements these.
return _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
senderDisplayName,
rule: ev.getContent().join_rule,
});
}
}
function textForGuestAccessEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) {
case "can_join":
return _t('%(senderDisplayName)s has allowed guests to join the room.', {senderDisplayName});
case "forbidden":
return _t('%(senderDisplayName)s has prevented guests from joining the room.', {senderDisplayName});
default:
// There's no other options we can expect, however just for safety's sake we'll do this.
return _t('%(senderDisplayName)s changed guest access to %(rule)s', {
senderDisplayName,
rule: ev.getContent().guest_access,
});
}
}
function textForRelatedGroupsEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || [];
const added = groups.filter((g) => !prevGroups.includes(g));
const removed = prevGroups.filter((g) => !groups.includes(g));
if (added.length && !removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: added.join(', '),
});
} else if (!added.length && removed.length) {
return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: removed.join(', '),
});
} else if (added.length && removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
'%(oldGroups)s in this room.', {
senderDisplayName,
newGroups: added.join(', '),
oldGroups: removed.join(', '),
});
} else {
// Don't bother rendering this change (because there were no changes)
return '';
}
}
function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
@ -304,6 +367,15 @@ function textForCallInviteEvent(event) {
function textForThreePidInviteEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) {
const targetDisplayName = event.getPrevContent().display_name || _t("Someone");
return _t('%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName,
});
}
return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {
senderName,
targetDisplayName: event.getContent().display_name,
@ -439,6 +511,9 @@ const stateHandlers = {
'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
'm.room.tombstone': textForTombstoneEvent,
'm.room.join_rules': textForJoinRulesEvent,
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
'im.vector.modular.widgets': textForWidgetEvent,
};

View file

@ -32,7 +32,7 @@ module.exports = {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false;
} else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
} else if (ev.getType() == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
return false;
}
const EventTile = sdk.getComponent('rooms.EventTile');

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -17,110 +18,173 @@ limitations under the License.
import dis from './dispatcher';
import Timer from './utils/Timer';
// 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;
// important these are larger than the timeouts of timers
// used with UserActivity.timeWhileActive*,
// such as READ_MARKER_INVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_MARKER_OUTOFVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_RECEIPT_INTERVAL_MS (timeWhileActiveNow) in TimelinePanel
// 'Under a few seconds'. Must be less than 'RECENTLY_ACTIVE_THRESHOLD_MS'
const CURRENTLY_ACTIVE_THRESHOLD_MS = 700;
// 'Under a few minutes'.
const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
/**
* This class watches for user activity (moving the mouse or pressing a key)
* and starts/stops attached timers while the user is active.
*
* There are two classes of 'active': 'active now' and 'active recently'
* see doc on the userActive* functions for what these mean.
*/
class UserActivity {
constructor() {
this._attachedTimers = [];
this._activityTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
export default class UserActivity {
constructor(windowObj, documentObj) {
this._window = windowObj;
this._document = documentObj;
this._attachedActiveNowTimers = [];
this._attachedActiveRecentlyTimers = [];
this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onDocumentBlurred = this._onDocumentBlurred.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
this.lastScreenX = 0;
this.lastScreenY = 0;
}
static sharedInstance() {
if (global.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document);
}
return global.mxUserActivity;
}
/**
* Runs the given timer while the user is active, aborting when the user becomes inactive.
* Runs the given timer while the user is 'active now', aborting when the user is no longer
* considered currently active.
* See userActiveNow() for what it means for a user to be 'active'.
* 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) {
timeWhileActiveNow(timer) {
this._timeWhile(timer, this._attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
}
/**
* Runs the given timer while the user is 'active' now or recently,
* aborting when the user becomes inactive.
* See userActiveRecently() for what it means for a user to be 'active recently'.
* 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
*/
timeWhileActiveRecently(timer) {
this._timeWhile(timer, this._attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
_timeWhile(timer, attachedTimers) {
// important this happens first
const index = this._attachedTimers.indexOf(timer);
const index = attachedTimers.indexOf(timer);
if (index === -1) {
this._attachedTimers.push(timer);
attachedTimers.push(timer);
// remove when done or aborted
timer.finished().finally(() => {
const index = this._attachedTimers.indexOf(timer);
const index = attachedTimers.indexOf(timer);
if (index !== -1) { // should never be -1
this._attachedTimers.splice(index, 1);
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;
document.onmousemove = this._onUserActivity;
document.onkeydown = this._onUserActivity;
document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
document.addEventListener("blur", this._onDocumentBlurred);
document.addEventListener("focus", this._onUserActivity);
this._document.addEventListener('mousedown', this._onUserActivity);
this._document.addEventListener('mousemove', this._onUserActivity);
this._document.addEventListener('keydown', this._onUserActivity);
this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.addEventListener("blur", this._onWindowBlurred);
this._window.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,
{ passive: true, capture: true });
this._window.addEventListener('wheel', this._onUserActivity, {
passive: true, capture: true,
});
}
/**
* Stop tracking user activity
*/
stop() {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
this._document.removeEventListener('mousedown', this._onUserActivity);
this._document.removeEventListener('mousemove', this._onUserActivity);
this._document.removeEventListener('keydown', this._onUserActivity);
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);
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.removeEventListener("blur", this._onWindowBlurred);
this._window.removeEventListener("focus", this._onUserActivity);
}
/**
* Return true if there has been user activity very recently
* (ie. within a few seconds)
* @returns {boolean} true if user is currently/very recently active
* Return true if the user is currently 'active'
* A user is 'active' while they are interacting with the app and for a very short (<1s)
* time after that. This is intended to give a strong indication that the app has the
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
userCurrentlyActive() {
return this._activityTimeout.isRunning();
userActiveNow() {
return this._activeNowTimeout.isRunning();
}
/**
* Return true if the user is currently active or has been recently
* A user is 'active recently' for a longer period of time (~2 mins) after
* they have been 'active' and while the app still has the focus. This is
* intended to indicate when the app may still have the user's attention
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
userActiveRecently() {
return this._activeRecentlyTimeout.isRunning();
}
_onPageVisibilityChanged(e) {
if (document.visibilityState === "hidden") {
this._activityTimeout.abort();
if (this._document.visibilityState === "hidden") {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
} else {
this._onUserActivity(e);
}
}
_onDocumentBlurred() {
this._activityTimeout.abort();
_onWindowBlurred() {
this._activeNowTimeout.abort();
this._activeRecentlyTimeout.abort();
}
async _onUserActivity(event) {
_onUserActivity(event) {
// ignore anything if the window isn't focused
if (!this._document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
@ -131,19 +195,29 @@ class UserActivity {
}
dis.dispatch({action: 'user_activity'});
if (!this._activityTimeout.isRunning()) {
this._activityTimeout.start();
if (!this._activeNowTimeout.isRunning()) {
this._activeNowTimeout.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());
this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout);
} else {
this._activityTimeout.restart();
this._activeNowTimeout.restart();
}
if (!this._activeRecentlyTimeout.isRunning()) {
this._activeRecentlyTimeout.start();
this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout);
} else {
this._activeRecentlyTimeout.restart();
}
}
async _runTimersUntilTimeout(attachedTimers, timeout) {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();
} catch (_e) { /* aborted */ }
attachedTimers.forEach((t) => t.abort());
}
}
module.exports = new UserActivity();

View file

@ -1,93 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 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 Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg';
/*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
*/
export default {
loadProfileInfo: function() {
const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId);
},
saveDisplayName: function(newDisplayname) {
return MatrixClientPeg.get().setDisplayName(newDisplayname);
},
loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) {
return Promise.resolve({
threepids: [],
}); // guests can't poke 3pid endpoint
}
return MatrixClientPeg.get().getThreePids();
},
saveThreePids: function(threePids) {
// TODO
},
changePassword: function(oldPassword, newPassword) {
const cli = MatrixClientPeg.get();
const authDict = {
type: 'm.login.password',
user: cli.credentials.userId,
password: oldPassword,
};
return cli.setPassword(authDict, newPassword);
},
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
* one such pusher.
*/
getEmailPusher: function(pushers, address) {
if (pushers === undefined) {
return undefined;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
return undefined;
},
hasEmailPusher: function(pushers, address) {
return this.getEmailPusher(pushers, address) !== undefined;
},
addEmailPusher: function(address, data) {
return MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
lang: navigator.language,
data: data,
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
},
};

View file

@ -1,7 +1,7 @@
const React = require('react');
const ReactDom = require('react-dom');
import PropTypes from 'prop-types';
const Velocity = require('velocity-vector');
const Velocity = require('velocity-animate');
/**
* The Velociraptor contains components and animates transitions with velocity.

View file

@ -1,4 +1,4 @@
const Velocity = require('velocity-vector');
const Velocity = require('velocity-animate');
// courtesy of https://github.com/julianshapiro/velocity/issues/283
// We only use easeOutBounce (easeInBounce is just sort of nonsensical)

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,6 +22,11 @@ limitations under the License.
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
import Modal from "./Modal";
import MatrixClientPeg from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore";
import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetUtils from "./utils/WidgetUtils";
if (!global.mxFromWidgetMessaging) {
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
@ -34,12 +40,14 @@ if (!global.mxToWidgetMessaging) {
const OUTBOUND_API_NAME = 'toWidget';
export default class WidgetMessaging {
constructor(widgetId, widgetUrl, target) {
constructor(widgetId, widgetUrl, isUserWidget, target) {
this.widgetId = widgetId;
this.widgetUrl = widgetUrl;
this.isUserWidget = isUserWidget;
this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
this.toWidget = global.mxToWidgetMessaging;
this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
this.start();
}
@ -109,9 +117,57 @@ export default class WidgetMessaging {
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
}
async _onOpenIdRequest(ev, rawEv) {
if (ev.widgetId !== this.widgetId) return; // not interesting
const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget);
const settings = SettingsStore.getValue("widgetOpenIDPermissions");
if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
this.fromWidget.sendResponse(rawEv, {state: "blocked"});
return;
}
if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
const responseBody = {state: "allowed"};
const credentials = await MatrixClientPeg.get().getOpenIdToken();
Object.assign(responseBody, credentials);
this.fromWidget.sendResponse(rawEv, responseBody);
return;
}
// Confirm that we received the request
this.fromWidget.sendResponse(rawEv, {state: "request"});
// Actually ask for permission to send the user's data
Modal.createTrackedDialog("OpenID widget permissions", '',
WidgetOpenIDPermissionsDialog, {
widgetUrl: this.widgetUrl,
widgetId: this.widgetId,
isUserWidget: this.isUserWidget,
onFinished: async (confirm) => {
const responseBody = {success: confirm};
if (confirm) {
const credentials = await MatrixClientPeg.get().getOpenIdToken();
Object.assign(responseBody, credentials);
}
this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "openid_credentials",
data: responseBody,
}).catch((error) => {
console.error("Failed to send OpenID credentials: ", error);
});
},
},
);
}
}

View file

@ -131,6 +131,24 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
return { action: 'MatrixActions.Room.tags', room };
}
/**
* Create a MatrixActions.Room.receipt action that represents a MatrixClient
* `Room.receipt` event, each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {MatrixEvent} event the receipt event.
* @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt.
*/
function createRoomReceiptAction(matrixClient, event, room) {
return {
action: 'MatrixActions.Room.receipt',
event,
room,
matrixClient,
};
}
/**
* @typedef RoomTimelineAction
* @type {Object}
@ -233,6 +251,7 @@ export default {
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);

View file

@ -240,7 +240,6 @@ export default React.createClass({
_renderPhasePassPhrase: function() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let strengthMeter;
let helpText;
@ -252,9 +251,7 @@ export default React.createClass({
for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) {
suggestions.push(<div key={i}>{this.state.zxcvbnResult.feedback.suggestions[i]}</div>);
}
const suggestionBlock = suggestions.length > 0 ? <div>
{suggestions}
</div> : null;
const suggestionBlock = <div>{suggestions.length > 0 ? suggestions : _t("Keep going...")}</div>;
helpText = <div>
{this.state.zxcvbnResult.feedback.warning}
@ -267,8 +264,15 @@ export default React.createClass({
}
return <div>
<p>{_t("Secure your encrypted message history with a Recovery Passphrase.")}</p>
<p>{_t("You'll need it if you log out or lose access to this device.")}</p>
<p>{_t(
"<b>Warning</b>: you should only set up key backup from a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"We'll store an encrypted copy of your keys on our server. " +
"Protect your backup with a passphrase to keep it secure.",
)}</p>
<p>{_t("For maximum security, this should be different from your account password.")}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -293,34 +297,12 @@ export default React.createClass({
disabled={!this._passPhraseIsValid()}
/>
<p>{_t(
"If you don't want encrypted message history to be available on other devices, "+
"<button>opt out</button>.",
{},
{
button: sub => <AccessibleButton
element="span"
className="mx_linkButton"
onClick={this._onOptOutClick}
>
{sub}
</AccessibleButton>,
},
)}</p>
<p>{_t(
"Or, if you don't want to create a Recovery Passphrase, skip this step and "+
"<button>download a recovery key</button>.",
{},
{
button: sub => <AccessibleButton
element="span"
className="mx_linkButton"
onClick={this._onSkipPassPhraseClick}
>
{sub}
</AccessibleButton>,
},
)}</p>
<details>
<summary>{_t("Advanced")}</summary>
<p><button onClick={this._onSkipPassPhraseClick} >
{_t("Set up with a Recovery Key")}
</button></p>
</details>
</div>;
},
@ -355,9 +337,7 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<p>{_t(
"Type in your Recovery Passphrase to confirm you remember it. " +
"If it helps, add it to your password manager or store it " +
"somewhere safe.",
"Please enter your passphrase a second time to confirm.",
)}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer">
@ -394,7 +374,13 @@ export default React.createClass({
}
return <div>
<p>{_t("Make a copy of this Recovery Key and keep it safe.")}</p>
<p>{_t(
"Your recovery key is a safety net - you can use it to restore " +
"access to your encrypted messages if you forget your passphrase.",
)}</p>
<p>{_t(
"Keep your recovery key somewhere very secure, like a password manager (or a safe)",
)}</p>
<p>{bodyText}</p>
<div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKeyHeader">
@ -457,10 +443,9 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div>
<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')}
"Your keys are being backed up (the first backup could take a few minutes).",
)}</p>
<DialogButtons primaryButton={_t('Okay')}
onPrimaryButtonClick={this._onDone}
hasCancel={false}
/>
@ -486,19 +471,19 @@ export default React.createClass({
_titleForPhase: function(phase) {
switch (phase) {
case PHASE_PASSPHRASE:
return _t('Create a Recovery Passphrase');
return _t('Secure your backup with a passphrase');
case PHASE_PASSPHRASE_CONFIRM:
return _t('Confirm Recovery Passphrase');
return _t('Confirm your passphrase');
case PHASE_OPTOUT_CONFIRM:
return _t('Warning!');
case PHASE_SHOWKEY:
return _t('Recovery Key');
return _t('Recovery key');
case PHASE_KEEPITSAFE:
return _t('Keep it safe');
case PHASE_BACKINGUP:
return _t('Starting backup...');
case PHASE_DONE:
return _t('Backup Started');
return _t('Success!');
default:
return _t("Create Key Backup");
}

View file

@ -39,36 +39,8 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
}
onSetupClick = async () => {
// TODO: Should change to a restore key backup flow that checks the
// recovery passphrase while at the same time also cross-signing the
// device as well in a single flow. Since we don't have that yet, we'll
// look for an unverified device and verify it. Note that this means
// we won't restore keys yet; instead we'll only trust the backup for
// sending our own new keys to it.
let backupSigStatus;
try {
backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(this.props.newVersionInfo);
} catch (e) {
console.log("Unable to fetch key backup status", e);
return;
}
let unverifiedDevice;
for (const sig of backupSigStatus.sigs) {
if (!sig.device.isVerified()) {
unverifiedDevice = sig.device;
break;
}
}
if (!unverifiedDevice) {
console.log("Unable to find a device to verify.");
return;
}
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, {
userId: MatrixClientPeg.get().credentials.userId,
device: unverifiedDevice,
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {
onFinished: this.props.onFinished,
});
}
@ -111,11 +83,6 @@ export default class NewRecoveryMethodDialog extends React.PureComponent {
} 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")}

View file

@ -97,7 +97,7 @@ export default class EmojiProvider extends AutocompleteProvider {
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}

View file

@ -56,7 +56,7 @@ export default class RoomProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.matcher.setObjects(client.getRooms().filter(
let matcherObjects = client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
@ -64,7 +64,21 @@ export default class RoomProvider extends AutocompleteProvider {
name: room.name,
displayedAlias: getDisplayAliasForRoom(room),
};
}));
});
// Filter out any matches where the user will have also autocompleted new rooms
matcherObjects = matcherObjects.filter((r) => {
const tombstone = r.room.currentState.getStateEvents("m.room.tombstone", "");
if (tombstone && tombstone.getContent() && tombstone.getContent()['replacement_room']) {
const hasReplacementRoom = matcherObjects.some(
(r2) => r2.room.roomId === tombstone.getContent()['replacement_room'],
);
return !hasReplacementRoom;
}
return true;
});
this.matcher.setObjects(matcherObjects);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [

View file

@ -20,6 +20,7 @@ import React from "react";
// Copyright (c) Noel Delgado <pixelia.me@gmail.com> (pixelia.me)
function getScrollbarWidth(alternativeOverflow) {
const div = document.createElement('div');
div.className = 'mx_AutoHideScrollbar'; //to get width of css scrollbar
div.style.position = 'absolute';
div.style.top = '-9999px';
div.style.width = '100px';
@ -101,10 +102,6 @@ export default class AutoHideScrollbar extends React.Component {
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();
}
@ -117,17 +114,16 @@ export default class AutoHideScrollbar extends React.Component {
}
}
componentWillUnmount() {
if (this._needsOverflowListener && this.containerRef) {
this.containerRef.removeEventListener("overflow", this.onOverflow);
this.containerRef.removeEventListener("underflow", this.onUnderflow);
}
getScrollTop() {
return this.containerRef.scrollTop;
}
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }

View file

@ -19,8 +19,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import sdk from '../../index';
import dis from '../../dispatcher';
import Velocity from 'velocity-vector';
import 'velocity-vector/velocity.ui';
import Velocity from 'velocity-animate';
import 'velocity-animate/velocity.ui';
import SettingsStore from '../../settings/SettingsStore';
const CALLOUT_ANIM_DURATION = 1000;
@ -145,8 +145,8 @@ module.exports = React.createClass({
// Get the label/tooltip to show
getLabel: function(label, show) {
if (show) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
return <RoomTooltip className="mx_BottomLeftMenu_tooltip" label={label} />;
const Tooltip = sdk.getComponent("elements.Tooltip");
return <Tooltip className="mx_BottomLeftMenu_tooltip" label={label} />;
}
},
@ -170,7 +170,7 @@ module.exports = React.createClass({
const SettingsButton = sdk.getComponent('elements.SettingsButton');
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const groupsButton = SettingsStore.getValue("TagPanel.disableTagPanel") ?
const groupsButton = !SettingsStore.getValue("TagPanel.enableTagPanel") ?
<GroupsButton tooltip={true} /> : null;
return (

View file

@ -41,26 +41,30 @@ module.exports = React.createClass({
<div className="mx_CompatibilityPage_box">
<p>{ _t("Sorry, your browser is <b>not</b> able to run Riot.", {}, { 'b': (sub) => <b>{sub}</b> }) } </p>
<p>
{ _t("Riot uses many advanced browser features, some of which are not available or experimental in your current browser.") }
{ _t(
"Riot uses many advanced browser features, some of which are not available " +
"or experimental in your current browser.",
) }
</p>
<p>
{ _t('Please install <chromeLink>Chrome</chromeLink> or <firefoxLink>Firefox</firefoxLink> for the best experience.',
{ _t(
'Please install <chromeLink>Chrome</chromeLink>, <firefoxLink>Firefox</firefoxLink>, ' +
'or <safariLink>Safari</safariLink> for the best experience.',
{},
{
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
'firefoxLink': (sub) => <a href="https://getfirefox.com">{sub}</a>,
},
)}
{ _t('<safariLink>Safari</safariLink> and <operaLink>Opera</operaLink> work too.',
{},
{
'safariLink': (sub) => <a href="http://apple.com/safari">{sub}</a>,
'operaLink': (sub) => <a href="http://opera.com">{sub}</a>,
'firefoxLink': (sub) => <a href="https://firefox.com">{sub}</a>,
'safariLink': (sub) => <a href="https://apple.com/safari">{sub}</a>,
},
)}
</p>
<p>
{ _t("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!") }
{ _t(
"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!",
) }
</p>
<button onClick={this.onAccept}>
{ _t("I understand the risks and wish to continue") }

View file

@ -56,6 +56,7 @@ export default class ContextualMenu extends React.Component {
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
@ -215,16 +216,22 @@ export default class ContextualMenu extends React.Component {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
const wrapperStyle = {};
if (!isNaN(Number(props.zIndex))) {
menuStyle["zIndex"] = props.zIndex + 1;
wrapperStyle["zIndex"] = props.zIndex;
}
const ElementClass = props.elementClass;
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
return <div className={className} style={position}>
return <div className={className} style={{...position, ...wrapperStyle}}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background"
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;

View file

@ -0,0 +1,125 @@
/*
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 CustomRoomTagStore from '../../stores/CustomRoomTagStore';
import AutoHideScrollbar from './AutoHideScrollbar';
import sdk from '../../index';
import dis from '../../dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
class CustomRoomTagPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: CustomRoomTagStore.getSortedTags(),
};
}
componentWillMount() {
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({tags: CustomRoomTagStore.getSortedTags()});
});
}
componentWillUnmount() {
if (this._tagStoreToken) {
this._tagStoreToken.remove();
}
}
render() {
const tags = this.state.tags.map((tag) => {
return (<CustomRoomTagTile tag={tag} key={tag.name} />);
});
const classes = classNames('mx_CustomRoomTagPanel', {
mx_CustomRoomTagPanel_empty: this.state.tags.length === 0,
});
return (<div className={classes}>
<div className="mx_CustomRoomTagPanel_divider" />
<AutoHideScrollbar className="mx_CustomRoomTagPanel_scroller">
{tags}
</AutoHideScrollbar>
</div>);
}
}
class CustomRoomTagTile extends React.Component {
constructor(props) {
super(props);
this.state = {hover: false};
this.onClick = this.onClick.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onMouseOver = this.onMouseOver.bind(this);
}
onMouseOver() {
this.setState({hover: true});
}
onMouseOut() {
this.setState({hover: false});
}
onClick() {
dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name});
}
render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Tooltip = sdk.getComponent('elements.Tooltip');
const tag = this.props.tag;
const avatarHeight = 40;
const className = classNames({
CustomRoomTagPanel_tileSelected: tag.selected,
});
const name = tag.name;
const badge = tag.badge;
let badgeElement;
if (badge) {
const badgeClasses = classNames({
"mx_TagTile_badge": true,
"mx_TagTile_badgeHighlight": badge.highlight,
});
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
}
const tip = (this.state.hover ?
<Tooltip className="mx_TagTile_tooltip" label={name} /> :
<div />);
return (
<AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar
name={tag.avatarLetter}
idName={name}
width={avatarHeight}
height={avatarHeight}
/>
{ badgeElement }
{ tip }
</div>
</AccessibleButton>
);
}
}
export default CustomRoomTagPanel;

View file

@ -0,0 +1,116 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
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.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import sdk from '../../index';
import { MatrixClient } from 'matrix-js-sdk';
import classnames from 'classnames';
export default class EmbeddedPage extends React.PureComponent {
static propTypes = {
// URL to request embedded page content from
url: PropTypes.string,
// Class name prefix to apply for a given instance
className: PropTypes.string,
// Whether to wrap the page in a scrollbar
scrollbar: PropTypes.bool,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
constructor(props) {
super(props);
this.state = {
page: '',
};
}
translate(s) {
// default implementation - skins may wish to extend this
return sanitizeHtml(_t(s));
}
componentWillMount() {
this._unmounted = false;
if (!this.props.url) {
return;
}
// we use request() to inline the page into the react component
// so that it can inherit CSS and theming easily rather than mess around
// with iframes and trying to synchronise document.stylesheets.
request(
{ method: "GET", url: this.props.url },
(err, response, body) => {
if (this._unmounted) {
return;
}
if (err || response.status < 200 || response.status >= 300) {
console.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1)=>this.translate(g1));
this.setState({ page: body });
},
);
}
componentWillUnmount() {
this._unmounted = true;
}
render() {
const client = this.context.matrixClient;
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
[className]: true,
[`${className}_guest`]: isGuest,
});
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
>
</div>;
if (this.props.scrollbar) {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
return <GeminiScrollbarWrapper autoshow={true} className={classes}>
{content}
</GeminiScrollbarWrapper>;
} else {
return <div className={classes}>
{content}
</div>;
}
}
}

View file

@ -123,6 +123,7 @@ const FilePanel = React.createClass({
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
/>
);

View file

@ -1,127 +0,0 @@
/*
Copyright 2017 Vector Creations 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 OpenRoomsStore from '../../stores/OpenRoomsStore';
import dis from '../../dispatcher';
import {_t} from '../../languageHandler';
import RoomView from './RoomView';
import classNames from 'classnames';
import MainSplit from './MainSplit';
import RightPanel from './RightPanel';
import RoomHeaderButtons from '../views/right_panel/RoomHeaderButtons';
export default class RoomGridView extends React.Component {
constructor(props) {
super(props);
this.state = {
roomStores: OpenRoomsStore.getRoomStores(),
activeRoomStore: OpenRoomsStore.getActiveRoomStore(),
};
this.onRoomsChanged = this.onRoomsChanged.bind(this);
}
componentDidUpdate(_, prevState) {
const store = this.state.activeRoomStore;
if (store) {
store.getDispatcher().dispatch({action: 'focus_composer'});
}
}
componentDidMount() {
this.componentDidUpdate();
}
componentWillMount() {
this._unmounted = false;
this._openRoomsStoreRegistration = OpenRoomsStore.addListener(this.onRoomsChanged);
}
componentWillUnmount() {
this._unmounted = true;
if (this._openRoomsStoreRegistration) {
this._openRoomsStoreRegistration.remove();
}
}
onRoomsChanged() {
if (this._unmounted) return;
this.setState({
roomStores: OpenRoomsStore.getRoomStores(),
activeRoomStore: OpenRoomsStore.getActiveRoomStore(),
});
}
_setActive(i) {
const store = OpenRoomsStore.getRoomStoreAt(i);
if (store !== this.state.activeRoomStore) {
dis.dispatch({
action: 'group_grid_set_active',
room_id: store.getRoomId(),
});
}
}
render() {
let roomStores = this.state.roomStores.slice(0, 6);
const emptyCount = 6 - roomStores.length;
if (emptyCount) {
const emptyTiles = Array.from({length: emptyCount}, () => null);
roomStores = roomStores.concat(emptyTiles);
}
const activeRoomId = this.state.activeRoomStore && this.state.activeRoomStore.getRoomId();
let rightPanel;
if (activeRoomId) {
rightPanel = (
<div className="mx_GroupGridView_rightPanel">
<div className="mx_GroupGridView_tabs"><RoomHeaderButtons /></div>
<RightPanel roomId={activeRoomId} />
</div>
);
}
return (<main className="mx_GroupGridView">
<MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs} >
<div className="mx_GroupGridView_rooms">
{ roomStores.map((roomStore, i) => {
if (roomStore) {
const isActive = roomStore === this.state.activeRoomStore;
const tileClasses = classNames({
"mx_GroupGridView_tile": true,
"mx_GroupGridView_activeTile": isActive,
});
return (<section
onClick={() => {this._setActive(i);}}
key={roomStore.getRoomId()}
className={tileClasses}
>
<RoomView
collapsedRhs={this.props.collapsedRhs}
isGrid={true}
roomViewStore={roomStore}
isActive={isActive}
/>
</section>);
} else {
return (<section className={"mx_GroupGridView_emptyTile"} key={`empty-${i}`}>{_t("No room in this tile yet.")}</section>);
}
}) }
</div>
</MainSplit>
</main>);
}
}

View file

@ -21,6 +21,7 @@ import Promise from 'bluebird';
import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import dis from '../../dispatcher';
import { getHostingLink } from '../../utils/HostingLink';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
@ -34,6 +35,7 @@ import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
import {Group} from "matrix-js-sdk";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -125,7 +127,7 @@ const CategoryRoomList = React.createClass({
(<AccessibleButton className="mx_GroupView_featuredThings_addButton"
onClick={this.onAddRoomsToSummaryClicked}
>
<TintableSvg src="img/icons-create-room.svg" width="64" height="64" />
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a Room') }
</div>
@ -226,7 +228,7 @@ const FeaturedRoom = React.createClass({
const deleteButton = this.props.editing ?
<img
className="mx_GroupView_featuredThing_deleteButton"
src="img/cancel-small.svg"
src={require("../../../res/img/cancel-small.svg")}
width="14"
height="14"
alt="Delete"
@ -300,7 +302,7 @@ const RoleUserList = React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddUsersClicked}>
<TintableSvg src="img/icons-create-room.svg" width="64" height="64" />
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a User') }
</div>
@ -379,7 +381,7 @@ const FeaturedUser = React.createClass({
const deleteButton = this.props.editing ?
<img
className="mx_GroupView_featuredThing_deleteButton"
src="img/cancel-small.svg"
src={require("../../../res/img/cancel-small.svg")}
width="14"
height="14"
alt="Delete"
@ -569,7 +571,7 @@ export default React.createClass({
_onShareClick: function() {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
target: this._matrixClient.getGroup(this.props.groupId),
target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId),
});
},
@ -815,6 +817,23 @@ export default React.createClass({
});
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
const hostingSignupLink = getHostingLink('community-settings');
let hostingSignup = null;
if (hostingSignupLink && this.state.isUserPrivileged) {
hostingSignup = <div className="mx_GroupView_hostingSignup">
{_t(
"Want more than a community? <a>Get your own server</a>", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
}
const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ?
<div className="mx_GroupView_changeDelayWarning">
{ _t(
@ -829,6 +848,7 @@ export default React.createClass({
</div> : <div />;
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
@ -855,7 +875,7 @@ export default React.createClass({
onClick={this._onAddRoomsClick}
>
<div className="mx_GroupView_rooms_header_addRow_button">
<TintableSvg src="img/icons-room-add.svg" width="24" height="24" />
<TintableSvg src={require("../../../res/img/icons-room-add.svg")} width="24" height="24" />
</div>
<div className="mx_GroupView_rooms_header_addRow_label">
{ _t('Add rooms to this community') }
@ -1157,7 +1177,6 @@ export default React.createClass({
render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
@ -1178,7 +1197,7 @@ export default React.createClass({
avatarImage = <GroupAvatar groupId={this.props.groupId}
groupName={this.state.profileForm.name}
groupAvatarUrl={this.state.profileForm.avatar_url}
width={48} height={48} resizeMethod='crop'
width={28} height={28} resizeMethod='crop'
/>;
}
@ -1189,7 +1208,7 @@ export default React.createClass({
</label>
<div className="mx_GroupView_avatarPicker_edit">
<label htmlFor="avatarInput" className="mx_GroupView_avatarPicker_label">
<img src="img/camera.svg"
<img src={require("../../../res/img/camera.svg")}
alt={_t("Upload avatar")} title={_t("Upload avatar")}
width="17" height="15" />
</label>
@ -1228,7 +1247,7 @@ export default React.createClass({
groupAvatarUrl={groupAvatarUrl}
groupName={groupName}
onClick={onGroupHeaderItemClick}
width={48} height={48}
width={28} height={28}
/>;
if (summary.profile && summary.profile.name) {
nameNode = <div onClick={onGroupHeaderItemClick}>
@ -1248,30 +1267,38 @@ export default React.createClass({
if (this.state.editing) {
rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
onClick={this._onSaveClick} key="_saveButton"
key="_saveButton"
onClick={this._onSaveClick}
>
{ _t('Save') }
</AccessibleButton>,
);
rightButtons.push(
<AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this._onCancelClick} key="_cancelButton">
<img src="img/cancel.svg" className="mx_filterFlipColor"
<AccessibleButton className="mx_RoomHeader_cancelButton"
key="_cancelButton"
onClick={this._onCancelClick}
>
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
width="18" height="18" alt={_t("Cancel")} />
</AccessibleButton>,
);
} else {
if (summary.user && summary.user.membership === 'join') {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
onClick={this._onEditClick} title={_t("Community Settings")} key="_editButton"
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
key="_editButton"
onClick={this._onEditClick}
title={_t("Community Settings")}
>
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16" />
</AccessibleButton>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" onClick={this._onShareClick} title={_t('Share Community')} key="_shareButton">
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
key="_shareButton"
onClick={this._onShareClick}
title={_t('Share Community')}
>
</AccessibleButton>,
);
}
@ -1324,7 +1351,7 @@ export default React.createClass({
} else {
let extraText;
if (this.state.error.errcode === 'M_UNRECOGNIZED') {
extraText = <div>{ _t('This Home server does not support communities') }</div>;
extraText = <div>{ _t('This homeserver does not support communities') }</div>;
}
return (
<div className="mx_GroupView_error">

View file

@ -1,155 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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 request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import sdk from '../../index';
import { MatrixClient } from 'matrix-js-sdk';
import dis from '../../dispatcher';
class HomePage extends React.Component {
static displayName = 'HomePage';
static propTypes = {
// URL base of the team server. Optional.
teamServerUrl: PropTypes.string,
// Team token. Optional. If set, used to get the static homepage of the team
// associated. If unset, homePageUrl will be used.
teamToken: PropTypes.string,
// URL to use as the iFrame src. Defaults to /home.html.
homePageUrl: PropTypes.string,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
state = {
iframeSrc: '',
page: '',
};
translate(s) {
// default implementation - skins may wish to extend this
return sanitizeHtml(_t(s));
}
componentWillMount() {
this._unmounted = false;
if (this.props.teamToken && this.props.teamServerUrl) {
this.setState({
iframeSrc: `${this.props.teamServerUrl}/static/${this.props.teamToken}/home.html`,
});
} else {
// we use request() to inline the homepage into the react component
// so that it can inherit CSS and theming easily rather than mess around
// with iframes and trying to synchronise document.stylesheets.
const src = this.props.homePageUrl || 'home.html';
request(
{ method: "GET", url: src },
(err, response, body) => {
if (this._unmounted) {
return;
}
if (err || response.status < 200 || response.status >= 300) {
console.warn(`Error loading home page: ${err}`);
this.setState({ page: _t("Couldn't load home page") });
return;
}
body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1)=>this.translate(g1));
this.setState({ page: body });
},
);
}
}
componentWillUnmount() {
this._unmounted = true;
}
onLoginClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_login' });
}
onRegisterClick(ev) {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ action: 'start_registration' });
}
render() {
let guestWarning = "";
if (this.context.matrixClient.isGuest()) {
guestWarning = (
<div className="mx_HomePage_guest_warning">
<img src="img/warning.svg" width="24" height="23" />
<div>
<div>
{ _t("You are currently using Riot anonymously as a guest.") }
</div>
<div>
{ _t(
'If you would like to create a Matrix account you can <a>register</a> now.',
{},
{ 'a': (sub) => <a href="#" onClick={this.onRegisterClick}>{ sub }</a> },
) }
</div>
<div>
{ _t(
'If you already have a Matrix account you can <a>log in</a> instead.',
{},
{ 'a': (sub) => <a href="#" onClick={this.onLoginClick}>{ sub }</a> },
) }
</div>
</div>
</div>
);
}
if (this.state.iframeSrc) {
return (
<div className="mx_HomePage">
{ guestWarning }
<iframe src={ this.state.iframeSrc } />
</div>
);
} else {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
return (
<GeminiScrollbarWrapper autoshow={true} className="mx_HomePage">
{ guestWarning }
<div className="mx_HomePage_body" dangerouslySetInnerHTML={{ __html: this.state.page }}>
</div>
</GeminiScrollbarWrapper>
);
}
}
}
module.exports = HomePage;

View file

@ -15,9 +15,17 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default class IndicatorScrollbar extends React.Component {
static PropTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
trackHorizontalOverflow: PropTypes.bool,
};
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
@ -25,6 +33,11 @@ export default class IndicatorScrollbar extends React.Component {
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this.state = {
leftIndicatorOffset: 0,
rightIndicatorOffset: 0,
};
}
_collectScroller(scroller) {
@ -43,6 +56,10 @@ export default class IndicatorScrollbar extends React.Component {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
const hasRightOverflow = this._scrollElement.scrollWidth >
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
@ -53,10 +70,34 @@ export default class IndicatorScrollbar extends React.Component {
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (hasLeftOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
}
if (hasRightOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
if (this.props.trackHorizontalOverflow) {
this.setState({
// Offset from absolute position of the container
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0',
// Negative because we're coming from the right
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
});
}
}
getScrollTop() {
return this._autoHideScrollbar.getScrollTop();
}
componentWillUnmount() {
@ -66,8 +107,21 @@ export default class IndicatorScrollbar extends React.Component {
}
render() {
return (<AutoHideScrollbar ref={this._collectScrollerComponent} wrappedRef={this._collectScroller} {... this.props}>
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
{... this.props}
>
{ leftOverflowIndicator }
{ this.props.children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}
}

View file

@ -20,7 +20,7 @@ const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
export default React.createClass({
displayName: 'InteractiveAuth',

View file

@ -24,8 +24,9 @@ import { KeyCode } from '../../Keyboard';
import sdk from '../../index';
import dis from '../../dispatcher';
import VectorConferenceHandler from '../../VectorConferenceHandler';
import TagPanelButtons from './TagPanelButtons';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
const LeftPanel = React.createClass({
@ -182,13 +183,25 @@ const LeftPanel = React.createClass({
render: function() {
const RoomList = sdk.getComponent('rooms.RoomList');
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
const TagPanel = sdk.getComponent('structures.TagPanel');
const CustomRoomTagPanel = sdk.getComponent('structures.CustomRoomTagPanel');
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const CallPreview = sdk.getComponent('voip.CallPreview');
const tagPanelEnabled = !SettingsStore.getValue("TagPanel.disableTagPanel");
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
let tagPanelContainer;
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
if (tagPanelEnabled) {
tagPanelContainer = (<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel />
{ isCustomTagsEnabled ? <CustomRoomTagPanel /> : undefined }
<TagPanelButtons />
</div>);
}
const containerClasses = classNames(
"mx_LeftPanel_container", "mx_fadable",
@ -199,19 +212,29 @@ const LeftPanel = React.createClass({
},
);
const searchBox = !this.props.collapsed ?
<SearchBox onSearch={ this.onSearch } onCleared={ this.onSearchCleared } /> :
undefined;
const searchBox = (<SearchBox
enableRoomSearchFocus={true}
placeholder={ _t('Filter room names') }
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
collapsed={this.props.collapsed} />);
let breadcrumbs;
if (SettingsStore.isFeatureEnabled("feature_room_breadcrumbs")) {
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
}
return (
<div className={containerClasses}>
{ tagPanel }
{ tagPanelContainer }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
{ breadcrumbs }
{ searchBox }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />

View file

@ -22,7 +22,6 @@ import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import sdk from '../../index';
@ -31,12 +30,12 @@ import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import OpenRoomsStore from "../../stores/OpenRoomsStore";
import { getHomePageUrl } from '../../utils/pages';
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'
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.
@ -58,13 +57,11 @@ const LoggedInView = React.createClass({
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
onUserSettingsClose: PropTypes.func,
// 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
viaServers: PropTypes.arrayOf(PropTypes.string),
@ -123,6 +120,18 @@ const LoggedInView = React.createClass({
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
},
componentDidUpdate(prevProps) {
// attempt to guess when a banner was opened or closed
if (
(prevProps.showCookieBar !== this.props.showCookieBar) ||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
) {
this.props.resizeNotifier.notifyBannersChanged();
}
},
componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData);
@ -161,18 +170,21 @@ const LoggedInView = React.createClass({
const classNames = {
handle: "mx_ResizeHandle",
vertical: "mx_ResizeHandle_vertical",
reverse: "mx_ResizeHandle_reverse"
reverse: "mx_ResizeHandle_reverse",
};
const collapseConfig = {
toggleSize: 260 - 50,
onCollapsed: (collapsed) => {
this.setState({collapseLhs: collapsed});
if (collapsed) {
dis.dispatch({action: "hide_left_panel"}, true);
window.localStorage.setItem("mx_lhs_size", '0');
} else {
dis.dispatch({action: "show_left_panel"}, true);
}
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.notifyLeftHandleResized();
},
};
const resizer = new Resizer(
@ -184,10 +196,13 @@ const LoggedInView = React.createClass({
},
_loadResizerPreferences() {
const lhsSize = window.localStorage.getItem("mx_lhs_size");
let lhsSize = window.localStorage.getItem("mx_lhs_size");
if (lhsSize !== null) {
this.resizer.forHandleAt(0).resize(parseInt(lhsSize, 10));
lhsSize = parseInt(lhsSize, 10);
} else {
lhsSize = 350;
}
this.resizer.forHandleAt(0).resize(lhsSize);
},
onAccountData: function(event) {
@ -202,7 +217,11 @@ const LoggedInView = React.createClass({
},
onSync: function(syncState, oldSyncState, data) {
const oldErrCode = this.state.syncErrorData && this.state.syncErrorData.error && this.state.syncErrorData.error.errcode;
const oldErrCode = (
this.state.syncErrorData &&
this.state.syncErrorData.error &&
this.state.syncErrorData.error.errcode
);
const newErrCode = data && data.error && data.error.errcode;
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
@ -311,12 +330,13 @@ const LoggedInView = React.createClass({
}
},
/** dispatch a page-up/page-down/etc to the appropriate component */
/**
* dispatch a page-up/page-down/etc to the appropriate component
* @param {Object} ev The key event
*/
_onScrollKeyPressed: function(ev) {
if (this.refs.roomView) {
this.refs.roomView.handleScrollKey(ev);
} else if (this.refs.roomDirectory) {
this.refs.roomDirectory.handleScrollKey(ev);
}
},
@ -413,11 +433,9 @@ const LoggedInView = React.createClass({
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const GroupGridView = sdk.getComponent('structures.GroupGridView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const CookieBar = sdk.getComponent('globals.CookieBar');
@ -426,18 +444,11 @@ const LoggedInView = React.createClass({
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element;
let pageElement;
switch (this.props.page_type) {
case PageTypes.RoomView:
if (!OpenRoomsStore.getActiveRoomStore()) {
console.warn(`LoggedInView: getCurrentRoomStore not set!`);
}
else if (OpenRoomsStore.getActiveRoomStore().getRoomId() !== this.props.currentRoomId) {
console.warn(`LoggedInView: room id in store not the same as in props: ${OpenRoomsStore.getActiveRoomStore().getRoomId()} & ${this.props.currentRoomId}`);
}
page_element = <RoomView
roomViewStore={OpenRoomsStore.getActiveRoomStore()}
pageElement = <RoomView
ref='roomView'
autoJoin={this.props.autoJoin}
onRegistered={this.props.onRegistered}
@ -449,54 +460,33 @@ const LoggedInView = React.createClass({
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier}
/>;
break;
case PageTypes.GroupGridView:
page_element = <GroupGridView collapsedRhs={this.props.collapsedRhs} />;
break;
case PageTypes.UserSettings:
page_element = <UserSettings
onClose={this.props.onCloseAllSettings}
brand={this.props.config.brand}
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
/>;
break;
case PageTypes.MyGroups:
page_element = <MyGroups />;
pageElement = <MyGroups />;
break;
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
ref="roomDirectory"
config={this.props.config.roomDirectory}
/>;
// handled by MatrixChat for now
break;
case PageTypes.HomePage:
{
// If team server config is present, pass the teamServerURL. props.teamToken
// must also be set for the team page to be displayed, otherwise the
// welcomePageUrl is used (which might be undefined).
const teamServerUrl = this.props.config.teamServerConfig ?
this.props.config.teamServerConfig.teamServerURL : null;
page_element = <HomePage
teamServerUrl={teamServerUrl}
teamToken={this.props.teamToken}
homePageUrl={this.props.config.welcomePageUrl}
const pageUrl = getHomePageUrl(this.props.config);
pageElement = <EmbeddedPage className="mx_HomePage"
url={pageUrl}
scrollbar={true}
/>;
}
break;
case PageTypes.UserView:
page_element = null; // deliberately null for now
// TODO: fix/remove UserView
// right_panel = <RightPanel disabled={this.props.rightDisabled} />;
pageElement = <UserView userId={this.props.currentUserId} />;
break;
case PageTypes.GroupView:
page_element = <GroupView
pageElement = <GroupView
groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapsedRhs}
@ -512,7 +502,6 @@ const LoggedInView = React.createClass({
});
let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact}
@ -536,7 +525,7 @@ const LoggedInView = React.createClass({
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />;
} else if (!isGuest && Notifier.supportsDesktopNotifications() && !Notifier.isEnabled() && !Notifier.isToolbarHidden()) {
} else if (this.props.showNotifierToolbar) {
topBar = <MatrixToolbar />;
}
@ -554,11 +543,12 @@ const LoggedInView = React.createClass({
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
collapsed={this.props.collapseLhs || this.state.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<ResizeHandle/>
{ page_element }
<ResizeHandle />
{ pageElement }
</div>
</DragDropContext>
</div>

View file

@ -27,6 +27,9 @@ export default class MainSplit extends React.Component {
_onResized(size) {
window.localStorage.setItem("mx_rhs_size", size);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.notifyRightHandleResized();
}
}
_createResizer() {
@ -71,13 +74,14 @@ export default class MainSplit extends React.Component {
}
componentDidUpdate(prevProps) {
const shouldAllowResizing =
!this.props.collapsedRhs &&
this.props.panel;
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 (shouldAllowResizing && !this.resizer) {
if (wasExpanded || wasPanelSet) {
this._createResizer();
} else if (!shouldAllowResizing && this.resizer) {
} else if (wasCollapsed || wasPanelCleared) {
this.resizer.detach();
this.resizer = null;
}

View file

@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import Tinter from "../../Tinter";
@ -40,6 +41,7 @@ import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
require('../../stores/LifecycleStore');
import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler';
@ -47,6 +49,8 @@ import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog";
const AutoDiscovery = Matrix.AutoDiscovery;
@ -60,27 +64,30 @@ const VIEWS = {
// trying to re-animate a matrix client or register as a guest.
LOADING: 0,
// we are showing the welcome view
WELCOME: 1,
// we are showing the login view
LOGIN: 1,
LOGIN: 2,
// we are showing the registration view
REGISTER: 2,
REGISTER: 3,
// completeing the registration flow
POST_REGISTRATION: 3,
POST_REGISTRATION: 4,
// showing the 'forgot password' view
FORGOT_PASSWORD: 4,
FORGOT_PASSWORD: 5,
// we have valid matrix credentials (either via an explicit login, via the
// initial re-animation/guest registration, or via a registration), and are
// now setting up a matrixclient to talk to it. This isn't an instant
// process because (a) we need to clear out indexeddb, and (b) we need to
// talk to the team server; while it is going on we show a big spinner.
LOGGING_IN: 5,
// process because we need to clear out indexeddb. While it is going on we
// show a big spinner.
LOGGING_IN: 6,
// we are logged in with an active matrix client.
LOGGED_IN: 6,
LOGGED_IN: 7,
};
// Actions that are redirected through the onboarding process prior to being
@ -136,10 +143,6 @@ export default React.createClass({
appConfig: PropTypes.object,
},
AuxPanel: {
RoomSettings: "room_settings",
},
getChildContext: function() {
return {
appConfig: this.props.config,
@ -161,7 +164,7 @@ export default React.createClass({
// If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null,
// this is persisted as mx_lhs_size, loaded in LoggedInView
collapseLhs: false,
collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true",
leftDisabled: false,
@ -183,7 +186,7 @@ export default React.createClass({
register_is_url: null,
register_id_sid: null,
// Parameters used for setting up the login/registration views
// Parameters used for setting up the authentication views
defaultServerName: this.props.config.default_server_name,
defaultHsUrl: this.props.config.default_hs_url,
defaultIsUrl: this.props.config.default_is_url,
@ -194,6 +197,8 @@ export default React.createClass({
hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(),
showNotifierToolbar: false,
};
return s;
},
@ -245,6 +250,17 @@ export default React.createClass({
return this.state.defaultIsUrl || "https://vector.im";
},
/**
* Whether to skip the server details phase of registration and start at the
* actual form.
* @return {boolean}
* If there was a configured default HS or default server name, skip the
* the server details.
*/
skipServerDetailsForRegistration() {
return !!this.state.defaultHsUrl;
},
componentWillMount: function() {
SdkConfig.put(this.props.config);
@ -256,42 +272,6 @@ export default React.createClass({
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
}
// To enable things like riot.im/geektime in a nicer way than rewriting the URL
// and appending a team token query parameter, use the first path segment to
// indicate a team, with "public" team tokens stored in the config teamTokenMap.
let routedTeamToken = null;
if (this.props.config.teamTokenMap) {
const teamName = window.location.pathname.split('/')[1];
if (teamName && this.props.config.teamTokenMap.hasOwnProperty(teamName)) {
routedTeamToken = this.props.config.teamTokenMap[teamName];
}
}
// Persist the team token across refreshes using sessionStorage. A new window or
// tab will not persist sessionStorage, but refreshes will.
if (this.props.startingFragmentQueryParams.team_token) {
window.sessionStorage.setItem(
'mx_team_token',
this.props.startingFragmentQueryParams.team_token,
);
}
// Use the locally-stored team token first, then as a fall-back, check to see if
// a referral link was used, which will contain a query parameter `team_token`.
this._teamToken = routedTeamToken ||
window.localStorage.getItem('mx_team_token') ||
window.sessionStorage.getItem('mx_team_token');
// Some users have ended up with "undefined" as their local storage team token,
// treat that as undefined.
if (this._teamToken === "undefined") {
this._teamToken = undefined;
}
if (this._teamToken) {
console.info(`Team token set to ${this._teamToken}`);
}
// Set up the default URLs (async)
if (this.getDefaultServerName() && !this.getDefaultHsUrl(false)) {
this.setState({loadingDefaultHomeserver: true});
@ -302,7 +282,10 @@ export default React.createClass({
// will check their settings.
this.setState({
defaultServerName: null, // To un-hide any secrets people might be keeping
defaultServerDiscoveryError: _t("Invalid configuration: Cannot supply a default homeserver URL and a default server name"),
defaultServerDiscoveryError: _t(
"Invalid configuration: Cannot supply a default homeserver URL and " +
"a default server name",
),
});
}
@ -338,6 +321,9 @@ export default React.createClass({
// N.B. we don't call the whole of setTheme() here as we may be
// racing with the theme CSS download finishing from index.js
Tinter.tint();
// For PersistentElement
this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize);
},
componentDidMount: function() {
@ -357,9 +343,6 @@ export default React.createClass({
linkifyMatrix.onGroupClick = this.onGroupClick;
}
const teamServerConfig = this.props.config.teamServerConfig || {};
Lifecycle.initRtsClient(teamServerConfig.teamServerURL);
// the first thing to do is to try the token params in the query-string
Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => {
if (loggedIn) {
@ -382,25 +365,7 @@ export default React.createClass({
return;
}
// the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
return Promise.resolve().then(() => {
return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getCurrentHsUrl(),
guestIsUrl: this.getCurrentIsUrl(),
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
}).then((loadedSession) => {
if (!loadedSession) {
// fall back to showing the login screen
dis.dispatch({action: "start_login"});
}
});
// Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want
// to try logging out.
return this._loadSession();
});
if (SettingsStore.getValue("showCookieBar")) {
@ -414,11 +379,34 @@ export default React.createClass({
}
},
_loadSession: function() {
// the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
return Promise.resolve().then(() => {
return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getCurrentHsUrl(),
guestIsUrl: this.getCurrentIsUrl(),
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
}).then((loadedSession) => {
if (!loadedSession) {
// fall back to showing the welcome screen
dis.dispatch({action: "view_welcome_page"});
}
});
// Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want
// to try logging out.
},
componentWillUnmount: function() {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
},
componentWillUpdate: function(props, state) {
@ -607,31 +595,15 @@ export default React.createClass({
case 'view_indexed_room':
this._viewIndexedRoom(payload.roomIndex);
break;
case 'view_user_settings':
this._setPage(PageTypes.UserSettings);
this.notifyNewScreen('settings');
break;
case 'close_settings':
this.setState({
leftDisabled: false,
rightDisabled: false,
middleDisabled: false,
});
if (this.state.page_type === PageTypes.UserSettings) {
// We do this to get setPage and notifyNewScreen
if (this.state.currentRoomId) {
this._viewRoom({
room_id: this.state.currentRoomId,
});
} else if (this.state.currentGroupId) {
this._viewGroup({
group_id: this.state.currentGroupId,
});
} else {
this._viewHome();
}
}
case 'view_user_settings': {
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog',
/*isPriority=*/false, /*isStatic=*/true);
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
break;
}
case 'view_create_room':
this._createRoom();
break;
@ -640,10 +612,16 @@ export default React.createClass({
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
}
break;
case 'view_room_directory':
this._setPage(PageTypes.RoomDirectory);
this.notifyNewScreen('directory');
break;
case 'view_room_directory': {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {
config: this.props.config,
}, 'mx_RoomDirectory_dialogWrapper');
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
}
break;
case 'view_my_groups':
this._setPage(PageTypes.MyGroups);
this.notifyNewScreen('groups');
@ -651,8 +629,8 @@ export default React.createClass({
case 'view_group':
this._viewGroup(payload);
break;
case 'group_grid_view':
this._viewGroupGrid(payload);
case 'view_welcome_page':
this._viewWelcome();
break;
case 'view_home_page':
this._viewHome();
@ -669,8 +647,9 @@ export default React.createClass({
case 'view_invite':
showRoomInviteDialog(payload.roomId);
break;
case 'notifier_enabled':
this.forceUpdate();
case 'notifier_enabled': {
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
}
break;
case 'hide_left_panel':
this.setState({
@ -702,11 +681,9 @@ export default React.createClass({
});
break;
}
// case 'set_theme':
// disable changing the theme for now
// as other themes are not compatible with dharma
// this._onSetTheme(payload.value);
// break;
case 'set_theme':
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
@ -716,7 +693,7 @@ export default React.createClass({
});
break;
case 'on_logged_in':
this._onLoggedIn(payload.teamToken);
this._onLoggedIn();
break;
case 'on_logged_out':
this._onLoggedOut();
@ -865,9 +842,9 @@ export default React.createClass({
// room name and avatar from an invite email)
_viewRoom: function(roomInfo) {
this.focusComposer = true;
console.log("!!! MatrixChat._viewRoom", roomInfo);
const newState = {
view: VIEWS.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView,
thirdPartyInvite: roomInfo.third_party_invite,
@ -914,9 +891,6 @@ export default React.createClass({
if (roomInfo.event_id && roomInfo.highlighted) {
presentedId += "/" + roomInfo.event_id;
}
// TODO: only emit this when we're not in grid mode?
this.notifyNewScreen('room/' + presentedId);
newState.ready = true;
this.setState(newState);
@ -933,9 +907,21 @@ export default React.createClass({
this.notifyNewScreen('group/' + groupId);
},
_viewGroupGrid: function(payload) {
this._setPage(PageTypes.GroupGridView);
// this.notifyNewScreen('grid/' + payload.group_id);
_viewSomethingBehindModal() {
if (this.state.view !== VIEWS.LOGGED_IN) {
this._viewWelcome();
return;
}
if (!this.state.currentGroupId && !this.state.currentRoomId) {
this._viewHome();
}
},
_viewWelcome() {
this.setStateForNewView({
view: VIEWS.WELCOME,
});
this.notifyNewScreen('welcome');
},
_viewHome: function() {
@ -1011,11 +997,11 @@ export default React.createClass({
}
dis.dispatch({
action: 'require_registration',
// If the set_mxid dialog is cancelled, view /home because if the browser
// was pointing at /user/@someone:domain?action=chat, the URL needs to be
// reset so that they can revisit /user/.. // (and trigger
// If the set_mxid dialog is cancelled, view /welcome because if the
// browser was pointing at /user/@someone:domain?action=chat, the URL
// needs to be reset so that they can revisit /user/.. // (and trigger
// `_chatCreateOrReuse` again)
go_home_on_cancel: true,
go_welcome_on_cancel: true,
});
return;
}
@ -1082,34 +1068,48 @@ export default React.createClass({
button: _t("Leave"),
onFinished: (shouldLeave) => {
if (shouldLeave) {
const d = MatrixClientPeg.get().leave(roomId);
const d = MatrixClientPeg.get().leaveRoomChain(roomId);
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(() => {
d.then((errors) => {
modal.close();
for (const leftRoomId of Object.keys(errors)) {
const err = errors[leftRoomId];
if (!err) continue;
console.error("Failed to leave room " + leftRoomId + " " + err);
let title = _t("Failed to leave room");
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
title = _t("Can't leave Server Notices room");
message = _t(
"This room is used for important messages from the Homeserver, " +
"so you cannot leave it.",
);
} else if (err && err.message) {
message = err.message;
}
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
title: title,
description: message,
});
return;
}
if (this.state.currentRoomId === roomId) {
dis.dispatch({action: 'view_next_room'});
}
}, (err) => {
// This should only happen if something went seriously wrong with leaving the chain.
modal.close();
console.error("Failed to leave room " + roomId + " " + err);
let title = _t("Failed to leave room");
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
title = _t("Can't leave Server Notices room");
message = _t(
"This room is used for important messages from the Homeserver, " +
"so you cannot leave it.",
);
} else if (err && err.message) {
message = err.message;
}
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
title: title,
description: message,
title: _t("Failed to leave room"),
description: _t("Unknown error"),
});
});
}
@ -1195,16 +1195,10 @@ export default React.createClass({
/**
* Called when a new logged in session has started
*
* @param {string} teamToken
*/
_onLoggedIn: async function(teamToken) {
this.setStateForNewView({view: VIEWS.LOGGED_IN});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
dis.dispatch({action: 'view_home_page'});
} else if (this._is_registered) {
_onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
@ -1243,7 +1237,15 @@ export default React.createClass({
room_id: localStorage.getItem('mx_last_room_id'),
});
} else {
dis.dispatch({action: 'view_home_page'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
} else if (getHomePageUrl(this.props.config)) {
dis.dispatch({action: 'view_home_page'});
} else {
this.firstSyncPromise.promise.then(() => {
dis.dispatch({action: 'view_next_room'});
});
}
}
},
@ -1260,7 +1262,6 @@ export default React.createClass({
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
this._teamToken = null;
this._setPageSubtitle();
},
@ -1277,6 +1278,7 @@ export default React.createClass({
this.firstSyncComplete = false;
this.firstSyncPromise = Promise.defer();
const cli = MatrixClientPeg.get();
const IncomingSasDialog = sdk.getComponent('views.dialogs.IncomingSasDialog');
// Allow the JS SDK to reap timeline events. This reduces the amount of
// memory consumed as the JS SDK stores multiple distinct copies of room
@ -1300,6 +1302,17 @@ export default React.createClass({
return self._loggedInView.child.canResetTimelineInRoom(roomId);
});
cli.on('sync.unexpectedError', function(err) {
if (err.message && err.message.includes("live timeline ") && err.message.includes(" is no longer live ")) {
console.error("Caught timeline explosion - trying to ask user for more information");
if (Modal.hasDialogs()) {
console.warn("User has another dialog open - skipping prompt");
return;
}
Modal.createTrackedDialog('Timeline exploded', '', TimelineExplosionDialog, {});
}
});
cli.on('sync', function(state, prevState, data) {
// LifecycleStore and others cannot directly subscribe to matrix client for
// events because flux only allows store state changes during flux dispatches.
@ -1328,7 +1341,10 @@ export default React.createClass({
self.firstSyncPromise.resolve();
dis.dispatch({action: 'focus_composer'});
self.setState({ready: true});
self.setState({
ready: true,
showNotifierToolbar: Notifier.shouldShowToolbar(),
});
});
cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event
@ -1476,6 +1492,12 @@ export default React.createClass({
}
});
cli.on("crypto.verification.start", (verifier) => {
Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, {
verifier,
});
});
// Fire the tinter right on startup to ensure the default theme is applied
// A later sync can/will correct the tint to be the right value for the user
const colorScheme = SettingsStore.getValue("roomColor");
@ -1523,6 +1545,10 @@ export default React.createClass({
dis.dispatch({
action: 'view_user_settings',
});
} else if (screen == 'welcome') {
dis.dispatch({
action: 'view_welcome_page',
});
} else if (screen == 'home') {
dis.dispatch({
action: 'view_home_page',
@ -1547,7 +1573,16 @@ export default React.createClass({
} else if (screen.indexOf('room/') == 0) {
const segments = screen.substring(5).split('/');
const roomString = segments[0];
const eventId = segments[1]; // undefined if no event id given
let eventId = segments.splice(1).join("/"); // empty string if no event id given
// Previously we pulled the eventID from the segments in such a way
// where if there was no eventId then we'd get undefined. However, we
// now do a splice and join to handle v3 event IDs which results in
// an empty string. To maintain our potential contract with the rest
// of the app, we coerce the eventId to be undefined where applicable.
if (!eventId) eventId = undefined;
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/riot-web/issues/9149
// FIXME: sort_out caseConsistency
const thirdPartyInvite = {
@ -1588,11 +1623,7 @@ export default React.createClass({
payload.room_id = roomString;
}
// we can't view a room unless we're logged in
// (a guest account is fine)
if (this.state.view === VIEWS.LOGGED_IN) {
dis.dispatch(payload);
}
dis.dispatch(payload);
} else if (screen.indexOf('user/') == 0) {
const userId = screen.substring(5);
@ -1605,14 +1636,9 @@ export default React.createClass({
this._chatCreateOrReuse(userId);
return;
}
this._setPage(PageTypes.UserView);
this.notifyNewScreen('user/' + userId);
const member = new Matrix.RoomMember(null, userId);
dis.dispatch({
action: 'view_user',
member: member,
});
this.setState({currentUserId: userId});
this._setPage(PageTypes.UserView);
});
} else if (screen.indexOf('group/') == 0) {
const groupId = screen.substring(6);
@ -1682,9 +1708,14 @@ export default React.createClass({
dis.dispatch({ action: 'show_right_panel' });
}
this.state.resizeNotifier.notifyWindowResized();
this._windowWidth = window.innerWidth;
},
_dispatchTimelineResize() {
dis.dispatch({ action: 'timeline_resize' });
},
onRoomCreated: function(roomId) {
dis.dispatch({
action: "view_room",
@ -1704,18 +1735,46 @@ export default React.createClass({
this.showScreen("forgot_password");
},
onReturnToAppClick: function() {
// treat it the same as if the user had completed the login
this._onLoggedIn(null);
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials, teamToken) {
// XXX: These both should be in state or ideally store(s) because we risk not
onRegistered: function(credentials) {
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
// teamToken may not be truthy
this._teamToken = teamToken;
this._is_registered = true;
if (this.state.register_session_id) {
// The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else.
const sessionOwner = Lifecycle.getStoredSessionOwner();
if (sessionOwner && sessionOwner !== credentials.userId) {
console.log(
`Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` +
`email address. Restoring the session for ${sessionOwner} with warning.`,
);
this._loadSession();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Existing session on register', '',
QuestionDialog, {
title: _t('You are logged in to another account'),
description: _t(
"Thank you for verifying your email! The account you're logged into here " +
"(%(sessionUserId)s) appears to be different from the account you've verified an " +
"email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, " +
"please log out first.", {
sessionUserId: sessionOwner,
verifiedUserId: credentials.userId,
// TODO: Fix translations to support reusing variables.
// https://github.com/vector-im/riot-web/issues/9086
verifiedUserId2: credentials.userId,
},
),
hasCancelButton: false,
});
return;
}
}
return Lifecycle.setLoggedIn(credentials);
},
@ -1845,7 +1904,11 @@ export default React.createClass({
render: function() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
if (this.state.view === VIEWS.LOADING || this.state.view === VIEWS.LOGGING_IN || this.state.loadingDefaultHomeserver) {
if (
this.state.view === VIEWS.LOADING ||
this.state.view === VIEWS.LOGGING_IN ||
this.state.loadingDefaultHomeserver
) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
@ -1856,7 +1919,7 @@ export default React.createClass({
// needs to be before normal PageTypes as you are logged in technically
if (this.state.view === VIEWS.POST_REGISTRATION) {
const PostRegistration = sdk.getComponent('structures.login.PostRegistration');
const PostRegistration = sdk.getComponent('structures.auth.PostRegistration');
return (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
@ -1883,7 +1946,6 @@ export default React.createClass({
onCloseAllSettings={this.onCloseAllSettings}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
teamToken={this._teamToken}
showCookieBar={this.state.showCookieBar}
{...this.props}
{...this.state}
@ -1910,29 +1972,30 @@ export default React.createClass({
}
}
if (this.state.view === VIEWS.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
return <Welcome />;
}
if (this.state.view === VIEWS.REGISTER) {
const Registration = sdk.getComponent('structures.login.Registration');
const Registration = sdk.getComponent('structures.auth.Registration');
return (
<Registration
clientSecret={this.state.register_client_secret}
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
skipServerDetails={this.skipServerDetailsForRegistration()}
brand={this.props.config.brand}
teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
makeRegistrationUrl={this._makeRegistrationUrl}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/>
);
@ -1940,7 +2003,7 @@ export default React.createClass({
if (this.state.view === VIEWS.FORGOT_PASSWORD) {
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
return (
<ForgotPassword
defaultServerName={this.getDefaultServerName()}
@ -1950,13 +2013,12 @@ export default React.createClass({
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onLoginClick={this.onLoginClick} />
);
}
if (this.state.view === VIEWS.LOGIN) {
const Login = sdk.getComponent('structures.login.Login');
const Login = sdk.getComponent('structures.auth.Login');
return (
<Login
onLoggedIn={Lifecycle.setLoggedIn}
@ -1971,7 +2033,6 @@ export default React.createClass({
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/>
);

View file

@ -21,7 +21,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import dis from "../../dispatcher";
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
@ -387,7 +386,7 @@ module.exports = React.createClass({
ret.push(<MemberEventListSummary key={key}
events={summarisedEvents}
onToggle={this._onWidgetLoad} // Update scroll state
onToggle={this._onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
>
{ eventTiles }
@ -517,7 +516,7 @@ module.exports = React.createClass({
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
@ -525,6 +524,7 @@ module.exports = React.createClass({
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last} isSelectedEvent={highlight} />
</li>,
);
@ -624,22 +624,57 @@ module.exports = React.createClass({
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
_onWidgetLoad: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.forceUpdate();
}
},
_scrollDownIfAtBottom: function() {
_onHeightChanged: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
_onTypingShown: function() {
const scrollPanel = this.refs.scrollPanel;
// this will make the timeline grow, so checkScroll
scrollPanel.checkScroll();
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.preventShrinking();
}
},
_onTypingHidden: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
// as hiding the typing notifications doesn't
// update the scrollPanel, we tell it to apply
// the shrinking prevention once the typing notifs are hidden
scrollPanel.updatePreventShrinking();
// order is important here as checkScroll will scroll down to
// reveal added padding to balance the notifs disappearing.
scrollPanel.checkScroll();
}
},
updateTimelineMinHeight: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
const isAtBottom = scrollPanel.isAtBottom();
const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
// when messages get added to the timeline,
// but somebody else is still typing,
// update the min-height, so once the last
// person stops typing, no jumping occurs
if (isAtBottom && isTypingVisible) {
scrollPanel.preventShrinking();
}
}
},
onTimelineReset: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearPreventShrinking();
}
},
render: function() {
@ -666,7 +701,12 @@ module.exports = React.createClass({
let whoIsTyping;
if (this.props.room) {
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._scrollDownIfAtBottom} />);
whoIsTyping = (<WhoIsTypingTile
room={this.props.room}
onShown={this._onTypingShown}
onHidden={this._onTypingHidden}
ref="whoIsTyping" />
);
}
return (
@ -676,7 +716,8 @@ module.exports = React.createClass({
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
style={style}
stickyBottom={this.props.stickyBottom}>
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }

View file

@ -107,7 +107,7 @@ export default withMatrixClient(React.createClass({
}
return <div className="mx_MyGroups">
<SimpleRoomHeader title={_t("Communities")} icon="img/icons-groups.svg" />
<SimpleRoomHeader title={_t("Communities")} icon={require("../../../res/img/icons-groups.svg")} />
<div className='mx_MyGroups_header'>
<div className="mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({
</div>
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
<TintableSvg src={require("../../../res/img/icons-create-room.svg")} width="50" height="50" />
</AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">

View file

@ -32,6 +32,7 @@ export default class RightPanel extends React.Component {
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
user: React.PropTypes.object,
};
}
@ -49,13 +50,14 @@ export default class RightPanel extends React.Component {
FilePanel: 'FilePanel',
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
Room3pidMemberInfo: 'Room3pidMemberInfo',
GroupMemberInfo: 'GroupMemberInfo',
});
constructor(props, context) {
super(props, context);
this.state = {
phase: this.props.groupId ? RightPanel.Phase.GroupMemberList : RightPanel.Phase.RoomMemberList,
phase: this._getPhaseFromProps(),
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
@ -69,11 +71,24 @@ export default class RightPanel extends React.Component {
}, 500);
}
_getPhaseFromProps() {
if (this.props.groupId) {
return RightPanel.Phase.GroupMemberList;
} else if (this.props.user) {
return RightPanel.Phase.RoomMemberInfo;
} else {
return RightPanel.Phase.RoomMemberList;
}
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context.matrixClient;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
if (this.props.user) {
this.setState({member: this.props.user});
}
}
componentWillUnmount() {
@ -141,6 +156,7 @@ export default class RightPanel extends React.Component {
groupRoomId: payload.groupRoomId,
groupId: payload.groupId,
member: payload.member,
event: payload.event,
});
}
}
@ -148,6 +164,7 @@ export default class RightPanel extends React.Component {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -165,7 +182,9 @@ export default class RightPanel extends React.Component {
} 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 roomId={this.props.roomId} member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) {
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
@ -179,7 +198,7 @@ export default class RightPanel extends React.Component {
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {

View file

@ -24,23 +24,25 @@ const Modal = require('../../Modal');
const sdk = require('../../index');
const dis = require('../../dispatcher');
const linkify = require('linkifyjs');
const linkifyString = require('linkifyjs/string');
const linkifyMatrix = require('../../linkify-matrix');
const sanitizeHtml = require('sanitize-html');
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import Promise from 'bluebird';
import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils';
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
linkifyMatrix(linkify);
function track(action) {
Analytics.trackEvent('RoomDirectory', action);
}
module.exports = React.createClass({
displayName: 'RoomDirectory',
propTypes: {
config: React.PropTypes.object,
onFinished: React.PropTypes.func.isRequired,
},
getDefaultProps: function() {
@ -54,6 +56,7 @@ module.exports = React.createClass({
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: null,
includeAll: false,
roomServer: null,
@ -61,6 +64,16 @@ module.exports = React.createClass({
};
},
childContextTypes: {
matrixClient: React.PropTypes.object,
},
getChildContext: function() {
return {
matrixClient: MatrixClientPeg.get(),
};
},
componentWillMount: function() {
this._unmounted = false;
this.nextBatch = null;
@ -69,6 +82,11 @@ module.exports = React.createClass({
this.protocols = null;
this.setState({protocolsLoading: true});
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
this.setState({protocolsLoading: false});
return;
}
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
@ -81,10 +99,12 @@ module.exports = React.createClass({
// thing you see when loading the client!
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to get protocol list from Home Server', '', ErrorDialog, {
title: _t('Failed to get protocol list from Home Server'),
description: _t('The Home Server may be too old to support third party networks'),
track('Failed to get protocol list from homeserver');
this.setState({
error: _t(
'Riot failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
),
});
});
@ -173,12 +193,14 @@ module.exports = React.createClass({
return;
}
this.setState({ loading: false });
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, {
title: _t('Failed to get public room list'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
track('Failed to get public room list');
this.setState({
loading: false,
error:
`${_t('Riot failed to get the public room list.')} ` +
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
,
});
});
},
@ -298,6 +320,11 @@ module.exports = React.createClass({
}
},
onCreateRoomClicked: function() {
this.props.onFinished();
dis.dispatch({action: 'view_create_room'});
},
onJoinClick: function(alias) {
// If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId) {
@ -345,6 +372,7 @@ module.exports = React.createClass({
},
showRoom: function(room, room_alias) {
this.props.onFinished();
const payload = {action: 'view_room'};
if (room) {
// Don't let the user view a room they won't be able to either
@ -390,7 +418,6 @@ module.exports = React.createClass({
const self = this;
let guestRead; let guestJoin; let perms;
for (let i = 0; i < rooms.length; i++) {
const name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
guestRead = null;
guestJoin = null;
@ -410,8 +437,16 @@ module.exports = React.createClass({
perms = <div className="mx_RoomDirectory_perms">{guestRead}{guestJoin}</div>;
}
let name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
let topic = rooms[i].topic || '';
topic = linkifyString(sanitizeHtml(topic));
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyAndSanitizeHtml(topic);
rows.push(
<tr key={ rooms[i].room_id }
@ -484,23 +519,15 @@ module.exports = React.createClass({
},
render: function() {
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const Loader = sdk.getComponent("elements.Spinner");
if (this.state.protocolsLoading) {
return (
<div className="mx_RoomDirectory">
<SimpleRoomHeader title={ _t('Directory') } />
<Loader />
</div>
);
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let content;
if (this.state.loading) {
content = <div className="mx_RoomDirectory">
<Loader />
</div>;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading || this.state.loading) {
content = <Loader />;
} else {
const rows = this.getRows();
// we still show the scrollpanel, at least for now, because
@ -522,58 +549,75 @@ module.exports = React.createClass({
onFillRequest={ this.onFillRequest }
stickyBottom={false}
startAtBottom={false}
onResize={function() {}}
>
{ scrollpanel_content }
</ScrollPanel>;
}
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
if (
protocolName &&
this.protocols &&
this.protocols[protocolName] &&
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
}
let listHeader;
if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
let placeholder = _t('Search for a room');
if (!this.state.instanceId) {
placeholder = _t('#example') + ':' + this.state.roomServer;
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
showJoinButton = false;
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
if (
protocolName &&
this.protocols &&
this.protocols[protocolName] &&
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
}
let placeholder = _t('Search for a room');
if (!this.state.instanceId) {
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
showJoinButton = false;
}
}
listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
placeholder={placeholder} showJoinButton={showJoinButton}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>;
}
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
const createRoomButton = (<AccessibleButton
onClick={this.onCreateRoomClicked}
className="mx_RoomDirectory_createRoom"
>{_t("Create new room")}</AccessibleButton>);
return (
<div className="mx_RoomDirectory">
<SimpleRoomHeader title={ _t('Directory') } icon="img/icons-directory.svg" />
<div className="mx_RoomDirectory_list">
<div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
placeholder={placeholder} showJoinButton={showJoinButton}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
<BaseDialog
className={'mx_RoomDirectory_dialog'}
hasCancel={true}
onFinished={this.props.onFinished}
headerButton={createRoomButton}
title={_t("Room directory")}
>
<div className="mx_RoomDirectory">
<div className="mx_RoomDirectory_list">
{listHeader}
{content}
</div>
{content}
</div>
</div>
</BaseDialog>
);
},
});

View file

@ -45,14 +45,6 @@ module.exports = React.createClass({
propTypes: {
// the room this statusbar is representing.
room: PropTypes.object.isRequired,
// the number of messages which have arrived since we've been scrolled up
numUnreadMessages: PropTypes.number,
// this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline.
atEndOfLiveTimeline: PropTypes.bool,
// This is true when the user is alone in the room, but has also sent a message.
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: PropTypes.bool,
@ -82,9 +74,6 @@ module.exports = React.createClass({
// 'you are alone' bar
onStopWarningClick: PropTypes.func,
// callback for when the user clicks on the 'scroll to bottom' button
onScrollToBottomClick: PropTypes.func,
// callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent
// component.
@ -180,8 +169,6 @@ module.exports = React.createClass({
// indicate other sizes.
_getSize: function() {
if (this._shouldShowConnectionError() ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
this.props.sentMessageAndIsAlone
) {
@ -194,32 +181,10 @@ module.exports = React.createClass({
// return suitable content for the image on the left of the status bar.
_getIndicator: function() {
if (this.props.numUnreadMessages) {
return (
<div className="mx_RoomStatusBar_scrollDownIndicator"
onClick={this.props.onScrollToBottomClick}>
<img src="img/newmessages.svg" width="24" height="24"
alt="" />
</div>
);
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (!this.props.atEndOfLiveTimeline) {
return (
<AccessibleButton className="mx_RoomStatusBar_scrollDownIndicator"
onClick={this.props.onScrollToBottomClick}>
<img src="img/scrolldown.svg" width="24" height="24"
alt={_t("Scroll to bottom of page")}
title={_t("Scroll to bottom of page")} />
</AccessibleButton>
);
}
if (this.props.hasActiveCall) {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<TintableSvg src="img/sound-indicator.svg" width="23" height="20" />
<TintableSvg src={require("../../../res/img/sound-indicator.svg")} width="23" height="20" />
);
}
@ -231,9 +196,7 @@ module.exports = React.createClass({
},
_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)
// It also trumps the "some not sent" msg since you can't resend without
// no conn bar trumps the "some not sent" msg since you can't resend without
// a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's
// if it's a resource limit exceeded error: those are shown in the top bar.
@ -327,7 +290,7 @@ module.exports = React.createClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt="" />
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
@ -346,7 +309,7 @@ module.exports = React.createClass({
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
@ -363,20 +326,6 @@ module.exports = React.createClass({
return this._getUnsentMessageContent();
}
// unread count trumps who is typing since the unread count is only
// set when you've scrolled up
if (this.props.numUnreadMessages) {
// MUST use var name "count" for pluralization to kick in
const unreadMsgs = _t("%(count)s new messages", {count: this.props.numUnreadMessages});
return (
<div className="mx_RoomStatusBar_unreadMessagesBar"
onClick={this.props.onScrollToBottomClick}>
{ unreadMsgs }
</div>
);
}
if (this.props.hasActiveCall) {
return (
<div className="mx_RoomStatusBar_callBar">

View file

@ -27,7 +27,9 @@ import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import MatrixClientPeg from "../../MatrixClientPeg";
// turn this on for drop & drag console debugging galore
const debug = false;
@ -60,6 +62,9 @@ const RoomSubList = React.createClass({
getInitialState: function() {
return {
hidden: this.props.startAsHidden || false,
// some values to get LazyRenderList starting
scrollerHeight: 800,
scrollTop: 0,
};
},
@ -84,7 +89,7 @@ const RoomSubList = React.createClass({
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
const stuck = this.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") {
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
return true;
} else {
return false;
@ -127,46 +132,6 @@ const RoomSubList = React.createClass({
});
},
_shouldShowNotifBadge: function(roomNotifState) {
const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
return showBadgeInStates.indexOf(roomNotifState) > -1;
},
_shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState !== RoomNotifs.MUTE;
},
/**
* Total up all the notification counts from the rooms
*
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/
roomNotificationCount: function() {
const self = this;
if (this.props.isInvite) {
return [0, true];
}
return this.props.list.reduce(function(result, room, index) {
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;
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
}
return result;
}, [0, false]);
},
_updateSubListCount: function() {
// Force an update by setting the state to the current state
// Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
@ -174,45 +139,55 @@ const RoomSubList = React.createClass({
this.setState(this.state);
},
makeRoomTiles: function() {
const RoomTile = sdk.getComponent("rooms.RoomTile");
return this.props.list.map((room, index) => {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
key={room.roomId}
collapsed={this.props.collapsed || false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite}
isInvite={this.props.isInvite}
refreshSubList={this._updateSubListCount}
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
});
getUnreadNotificationCount: function(room, type=null) {
let notificationCount = room.getUnreadNotificationCount(type);
// Check notification counts in the old room just in case there's some lost
// there. We only go one level down to avoid performance issues, and theory
// is that 1st generation rooms will have already been read by the 3rd generation.
const createEvent = room.currentState.getStateEvents("m.room.create", "");
if (createEvent && createEvent.getContent()['predecessor']) {
const oldRoomId = createEvent.getContent()['predecessor']['room_id'];
const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId);
if (oldRoom) {
// We only ever care if there's highlights in the old room. No point in
// notifying the user for unread messages because they would have extreme
// difficulty changing their notification preferences away from "All Messages"
// and "Noisy".
notificationCount += oldRoom.getUnreadNotificationCount("highlight");
}
}
return notificationCount;
},
makeRoomTile: function(room) {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
key={room.roomId}
collapsed={this.props.collapsed || false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={this.props.isInvite || this.getUnreadNotificationCount(room, 'highlight') > 0}
notificationCount={this.getUnreadNotificationCount(room)}
isInvite={this.props.isInvite}
refreshSubList={this._updateSubListCount}
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
},
_onNotifBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.props.list) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && this._shouldShowMentionBadge(roomNotifState);
if (notifBadges || mentionBadges) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
return;
}
const room = this.props.list.find(room => RoomNotifs.getRoomHasBadge(room));
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
}
},
@ -238,11 +213,13 @@ const RoomSubList = React.createClass({
}
},
_getHeaderJsx: function() {
_getHeaderJsx: function(isCollapsed) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
const subListNotifications = !this.props.isInvite ?
RoomNotifs.aggregateNotificationCount(this.props.list) :
{count: 0, highlight: true};
const subListNotifCount = subListNotifications.count;
const subListNotifHighlight = subListNotifications.highlight;
let badge;
if (!this.props.collapsed) {
@ -254,9 +231,9 @@ const RoomSubList = React.createClass({
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>{this.props.list.length}</div>;
}
}
@ -287,8 +264,8 @@ const RoomSubList = React.createClass({
if (len) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
'mx_RoomSubList_chevronRight': isCollapsed,
'mx_RoomSubList_chevronDown': !isCollapsed,
});
chevron = (<div className={chevronClasses}></div>);
}
@ -313,24 +290,59 @@ const RoomSubList = React.createClass({
}
},
setHeight: function(height) {
if (this.refs.subList) {
this.refs.subList.style.height = `${height}px`;
}
this._updateLazyRenderHeight(height);
},
_updateLazyRenderHeight: function(height) {
this.setState({scrollerHeight: height});
},
_onScroll: function() {
this.setState({scrollTop: this.refs.scroller.getScrollTop()});
},
_canUseLazyListRendering() {
// for now disable lazy rendering as they are already rendered tiles
// not rooms like props.list we pass to LazyRenderList
return !this.props.extraTiles || !this.props.extraTiles.length;
},
render: function() {
const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": this.state.hidden,
"mx_RoomSubList_nonEmpty": len && !this.state.hidden,
"mx_RoomSubList_hidden": isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
if (this.state.hidden) {
return <div className={subListClasses}>
{this._getHeaderJsx()}
if (isCollapsed) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
</div>;
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
renderItem={ this.makeRoomTile }
itemHeight={34}
items={ this.props.list } />
</IndicatorScrollbar>
</div>;
} else {
const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles);
return <div className={subListClasses}>
{this._getHeaderJsx()}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
{ tiles }
</IndicatorScrollbar>
</div>;
@ -338,13 +350,13 @@ const RoomSubList = React.createClass({
} else {
const Loader = sdk.getComponent("elements.Spinner");
let content;
if (this.props.showSpinner && !this.state.hidden) {
if (this.props.showSpinner && !isCollapsed) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{ this._getHeaderJsx() }
<div ref="subList" className="mx_RoomSubList">
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,13 @@ limitations under the License.
*/
const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
import sdk from '../../index.js';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
@ -30,12 +29,18 @@ const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
// _updateHeight makes the height a ceiled multiple of this so we
// don't have to update the height too often. It also allows the user
// to scroll past the pagination spinner a bit so they don't feel blocked so
// much while the content loads.
const PAGE_SIZE = 400;
let debuglog;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
debuglog = console.log.bind(console, "ScrollPanel debuglog:");
} else {
var debuglog = function() {};
debuglog = function() {};
}
/* This component implements an intelligent scrolling list.
@ -78,6 +83,7 @@ if (DEBUG_SCROLL) {
* scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal.
*/
module.exports = React.createClass({
displayName: 'ScrollPanel',
@ -128,11 +134,6 @@ module.exports = React.createClass({
*/
onScroll: PropTypes.func,
/* onResize: a callback which is called whenever the Gemini scroll
* panel is resized
*/
onResize: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
@ -140,6 +141,9 @@ module.exports = React.createClass({
/* style: styles to add to the top-level div
*/
style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
},
getDefaultProps: function() {
@ -149,12 +153,18 @@ module.exports = React.createClass({
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {},
onResize: function() {},
};
},
componentWillMount: function() {
this._fillRequestWhileRunning = false;
this._isFilling = false;
this._pendingFillRequests = {b: null, f: null};
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.resetScrollState();
},
@ -169,6 +179,7 @@ module.exports = React.createClass({
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
this.updatePreventShrinking();
},
componentWillUnmount: function() {
@ -177,54 +188,27 @@ module.exports = React.createClass({
//
// (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true;
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
},
onScroll: function(ev) {
const sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll);
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
// scroll event, it's been reset again).
//
// This was observed on Chrome 47, when scrolling using the trackpad in OS
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
// due to Chrome not being able to cope with the scroll offset being reset
// while a two-finger drag is in progress.
//
// By way of a workaround, we detect this situation and just keep
// resetting scrollTop until we see the scroll node have the right
// value.
if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) {
console.log("Working around vector-im/vector-web#528");
this._restoreSavedScrollState();
return;
}
// If there weren't enough children to fill the viewport, the scroll we
// got might be different to the scroll we wanted; we don't want to
// forget what we wanted, so don't overwrite the saved state unless
// this appears to be a user-initiated scroll.
if (sn.scrollTop != this._lastSetScroll) {
this._saveScrollState();
} else {
debuglog("Ignoring scroll echo");
// only ignore the echo once, otherwise we'll get confused when the
// user scrolls away from, and back to, the autoscroll point.
this._lastSetScroll = undefined;
}
debuglog("onScroll", this._getScrollNode().scrollTop);
this._scrollTimeout.restart();
this._saveScrollState();
this.updatePreventShrinking();
this.props.onScroll(ev);
this.checkFillState();
},
onResize: function() {
this.props.onResize();
this.checkScroll();
if (this._gemScroll) this._gemScroll.forceUpdate();
// update preventShrinkingState if present
if (this.preventShrinkingState) {
this.preventShrinking();
}
},
// after an update to the contents of the panel, check that the scroll is
@ -237,18 +221,16 @@ module.exports = React.createClass({
// return true if the content is fully scrolled down right now; else false.
//
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the the content is scrolled down right now, irrespective of
// about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom: function() {
const sn = this._getScrollNode();
// fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms
// when scrolled all the way down. E.g. Chrome 72 on debian.
// so check difference <= 1;
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
// understanding of the box model, wherein the scrollNode ends up 2
// pixels higher than the available space, even when there are less
// than a screenful of messages. + 3 is a fudge factor to pretend
// that we're at the bottom when we're still a few pixels off.
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
},
// returns the vertical height in the given direction that can be removed from
@ -284,19 +266,25 @@ module.exports = React.createClass({
// `---------' -
_getExcessHeight: function(backwards) {
const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
}
},
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
checkFillState: async function(depth=0) {
if (this.unmounted) {
return;
}
const isFirstCall = depth === 0;
const sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the
@ -323,13 +311,53 @@ module.exports = React.createClass({
// `---------' -
//
if (sn.scrollTop < sn.clientHeight) {
// need to back-fill
this._maybeFill(true);
// as filling is async and recursive,
// don't allow more than 1 chain of calls concurrently
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
if (isFirstCall) {
if (this._isFilling) {
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
this._fillRequestWhileRunning = true;
return;
}
debuglog("_isFilling: setting");
this._isFilling = true;
}
if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
const itemlist = this.refs.itemlist;
const firstTile = itemlist && itemlist.firstElementChild;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
// if scrollTop gets to 1 screen from the top of the first tile,
// try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
// need to back-fill
fillPromises.push(this._maybeFill(depth, true));
}
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
// need to forward-fill
this._maybeFill(false);
fillPromises.push(this._maybeFill(depth, false));
}
if (fillPromises.length) {
try {
await Promise.all(fillPromises);
} catch (err) {
console.error(err);
}
}
if (isFirstCall) {
debuglog("_isFilling: clearing");
this._isFilling = false;
}
if (this._fillRequestWhileRunning) {
this._fillRequestWhileRunning = false;
this.checkFillState();
}
},
@ -339,6 +367,9 @@ module.exports = React.createClass({
if (excessHeight <= 0) {
return;
}
const origExcessHeight = excessHeight;
const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated
@ -350,8 +381,9 @@ module.exports = React.createClass({
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[backwards ? i : tiles.length - 1 - i];
tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
@ -372,26 +404,31 @@ module.exports = React.createClass({
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
},
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
_maybeFill: function(depth, backwards) {
const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
debuglog("Already a "+dir+" fill in progress - not starting another");
return;
}
debuglog("ScrollPanel: starting "+dir+" fill");
debuglog("starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true;
Promise.try(() => {
// wait 1ms before paginating, because otherwise
// this will block the scroll event handler for +700ms
// if messages are already cached in memory,
// This would cause jumping to happen on Chrome/macOS.
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false;
@ -402,14 +439,14 @@ module.exports = React.createClass({
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination
// was insufficient.
this.checkFillState();
return this.checkFillState(depth + 1);
}
}).done();
});
},
/* get the current scroll state. This returns an object with the following
@ -422,7 +459,7 @@ module.exports = React.createClass({
* false, the first token in data-scroll-tokens of the child which we are
* tracking.
*
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
* number bottomOffset: undefined if stuckAtBottom is true; if it is false,
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
@ -443,14 +480,20 @@ module.exports = React.createClass({
* child list.)
*/
resetScrollState: function() {
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
this.scrollState = {
stuckAtBottom: this.props.startAtBottom,
};
this._bottomGrowth = 0;
this._pages = 0;
this._scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false;
},
/**
* jump to the top of the content.
*/
scrollToTop: function() {
this._setScrollTop(0);
this._getScrollNode().scrollTop = 0;
this._saveScrollState();
},
@ -462,24 +505,26 @@ module.exports = React.createClass({
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
this._saveScrollState();
},
/**
* Page up/down.
*
* mult: -1 to page up, +1 to page down
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta);
scrollNode.scrollTop = scrollNode.scrollTop + delta;
this._saveScrollState();
},
/**
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
handleScrollKey: function(ev) {
switch (ev.keyCode) {
@ -524,135 +569,193 @@ module.exports = React.createClass({
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
// convert pixelOffset so that it is based on the bottom of the
// container.
pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase);
// save the desired scroll state. It's important we do this here rather
// than as a result of the scroll event, because (a) we might not *get*
// a scroll event, and (b) it might not currently be possible to set
// the requested scroll state (eg, because we hit the end of the
// timeline and need to do more pagination); we want to save the
// *desired* scroll state rather than what we end up achieving.
// set the trackedScrollToken so we can get the node through _getTrackedNode
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
pixelOffset: pixelOffset,
};
// ... then make it so.
this._restoreSavedScrollState();
},
// set the scrollTop attribute appropriately to position the given child at the
// given offset in the window. A helper for _restoreSavedScrollState.
_scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
let node;
const messages = this.refs.itemlist.children;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
if (!node) {
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'");
return;
}
const trackedNode = this._getTrackedNode();
const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if (scrollDelta != 0) {
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
if (trackedNode) {
// set the scrollTop to the position we want.
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
// would position the trackedNode towards the top of the viewport.
// This because when setting the scrollTop only 10 or so events might be loaded,
// not giving enough content below the trackedNode to scroll downwards
// enough so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
this._saveScrollState();
}
},
_saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
debuglog("saved stuckAtBottom state");
return;
}
const scrollNode = this._getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children;
let newScrollState = null;
let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i];
if (!node.dataset.scrollTokens) continue;
const boundingRect = node.getBoundingClientRect();
newScrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
if (!messages[i].dataset.scrollTokens) {
continue;
}
node = messages[i];
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken
break;
}
}
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
if (!node) {
debuglog("unable to save scroll state: found no children in the viewport");
return;
}
const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
const bottomOffset = this._topFromBottom(node);
this.scrollState = {
stuckAtBottom: false,
trackedNode: node,
trackedScrollToken: scrollToken,
bottomOffset: bottomOffset,
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
};
},
_restoreSavedScrollState: function() {
_restoreSavedScrollState: async function() {
const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
} else if (scrollState.trackedScrollToken) {
this._scrollToToken(scrollState.trackedScrollToken,
scrollState.pixelOffset);
const itemlist = this.refs.itemlist;
const trackedNode = this._getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
itemlist.style.height = `${this._getListHeight()}px`;
debuglog("balancing height because messages below viewport grew by", bottomDiff);
}
}
if (!this._heightUpdateInProgress) {
this._heightUpdateInProgress = true;
try {
await this._updateHeight();
} finally {
this._heightUpdateInProgress = false;
}
} else {
debuglog("not updating height because request already in progress");
}
},
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() {
// wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... ");
await this._scrollTimeout.finished();
} else {
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
const sn = this._getScrollNode();
const itemlist = this.refs.itemlist;
const contentHeight = this._getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = this._getListHeight();
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
itemlist.style.height = `${newHeight}px`;
sn.scrollTop = sn.scrollHeight;
debuglog("updateHeight to", newHeight);
} else if (scrollState.trackedScrollToken) {
const trackedNode = this._getTrackedNode();
// if the timeline has been reloaded
// this can be called before scrollToBottom or whatever has been called
// so don't do anything if the node has disappeared from
// the currently filled piece of the timeline
if (trackedNode) {
const oldTop = trackedNode.offsetTop;
// changing the height might change the scrollTop
// if the new height is smaller than the scrollTop.
// We calculate the diff that needs to be applied
// ourselves, so be sure to measure the
// scrollTop before changing the height.
const preexistingScrollTop = sn.scrollTop;
itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop;
sn.scrollTop = preexistingScrollTop + topDiff;
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop});
}
}
},
_setScrollTop: function(scrollTop) {
const scrollNode = this._getScrollNode();
_getTrackedNode() {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
const prevScroll = scrollNode.scrollTop;
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this.refs.itemlist.children;
const scrollToken = scrollState.trackedScrollToken;
// FF ignores attempts to set scrollTop to very large numbers
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
// If this change generates a scroll event, we should not update the
// saved scroll state on it. See the comments in onScroll.
//
// If we *don't* expect a scroll event, we need to leave _lastSetScroll
// alone, otherwise we'll end up ignoring a future scroll event which is
// nothing to do with this change.
if (scrollNode.scrollTop != prevScroll) {
this._lastSetScroll = scrollNode.scrollTop;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
if (node) {
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
}
scrollState.trackedNode = node;
}
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
if (!scrollState.trackedNode) {
debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
return;
}
return scrollState.trackedNode;
},
_getListHeight() {
return this._bottomGrowth + (this._pages * PAGE_SIZE);
},
_getMessagesHeight() {
const itemlist = this.refs.itemlist;
const lastNode = itemlist.lastElementChild;
// 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
},
_topFromBottom(node) {
return this.refs.itemlist.clientHeight - node.offsetTop;
},
/* get the DOM node which has the scrollTop property we care about for our
@ -665,33 +768,112 @@ module.exports = React.createClass({
throw new Error("ScrollPanel._getScrollNode called when unmounted");
}
if (!this._gemScroll) {
if (!this._divScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
}
return this._gemScroll.scrollbar.getViewElement();
return this._divScroll;
},
_collectGeminiScroll: function(gemScroll) {
this._gemScroll = gemScroll;
_collectScroll: function(divScroll) {
this._divScroll = divScroll;
},
/**
Mark the bottom offset of the last tile so we can balance it out when
anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
preventShrinking: function() {
const messageList = this.refs.itemlist;
const tiles = messageList && messageList.children;
if (!messageList) {
return;
}
let lastTileNode;
for (let i = tiles.length - 1; i >= 0; i--) {
const node = tiles[i];
if (node.dataset.scrollTokens) {
lastTileNode = node;
break;
}
}
if (!lastTileNode) {
return;
}
this.clearPreventShrinking();
const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight);
this.preventShrinkingState = {
offsetFromBottom: offsetFromBottom,
offsetNode: lastTileNode,
};
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
},
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking: function() {
const messageList = this.refs.itemlist;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null;
debuglog("prevent shrinking cleared");
},
/**
update the container padding to balance
the bottom offset of the last tile since
preventShrinking was called.
Clears the prevent-shrinking state ones the offset
from the bottom of the marked tile grows larger than
what it was when marking.
*/
updatePreventShrinking: function() {
if (this.preventShrinkingState) {
const sn = this._getScrollNode();
const scrollState = this.scrollState;
const messageList = this.refs.itemlist;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement;
// if the offsetNode got unmounted, clear
let shouldClear = !offsetNode.parentElement;
// also if 200px from bottom
if (!shouldClear && !scrollState.stuckAtBottom) {
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
shouldClear = spaceBelowViewport >= 200;
}
// try updating if not clearing
if (!shouldClear) {
const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight);
const offsetDiff = offsetFromBottom - currentOffset;
if (offsetDiff > 0) {
balanceElement.style.paddingBottom = `${offsetDiff}px`;
debuglog("update prevent shrinking ", offsetDiff, "px from bottom");
} else if (offsetDiff < 0) {
shouldClear = true;
}
}
if (shouldClear) {
this.clearPreventShrinking();
}
}
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
onScroll={this.onScroll} onResize={this.onResize}
className={this.props.className} style={this.props.style}>
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children }
</ol>
</div>
</GeminiScrollbarWrapper>
);
</AutoHideScrollbar>
);
},
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,22 +15,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import { _t } from '../../languageHandler';
import PropTypes from 'prop-types';
import { KeyCode } from '../../Keyboard';
import sdk from '../../index';
import dis from '../../dispatcher';
import rate_limited_func from '../../ratelimitedfunc';
import { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'SearchBox',
propTypes: {
onSearch: React.PropTypes.func,
onCleared: React.PropTypes.func,
onSearch: PropTypes.func,
onCleared: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
// If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take
// this functionality out, but not obvious how that would work)
enableRoomSearchFocus: PropTypes.bool,
},
getDefaultProps: function() {
return {
enableRoomSearchFocus: false,
};
},
getInitialState: function() {
@ -47,6 +58,8 @@ module.exports = React.createClass({
},
onAction: function(payload) {
if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) {
case 'view_room':
if (this.refs.search && payload.clear_search) {
@ -67,12 +80,9 @@ module.exports = React.createClass({
this.onSearch();
},
onSearch: new rate_limited_func(
function() {
this.props.onSearch(this.refs.search.value);
},
100,
),
onSearch: throttle(function() {
this.props.onSearch(this.refs.search.value);
}, 200, {trailing: true, leading: true}),
_onKeyDown: function(ev) {
switch (ev.keyCode) {
@ -95,26 +105,32 @@ module.exports = React.createClass({
},
render: function() {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
// check for collapsed here and
// not at parent so we keep
// searchTerm in our state
// when collapsing and expanding
if (this.props.collapsed) {
return null;
}
const clearButton = this.state.searchTerm.length > 0 ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button")} }>
</AccessibleButton>) : undefined;
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
const className = this.props.className || "";
return (
<div className="mx_SearchBox mx_textinput">
<input
key="searchfield"
type="text"
ref="search"
className="mx_textinput_icon mx_textinput_search"
className={"mx_textinput_icon mx_textinput_search " + className}
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
placeholder={ this.props.placeholder }
/>
{ clearButton }
</div>

View file

@ -0,0 +1,119 @@
/*
Copyright 2017 Travis Ralston
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 * as React from "react";
import {_t} from '../../languageHandler';
import PropTypes from "prop-types";
/**
* Represents a tab for the TabbedView.
*/
export class Tab {
/**
* Creates a new tab.
* @param {string} tabLabel The untranslated tab label.
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
* @param {string} tabJsx The JSX for the tab container.
*/
constructor(tabLabel, tabIconClass, tabJsx) {
this.label = tabLabel;
this.icon = tabIconClass;
this.body = tabJsx;
}
}
export class TabbedView extends React.Component {
static propTypes = {
// The tabs to show
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
};
constructor() {
super();
this.state = {
activeTabIndex: 0,
};
}
_getActiveTabIndex() {
if (!this.state || !this.state.activeTabIndex) return 0;
return this.state.activeTabIndex;
}
/**
* Shows the given tab
* @param {Tab} tab the tab to show
* @private
*/
_setActiveTab(tab) {
const idx = this.props.tabs.indexOf(tab);
if (idx !== -1) {
this.setState({activeTabIndex: idx});
} else {
console.error("Could not find tab " + tab.label + " in tabs");
}
}
_renderTabLabel(tab) {
let classes = "mx_TabbedView_tabLabel ";
const idx = this.props.tabs.indexOf(tab);
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
let tabIcon = null;
if (tab.icon) {
tabIcon = <span className={`mx_TabbedView_maskedIcon ${tab.icon}`} />;
}
const onClickHandler = () => this._setActiveTab(tab);
return (
<span className={classes} key={"tab_label_" + tab.label}
onClick={onClickHandler}>
{tabIcon}
<span className="mx_TabbedView_tabLabel_text">
{_t(tab.label)}
</span>
</span>
);
}
_renderTabPanel(tab) {
return (
<div className="mx_TabbedView_tabPanel" key={"mx_tabpanel_" + tab.label}>
<div className='mx_TabbedView_tabPanelContent'>
{tab.body}
</div>
</div>
);
}
render() {
const labels = this.props.tabs.map(tab => this._renderTabLabel(tab));
const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]);
return (
<div className="mx_TabbedView">
<div className="mx_TabbedView_tabLabels">
{labels}
</div>
{panel}
</div>
);
}
}

View file

@ -23,7 +23,6 @@ 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';
@ -48,8 +47,6 @@ 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;
@ -70,9 +67,6 @@ const TagPanel = React.createClass({
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
}
},
_onGroupMyMembership() {
@ -106,21 +100,11 @@ 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
@ -136,7 +120,7 @@ const TagPanel = React.createClass({
let clearButton;
if (itemsSelected) {
clearButton = <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
<TintableSvg src="img/icons-close.svg" width="24" height="24"
<TintableSvg src={require("../../../res/img/icons-close.svg")} width="24" height="24"
alt={_t("Clear filter")}
title={_t("Clear filter")}
/>
@ -174,13 +158,6 @@ const TagPanel = React.createClass({
) }
</Droppable>
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton />
<ActionButton
className="mx_TagPanel_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>
</div>;
},
});

View file

@ -0,0 +1,58 @@
/*
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 sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
const TagPanelButtons = React.createClass({
displayName: 'TagPanelButtons',
componentWillMount: function() {
this._dispatcherRef = dis.register(this._onAction);
},
componentWillUnmount() {
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
}
},
_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 ActionButton = sdk.getComponent("elements.ActionButton");
return (<div className="mx_TagPanelButtons">
<GroupsButton />
<ActionButton
className="mx_TagPanelButtons_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>);
},
});
export default TagPanelButtons;

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
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.
@ -451,12 +452,12 @@ var TimelinePanel = React.createClass({
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
var callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
@ -465,11 +466,16 @@ var TimelinePanel = React.createClass({
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
callback = this.props.onReadMarkerUpdated;
callRMUpdated = true;
}
}
this.setState(updatedState, callback);
this.setState(updatedState, () => {
this.refs.messagePanel.updateTimelineMinHeight();
if (callRMUpdated) {
this.props.onReadMarkerUpdated();
}
});
});
},
@ -557,7 +563,7 @@ var TimelinePanel = React.createClass({
this._readMarkerActivityTimer = new Timer(initialTimeout);
while (this._readMarkerActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readMarkerActivityTimer);
UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
@ -569,7 +575,7 @@ var TimelinePanel = React.createClass({
updateReadReceiptOnUserActivity: async function() {
this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
while (this._readReceiptActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readReceiptActivityTimer);
UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
@ -930,6 +936,11 @@ var TimelinePanel = React.createClass({
{windowLimit: this.props.timelineCap});
const onLoaded = () => {
// clear the timeline min-height when
// (re)loading the timeline
if (this.refs.messagePanel) {
this.refs.messagePanel.onTimelineReset();
}
this._reloadEvents();
// If we switched away from the room while there were pending
@ -1197,6 +1208,7 @@ var TimelinePanel = React.createClass({
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
permalinkCreator={this.props.permalinkCreator}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}
@ -1216,6 +1228,7 @@ var TimelinePanel = React.createClass({
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
/>
);
},

View file

@ -22,6 +22,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
import { _t } from '../../languageHandler';
const AVATAR_SIZE = 28;
@ -67,10 +68,18 @@ export default class TopLeftMenuButton extends React.Component {
}
}
_getDisplayName() {
if (MatrixClientPeg.get().isGuest()) {
return _t("Guest");
} else if (this.state.profileInfo) {
return this.state.profileInfo.name;
} else {
return MatrixClientPeg.get().getUserId();
}
}
render() {
const fallbackUserId = MatrixClientPeg.get().getUserId();
const profileInfo = this.state.profileInfo;
const name = profileInfo ? profileInfo.name : fallbackUserId;
const name = this._getDisplayName();
let nameElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
@ -81,15 +90,15 @@ export default class TopLeftMenuButton extends React.Component {
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<BaseAvatar
idName={fallbackUserId}
idName={MatrixClientPeg.get().getUserId()}
name={name}
url={profileInfo && profileInfo.avatarUrl}
url={this.state.profileInfo && this.state.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" />
<span className="mx_TopLeftMenuButton_chevron"></span>
</AccessibleButton>
);
}
@ -106,6 +115,8 @@ export default class TopLeftMenuButton extends React.Component {
chevronFace: "none",
left: x,
top: y,
userId: MatrixClientPeg.get().getUserId(),
displayName: this._getDisplayName(),
onFinished: () => {
this.setState({ menuDisplayed: false });
},

View file

@ -47,7 +47,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
},
render: function() {
const uploads = ContentMessages.getCurrentUploads();
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
// check in RoomView
@ -91,9 +91,9 @@ module.exports = React.createClass({displayName: 'UploadBar',
<div className="mx_UploadBar_uploadProgressOuter">
<div className="mx_UploadBar_uploadProgressInner" style={innerProgressStyle}></div>
</div>
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src="img/fileicon.png" width="17" height="22" />
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
onClick={function() { ContentMessages.cancelUpload(upload.promise); }}
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src={require("../../../res/img/fileicon.png")} width="17" height="22" />
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src={require("../../../res/img/cancel.svg")} width="18" height="18"
onClick={function() { ContentMessages.sharedInstance().cancelUpload(upload.promise); }}
/>
<div className="mx_UploadBar_uploadBytes">
{ uploadedSize } / { totalSize }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
/*
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 Matrix from "matrix-js-sdk";
import MatrixClientPeg from "../../MatrixClientPeg";
import sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
export default class UserView extends React.Component {
static get propTypes() {
return {
userId: React.PropTypes.string,
};
}
constructor(props) {
super(props);
this.state = {};
}
componentWillMount() {
if (this.props.userId) {
this._loadProfileInfo();
}
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this._loadProfileInfo();
}
}
async _loadProfileInfo() {
const cli = MatrixClientPeg.get();
this.setState({loading: true});
let profileInfo;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
this.setState({loading: false});
return;
}
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new Matrix.RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
}
render() {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
} else if (this.state.member) {
const RightPanel = sdk.getComponent('structures.RightPanel');
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
return (<MainSplit panel={panel}><div style={{flex: "1"}} /></MainSplit>);
} else {
return (<div />);
}
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,11 +15,11 @@ 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 SyntaxHighlight from '../views/elements/SyntaxHighlight';
import {_t} from "../../languageHandler";
import sdk from "../../index";
module.exports = React.createClass({
@ -27,31 +28,24 @@ module.exports = React.createClass({
propTypes: {
content: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
},
componentDidMount: function() {
document.addEventListener("keydown", this.onKeyDown);
},
componentWillUnmount: function() {
document.removeEventListener("keydown", this.onKeyDown);
},
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
roomId: PropTypes.string.isRequired,
eventId: PropTypes.string.isRequired,
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_ViewSource">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
<div className="mx_ViewSource_label_left">Room ID: { this.props.roomId }</div>
<div className="mx_ViewSource_label_right">Event ID: { this.props.eventId }</div>
<div className="mx_ViewSource_label_bottom" />
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
</BaseDialog>
);
},
});

View file

@ -0,0 +1,365 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 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 PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the forgot password inputs
const PHASE_FORGOT = 1;
// Email is in the process of being sent
const PHASE_SENDING_EMAIL = 2;
// Email has been sent
const PHASE_EMAIL_SENT = 3;
// User has clicked the link in email and completed reset
const PHASE_DONE = 4;
module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
onLoginClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
phase: PHASE_FORGOT,
email: "",
password: "",
password2: "",
errorText: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
this.setState({
phase: PHASE_SENDING_EMAIL,
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
phase: PHASE_EMAIL_SENT,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
phase: PHASE_FORGOT,
});
});
},
onVerify: function(ev) {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
this.setState({ phase: PHASE_DONE });
}, (err) => {
this.showErrorDialog(err.message);
});
},
onSubmitForm: function(ev) {
ev.preventDefault();
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
"Changing your password will reset any end-to-end encryption keys " +
"on all of your devices, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another device before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHsUrl, this.state.enteredIsUrl,
this.state.email, this.state.password,
);
}
},
});
}
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value,
});
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.enteredHsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIsUrl = config.isUrl;
}
this.setState(newState);
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
this.setState({
phase: PHASE_FORGOT,
});
},
onEditServerDetailsClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
showErrorDialog: function(body, title) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, {
title: title,
description: body,
});
},
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <div>
<ServerConfig
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
<AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>
</div>;
},
renderForgot() {
const Field = sdk.getComponent('elements.Field');
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let yourMatrixAccountText = _t('Your Matrix account');
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.defaultServerName,
});
} else {
try {
const parsedHsUrl = new URL(this.state.enteredHsUrl);
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
errorText = <div className="mx_Login_error">{_t(
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " +
"enter a valid URL including the protocol prefix.",
{
hsUrl: this.state.enteredHsUrl,
})}</div>;
}
}
// If custom URLs are allowed, wire up the server details edit link.
let editLink = null;
if (!SdkConfig.get()['disable_custom_urls']) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
return <div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{errorText}
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_email"
name="reset_email" // define a name so browser's password autofill gets less confused
type="text"
label={_t('Email')}
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
autoFocus
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_password"
name="reset_password"
type="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
/>
<Field
id="mx_ForgotPassword_passwordConfirm"
name="reset_password_confirm"
type="password"
label={_t('Confirm')}
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
/>
</div>
<span>{_t(
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
)}</span>
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form>
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
</a>
</div>;
},
renderSendingEmail() {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
},
renderEmailSent() {
return <div>
{_t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email })}
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} />
</div>;
},
renderDone() {
return <div>
<p>{_t("Your password has been reset.")}</p>
<p>{_t(
"You have been logged out of all devices and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
)}</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
},
render: function() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
let resetPasswordJsx;
switch (this.state.phase) {
case PHASE_SERVER_DETAILS:
resetPasswordJsx = this.renderServerDetails();
break;
case PHASE_FORGOT:
resetPasswordJsx = this.renderForgot();
break;
case PHASE_SENDING_EMAIL:
resetPasswordJsx = this.renderSendingEmail();
break;
case PHASE_EMAIL_SENT:
resetPasswordJsx = this.renderEmailSent();
break;
case PHASE_DONE:
resetPasswordJsx = this.renderDone();
break;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2> { _t('Set a new password') } </h2>
{resetPasswordJsx}
</AuthBody>
</AuthPage>
);
},
});

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.
@ -24,13 +24,21 @@ import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { AutoDiscovery } from "matrix-js-sdk";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Enable phases for login
const PHASES_ENABLED = true;
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
_td("Invalid homeserver discovery response");
@ -48,23 +56,24 @@ module.exports = React.createClass({
enableGuest: PropTypes.bool,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about where to "sign in to".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different home server without confusing users.
// different homeserver without confusing users.
fallbackHsUrl: PropTypes.string,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration is done.
@ -72,7 +81,6 @@ module.exports = React.createClass({
// login shouldn't care how password recovery is done.
onForgotPasswordClick: PropTypes.func,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
},
@ -81,18 +89,21 @@ module.exports = React.createClass({
busy: false,
errorText: null,
loginIncorrect: false,
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
// Phase of the overall login dialog.
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: "m.login.password",
// .well-known discovery
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: "",
findingHomeserver: false,
};
@ -149,7 +160,7 @@ module.exports = React.createClass({
// Some error strings only apply for logging in
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.');
errorText = _t('This homeserver does not support login using email address.');
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
@ -230,7 +241,7 @@ module.exports = React.createClass({
}, function(error) {
let errorText;
if (error.httpStatus === 403) {
errorText = _t("Guest access is disabled on this Home Server.");
errorText = _t("Guest access is disabled on this homeserver.");
} else {
errorText = self._errorTextFromError(error);
}
@ -250,7 +261,10 @@ module.exports = React.createClass({
},
onUsernameBlur: function(username) {
this.setState({ username: username });
this.setState({
username: username,
discoveryError: null,
});
if (username[0] === "@") {
const serverName = username.split(':').slice(1).join(':');
try {
@ -270,16 +284,22 @@ module.exports = React.createClass({
},
onPhoneNumberChanged: function(phoneNumber) {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({ errorText: _t('The phone number entered looks invalid') });
return;
}
this.setState({
phoneNumber: phoneNumber,
});
},
onPhoneNumberBlur: function(phoneNumber) {
this.setState({
errorText: null,
});
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
});
}
},
onServerConfigChange: function(config) {
@ -288,10 +308,10 @@ module.exports = React.createClass({
errorText: null, // reset err messages
};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
newState.enteredHsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
newState.enteredIsUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
@ -306,46 +326,60 @@ module.exports = React.createClass({
this.props.onRegisterClick();
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
this.setState({
phase: PHASE_LOGIN,
});
},
onEditServerDetailsClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
this.setState({discoveryError: "", discoveredHsUrl: "", discoveredIsUrl: "", findingHomeserver: false});
this.setState({
discoveryError: "",
findingHomeserver: false,
});
return;
}
this.setState({findingHomeserver: true});
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) {
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: discovery["m.homeserver"].error,
findingHomeserver: false,
});
} else if (state === AutoDiscovery.PROMPT) {
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: "",
findingHomeserver: false,
});
} else if (state === AutoDiscovery.SUCCESS) {
this.setState({
discoveredHsUrl: discovery["m.homeserver"].base_url,
discoveredIsUrl:
discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "",
discoveryError: "",
findingHomeserver: false,
});
this.onServerConfigChange({
hsUrl: discovery["m.homeserver"].base_url,
isUrl: discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "",
});
} else {
console.warn("Unknown state for m.homeserver in discovery response: ", discovery);
this.setState({
discoveredHsUrl: "",
discoveredIsUrl: "",
discoveryError: _t("Unknown failure discovering homeserver"),
findingHomeserver: false,
});
@ -361,8 +395,8 @@ module.exports = React.createClass({
_initLoginLogic: function(hsUrl, isUrl) {
const self = this;
hsUrl = hsUrl || this.state.enteredHomeserverUrl;
isUrl = isUrl || this.state.enteredIdentityServerUrl;
hsUrl = hsUrl || this.state.enteredHsUrl;
isUrl = isUrl || this.state.enteredIsUrl;
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
@ -372,8 +406,8 @@ module.exports = React.createClass({
this._loginLogic = loginLogic;
this.setState({
enteredHomeserverUrl: hsUrl,
enteredIdentityServerUrl: isUrl,
enteredHsUrl: hsUrl,
enteredIsUrl: isUrl,
busy: true,
loginIncorrect: false,
});
@ -439,15 +473,17 @@ module.exports = React.createClass({
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
(this.state.enteredHomeserverUrl.startsWith("http:") ||
!this.state.enteredHomeserverUrl.startsWith("http"))
(this.state.enteredHsUrl.startsWith("http:") ||
!this.state.enteredHsUrl.startsWith("http"))
) {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">
return <a target="_blank" rel="noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
>
{ sub }
</a>;
},
@ -461,7 +497,9 @@ module.exports = React.createClass({
"is not blocking requests.", {},
{
'a': (sub) => {
return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>;
return <a target="_blank" rel="noopener"
href={this.state.enteredHsUrl}
>{ sub }</a>;
},
},
) }
@ -472,7 +510,49 @@ module.exports = React.createClass({
return errorText;
},
componentForStep: function(step) {
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return null;
}
const serverDetails = <ServerConfig
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
let nextButton = null;
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
}
return <div>
{serverDetails}
{nextButton}
</div>;
},
renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) {
return null;
}
const step = this.state.currentFlow;
if (!step) {
return null;
}
@ -487,11 +567,26 @@ module.exports = React.createClass({
},
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('login.PasswordLogin');
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
onEditServerDetailsClick={onEditServerDetailsClick}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
@ -499,27 +594,35 @@ module.exports = React.createClass({
onUsernameBlur={this.onUsernameBlur}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsUrl={this.state.enteredHomeserverUrl}
hsName={this.props.defaultServerName}
hsName={hsName}
hsUrl={this.state.enteredHsUrl}
disableSubmit={this.state.findingHomeserver}
/>
);
},
_renderSsoStep: function(url) {
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
// redirecting the user back to a URI once they're logged in. On the web, this means
// we use the same window and redirect back to riot. On electron, this actually
// opens the SSO page in the electron app itself due to
// https://github.com/electron/electron/issues/8841 and so happens to work.
// If this bug gets fixed, it will break SSO since it will open the SSO page in the
// user's browser, let them log into their SSO provider, then redirect their browser
// to vector://vector which, of course, will not work.
return (
<a href={url} className="mx_Login_sso_link">{ _t('Sign in with single sign-on') }</a>
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a>
);
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
@ -527,35 +630,11 @@ module.exports = React.createClass({
let loginAsGuestJsx;
if (this.props.enableGuest) {
loginAsGuestJsx =
<a className="mx_Login_create" onClick={this._onLoginAsGuestClick} href="#">
<a className="mx_AuthBody_changeFlow" onClick={this._onLoginAsGuestClick} href="#">
{ _t('Try the app first') }
</a>;
}
let serverConfig;
let header;
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfig = <ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
customIsUrl={this.state.discoveredIsUrl || this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000} />;
}
// FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
header = <h2>{ _t('Sign in') } { loader }</h2>;
} else {
if (!errorText) {
header = <h2>{ _t('Sign in to get started') } { loader }</h2>;
}
}
let errorTextSection;
if (errorText) {
errorTextSection = (
@ -565,26 +644,23 @@ module.exports = React.createClass({
);
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
return (
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
<div>
{ header }
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }
<LanguageSelector />
<LoginFooter />
</div>
</div>
</LoginPage>
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>
{_t('Sign in')}
{loader}
</h2>
{ errorTextSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
{ _t('Create account') }
</a>
{ loginAsGuestJsx }
</AuthBody>
</AuthPage>
);
},
});

View file

@ -60,12 +60,13 @@ module.exports = React.createClass({
render: function() {
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const LoginPage = sdk.getComponent('login.LoginPage');
const LoginHeader = sdk.getComponent('login.LoginHeader');
const AuthPage = sdk.getComponent('auth.AuthPage');
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
return (
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
<AuthPage>
<AuthHeader />
<AuthBody>
<div className="mx_Login_profile">
{ _t('Set a display name:') }
<ChangeDisplayName />
@ -75,8 +76,8 @@ module.exports = React.createClass({
<button onClick={this.props.onComplete}>{ _t('Continue') }</button>
{ this.state.errorString }
</div>
</div>
</LoginPage>
</AuthBody>
</AuthPage>
);
},
});

View file

@ -0,0 +1,585 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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.
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 Matrix from 'matrix-js-sdk';
import Promise from 'bluebird';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
const MIN_PASSWORD_LENGTH = 6;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate registration flow(s) for the server
const PHASE_REGISTRATION = 1;
// Enable phases for registration
const PHASES_ENABLED = true;
module.exports = React.createClass({
displayName: 'Registration',
propTypes: {
onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
skipServerDetails: PropTypes.bool,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onServerConfigChange: PropTypes.func.isRequired,
},
getInitialState: function() {
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
const customURLsAllowed = !SdkConfig.get()['disable_custom_urls'];
let initialPhase = this.getDefaultPhaseForServerType(serverType);
if (
// if we have these two, skip to the good bit
// (they could come in from the URL params in a
// registration email link)
(this.props.clientSecret && this.props.sessionId) ||
// if custom URLs aren't allowed, skip to form
!customURLsAllowed ||
// if other logic says to, skip to form
this.props.skipServerDetails
) {
// TODO: It would seem we've now added enough conditions here that the initial
// phase will _always_ be the form. It's tempting to remove the complexity and
// just do that, but we keep tweaking and changing auth, so let's wait until
// things settle a bit.
// Filed https://github.com/vector-im/riot-web/issues/8886 to track this.
initialPhase = PHASE_REGISTRATION;
}
return {
busy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType,
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
// Phase of the overall registration dialog.
phase: initialPhase,
flows: null,
};
},
componentWillMount: function() {
this._unmounted = false;
this._replaceClient();
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, () => {
this._replaceClient();
});
},
getDefaultPhaseForServerType(type) {
switch (type) {
case ServerType.FREE: {
// Move directly to the registration phase since the server
// details are fixed.
return PHASE_REGISTRATION;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS;
}
},
onServerTypeChange(type) {
this.setState({
serverType: type,
});
// When changing server types, set the HS / IS URLs to reasonable defaults for the
// the new type.
switch (type) {
case ServerType.FREE: {
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
this.onServerConfigChange({
hsUrl,
isUrl,
});
break;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
this.onServerConfigChange({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
},
_replaceClient: async function() {
this.setState({
errorText: null,
});
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
});
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
// auth object.
console.log("Expecting 401 from register request but got success!");
} catch (e) {
if (e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
this.setState({
errorText: _t("Registration has been disabled on this homeserver."),
});
} else {
this.setState({
errorText: _t("Unable to query for supported registration methods."),
});
}
}
},
onFormSubmit: function(formVals) {
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
this.setState({
errorText: "",
busy: true,
formVals: formVals,
doingUIAuth: true,
});
},
_onUIAuthFinished: async function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>;
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
}
}
this.setState({
busy: false,
doingUIAuth: false,
errorText: msg,
});
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
doingUIAuth: false,
});
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
this._setupPushers(cli);
},
_setupPushers: function(matrixClient) {
if (!this.props.brand) {
return Promise.resolve();
}
return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
},
onFormValidationChange: function(fieldErrors) {
// `fieldErrors` is an object mapping field IDs to error codes when there is an
// error or `null` for no error, so the values array will be something like:
// `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]`
// Find the first non-null error code and show that.
const errCode = Object.values(fieldErrors).find(value => !!value);
if (!errCode) {
this.setState({
errorText: null,
});
return;
}
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
errMsg = _t('Missing password.');
break;
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.');
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a username.');
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = _t('An unknown error occurred.');
break;
}
this.setState({
errorText: errMsg,
});
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
onGoToFormClicked(ev) {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
});
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
this.setState({
phase: PHASE_REGISTRATION,
});
},
onEditServerDetailsClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
},
_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
// session).
const bindThreepids = this.state.formVals.password ? {
email: true,
msisdn: true,
} : {};
return this._matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
bindThreepids,
null,
);
},
_getUIAuthInputs: function() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
phoneNumber: this.state.formVals.phoneNumber,
};
},
renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</div>;
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
customIsUrl={this.state.discoveredIsUrl || this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
}
let nextButton = null;
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
}
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
},
renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) {
return null;
}
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
if (this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this._matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>;
} else if (this.state.busy || !this.state.flows) {
return <div className="mx_AuthBody_spinner">
<Spinner />
</div>;
} else {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
if (
PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.hsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
minPasswordLength={MIN_PASSWORD_LENGTH}
onValidationChange={this.onFormValidationChange}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
hsName={hsName}
hsUrl={this.state.hsUrl}
/>;
}
},
render: function() {
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
const AuthPage = sdk.getComponent('auth.AuthPage');
let errorText;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
// Only show the 'go back' button if you're not looking at the form
let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') }
</a>;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</AuthBody>
</AuthPage>
);
},
});

View file

@ -1,291 +0,0 @@
/*
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 { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
onLoginClick: PropTypes.func,
onRegisterClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
},
getInitialState: function() {
return {
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
progress: null,
password: null,
password2: null,
errorText: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
this.setState({
progress: "sending_email",
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
progress: "sent_email",
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
progress: null,
});
});
},
onVerify: function(ev) {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
this.setState({ progress: "complete" });
}, (err) => {
this.showErrorDialog(err.message);
});
},
onSubmitForm: function(ev) {
ev.preventDefault();
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
this.showErrorDialog(_t('A new password must be entered.'));
} else if (this.state.password !== this.state.password2) {
this.showErrorDialog(_t('New passwords must match each other.'));
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, {
title: _t('Warning!'),
description:
<div>
{ _t(
'Resetting password will currently reset any ' +
'end-to-end encryption keys on all devices, ' +
'making encrypted chat history unreadable, ' +
'unless you first export your room keys and re-import ' +
'them afterwards. In future this will be improved.',
) }
</div>,
button: _t('Continue'),
extraButtons: [
<button key="export_keys" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t('Export E2E room keys') }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password,
);
}
},
});
}
},
_onExportE2eKeysClicked: function() {
Modal.createTrackedDialogAsync('Export E2E Keys', 'Forgot Password',
import('../../../async-components/views/dialogs/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value,
});
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
}
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, {
title: title,
description: body,
});
},
render: function() {
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
const Spinner = sdk.getComponent("elements.Spinner");
let resetPasswordJsx;
if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner />;
} else if (this.state.progress === "sent_email") {
resetPasswordJsx = (
<div className="mx_Login_prompt">
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, " +
"click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} />
</div>
);
} else if (this.state.progress === "complete") {
resetPasswordJsx = (
<div className="mx_Login_prompt">
<p>{ _t('Your password has been reset') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. ' +
'To re-enable notifications, sign in again on each device') }.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>
);
} else {
let serverConfigSection;
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
);
}
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
resetPasswordJsx = (
<div>
<div className="mx_Login_prompt">
{ _t('To reset your password, enter the email address linked to your account') }:
</div>
<div>
<form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" ref="user" type="text"
name="reset_email" // define a name so browser's password autofill gets less confused
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
placeholder={_t('Email address')} autoFocus />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password"
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
placeholder={_t('New password')} />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password_confirm"
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
placeholder={_t('Confirm your new password')} />
<br />
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form>
{ serverConfigSection }
{ errorText }
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ _t('Return to login screen') }
</a>
<a className="mx_Login_create" onClick={this.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
<LanguageSelector />
<LoginFooter />
</div>
</div>
);
}
return (
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
{ resetPasswordJsx }
</div>
</LoginPage>
);
},
});

View file

@ -1,504 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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 Matrix from 'matrix-js-sdk';
import Promise from 'bluebird';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
const MIN_PASSWORD_LENGTH = 6;
module.exports = React.createClass({
displayName: 'Registration',
propTypes: {
onLoggedIn: PropTypes.func.isRequired,
clientSecret: PropTypes.string,
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
brand: PropTypes.string,
email: PropTypes.string,
referrer: PropTypes.string,
teamServerConfig: PropTypes.shape({
// Email address to request new teams
supportEmail: PropTypes.string.isRequired,
// URL of the riot-team-server to get team configurations and track referrals
teamServerURL: PropTypes.string.isRequired,
}),
teamSelected: PropTypes.object,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
rtsClient: PropTypes.shape({
getTeamsConfig: PropTypes.func.isRequired,
trackReferral: PropTypes.func.isRequired,
getTeam: PropTypes.func.isRequired,
}),
},
getInitialState: function() {
return {
busy: false,
teamServerBusy: false,
errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
flows: null,
};
},
componentWillMount: function() {
this._unmounted = false;
this._replaceClient();
if (
this.props.teamServerConfig &&
this.props.teamServerConfig.teamServerURL &&
!this._rtsClient
) {
this._rtsClient = this.props.rtsClient || new RtsClient(this.props.teamServerConfig.teamServerURL);
this.setState({
teamServerBusy: true,
});
// GET team configurations including domains, names and icons
this._rtsClient.getTeamsConfig().then((data) => {
const teamsConfig = {
teams: data,
supportEmail: this.props.teamServerConfig.supportEmail,
};
console.log('Setting teams config to ', teamsConfig);
this.setState({
teamsConfig: teamsConfig,
teamServerBusy: false,
});
}, (err) => {
console.error('Error retrieving config for teams', err);
this.setState({
teamServerBusy: false,
});
});
}
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, () => {
this._replaceClient();
});
},
_replaceClient: async function() {
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
});
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
// auth object.
console.log("Expecting 401 from register request but got success!");
} catch (e) {
if (e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else {
this.setState({
errorText: _t("Unable to query for supported registration methods"),
});
}
}
},
onFormSubmit: function(formVals) {
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
this.setState({
errorText: "",
busy: true,
formVals: formVals,
doingUIAuth: true,
});
},
_onUIAuthFinished: function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>;
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
}
}
this.setState({
busy: false,
doingUIAuth: false,
errorText: msg,
});
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
doingUIAuth: false,
});
// Done regardless of `teamSelected`. People registering with non-team emails
// will just nop. The point of this being we might not have the email address
// that the user registered with at this stage (depending on whether this
// is the client they initiated registration).
let trackPromise = Promise.resolve(null);
if (this._rtsClient && extra.emailSid) {
// Track referral if this.props.referrer set, get team_token in order to
// retrieve team config and see welcome page etc.
trackPromise = this._rtsClient.trackReferral(
this.props.referrer || '', // Default to empty string = not referred
extra.emailSid,
extra.clientSecret,
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
this._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`,
);
if (!team.rooms) {
return;
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.room_id);
}
});
}, (err) => {
console.error('Error getting team config', err);
});
return teamToken;
}, (err) => {
console.error('Error tracking referral', err);
});
}
trackPromise.then((teamToken) => {
return this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
}, teamToken);
}).then((cli) => {
return this._setupPushers(cli);
});
},
_setupPushers: function(matrixClient) {
if (!this.props.brand) {
return Promise.resolve();
}
return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
},
onFormValidationFailed: function(errCode) {
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
errMsg = _t('Missing password.');
break;
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.');
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t("Only use lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a user name.');
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = _t('An unknown error occurred.');
break;
}
this.setState({
errorText: errMsg,
});
},
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
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
// session).
const bindThreepids = this.state.formVals.password ? {
email: true,
msisdn: true,
} : {};
return this._matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
bindThreepids,
null,
);
},
_getUIAuthInputs: function() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
phoneNumber: this.state.formVals.phoneNumber,
};
},
render: function() {
const LoginHeader = sdk.getComponent('login.LoginHeader');
const LoginFooter = sdk.getComponent('login.LoginFooter');
const LoginPage = sdk.getComponent('login.LoginPage');
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent("elements.Spinner");
const ServerConfig = sdk.getComponent('views.login.ServerConfig');
const theme = SettingsStore.getValue("theme");
let registerBody;
if (this.state.doingUIAuth) {
registerBody = (
<InteractiveAuth
matrixClient={this._matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>
);
} else if (this.state.busy || this.state.teamServerBusy || !this.state.flows) {
registerBody = <Spinner />;
} else {
let serverConfigSection;
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}
/>
);
}
registerBody = (
<div>
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
flows={this.state.flows}
/>
{ serverConfigSection }
</div>
);
}
let header;
let errorText;
// FIXME: remove hardcoded Status team tweaks at some point
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (theme === 'status' && err) {
header = <div className="mx_Login_error">{ err }</div>;
} else {
header = <h2>{ _t('Create an account') }</h2>;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
}
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
return (
<LoginPage>
<div className="mx_Login_box">
<LoginHeader
icon={this.state.teamSelected ?
this.props.teamServerConfig.teamServerURL + "/static/common/" +
this.state.teamSelected.domain + "/icon.png" :
null}
/>
{ header }
{ registerBody }
{ signIn }
{ errorText }
<LanguageSelector />
<LoginFooter />
</div>
</LoginPage>
);
},
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
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.
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Functions for applying common thematic effects to UI elements.
* Ideally this would be themeable.
*/
'use strict';
import Velocity from 'velocity-vector';
import 'velocity-vector/velocity.ui';
import React from 'react';
export function field_input_incorrect(element) {
Velocity(element, "callout.shake", 300);
export default class AuthBody extends React.PureComponent {
render() {
return <div className="mx_AuthBody">
{ this.props.children }
</div>;
}
}

View file

@ -1,6 +1,6 @@
/*
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.
@ -18,12 +18,12 @@ limitations under the License.
'use strict';
const React = require('react');
import { _t } from '../../languageHandler';
const dis = require('../../dispatcher');
const AccessibleButton = require('../../components/views/elements/AccessibleButton');
import { _t } from '../../../languageHandler';
const dis = require('../../../dispatcher');
const AccessibleButton = require('../elements/AccessibleButton');
module.exports = React.createClass({
displayName: 'LoginBox',
displayName: 'AuthButtons',
propTypes: {
},
@ -38,18 +38,18 @@ module.exports = React.createClass({
render: function() {
const loginButton = (
<div className="mx_LoginBox_loginButton_wrapper">
<AccessibleButton className="mx_LoginBox_loginButton" element="button" onClick={this.onLoginClick}>
<div className="mx_AuthButtons_loginButton_wrapper">
<AccessibleButton className="mx_AuthButtons_loginButton" element="button" onClick={this.onLoginClick}>
{ _t("Login") }
</AccessibleButton>
<AccessibleButton className="mx_LoginBox_registerButton" element="button" onClick={this.onRegisterClick}>
<AccessibleButton className="mx_AuthButtons_registerButton" element="button" onClick={this.onRegisterClick}>
{ _t("Register") }
</AccessibleButton>
</div>
);
return (
<div className="mx_LoginBox">
<div className="mx_AuthButtons">
{ loginButton }
</div>
);

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -20,12 +21,12 @@ import { _t } from '../../../languageHandler';
import React from 'react';
module.exports = React.createClass({
displayName: 'LoginFooter',
displayName: 'AuthFooter',
render: function() {
return (
<div className="mx_Login_links">
<a href="https://matrix.org">{ _t("powered by Matrix") }</a>
<div className="mx_AuthFooter">
<a href="https://matrix.org" target="_blank" rel="noopener">{ _t("powered by Matrix") }</a>
</div>
);
},

View file

@ -0,0 +1,37 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
*/
'use strict';
const React = require('react');
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'AuthHeader',
render: function() {
const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo');
const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector');
return (
<div className="mx_AuthHeader">
<AuthHeaderLogo />
<LanguageSelector />
</div>
);
},
});

View file

@ -0,0 +1,27 @@
/*
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.
*/
'use strict';
import React from 'react';
export default class AuthHeaderLogo extends React.PureComponent {
render() {
return <div className="mx_AuthHeaderLogo">
Matrix
</div>;
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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.
@ -17,14 +18,20 @@ limitations under the License.
'use strict';
const React = require('react');
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'LoginHeader',
displayName: 'AuthPage',
render: function() {
const AuthFooter = sdk.getComponent('auth.AuthFooter');
return (
<div className="mx_Login_logo">
Matrix
<div className="mx_AuthPage">
<div className="mx_AuthPage_modal">
{ this.props.children }
</div>
<AuthFooter />
</div>
);
},

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
@ -61,25 +60,15 @@ module.exports = React.createClass({
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
const protocol = global.location.protocol;
if (protocol === "file:") {
const warning = document.createElement('div');
// XXX: fix hardcoded app URL. Better solutions include:
// * jumping straight to a hosted captcha page (but we don't support that yet)
// * embedding the captcha in an iframe (if that works)
// * using a better captcha lib
ReactDOM.render(_t(
"Robot check is currently unavailable on desktop - please use a <a>web browser</a>",
{},
{ 'a': (sub) => { return <a href='https://riot.im/app'>{ sub }</a>; }}), warning);
this.refs.recaptchaContainer.appendChild(warning);
} else {
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit",
);
this.refs.recaptchaContainer.appendChild(scriptTag);
let protocol = global.location.protocol;
if (protocol === "vector:") {
protocol = "https:";
}
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', `${protocol}//www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`,
);
this.refs.recaptchaContainer.appendChild(scriptTag);
}
},
@ -137,8 +126,9 @@ module.exports = React.createClass({
return (
<div ref="recaptchaContainer">
{ _t("This Home Server would like to make sure you are not a robot") }
<br />
<p>{_t(
"This homeserver would like to make sure you are not a robot.",
)}</p>
<div id={DIV_ID}></div>
{ error }
</div>

View file

@ -21,7 +21,7 @@ import sdk from '../../../index';
import { COUNTRIES } from '../../../phonenumber';
const COUNTRIES_BY_ISO2 = new Object(null);
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
COUNTRIES_BY_ISO2[c.iso2] = c;
}
@ -70,7 +70,7 @@ export default class CountryDropdown extends React.Component {
}
_flagImgForIso2(iso2) {
return <img src={`img/flags/${iso2}.png`} />;
return <img src={require(`../../../../res/img/flags/${iso2}.png`)} />;
}
_getShortOption(iso2) {
@ -81,7 +81,7 @@ export default class CountryDropdown extends React.Component {
if (this.props.showPrefix) {
countryPrefix = '+' + COUNTRIES_BY_ISO2[iso2].prefix;
}
return <span>
return <span className="mx_CountryDropdown_shortOption">
{ this._flagImgForIso2(iso2) }
{ countryPrefix }
</span>;
@ -111,9 +111,9 @@ export default class CountryDropdown extends React.Component {
}
const options = displayedCountries.map((country) => {
return <div key={country.iso2}>
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ country.name } <span>(+{ country.prefix })</span>
{ country.name } (+{ country.prefix })
</div>;
});
@ -121,7 +121,7 @@ export default class CountryDropdown extends React.Component {
// values between mounting and the initial value propgating
const value = this.props.value || COUNTRIES[0].iso2;
return <Dropdown className={this.props.className + " left_aligned"}
return <Dropdown className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}
menuWidth={298} getShortOption={this._getShortOption}
value={value} searchEnabled={true} disabled={this.props.disabled}

View file

@ -27,17 +27,17 @@ module.exports = React.createClass({
{ _t("Custom Server Options") }
</div>
<div className="mx_Dialog_content">
<span>
{ _t("You can use the custom server options to sign into other Matrix " +
"servers by specifying a different Home server URL.") }
<br />
{ _t("This allows you to use this app with an existing Matrix account on " +
"a different home server.") }
<br />
<br />
{ _t("You can also set a custom identity server but this will typically prevent " +
"interaction with users based on email address.") }
</span>
<p>{_t(
"You can use the custom server options to sign into other " +
"Matrix servers by specifying a different homeserver URL. This " +
"allows you to use this app with an existing Matrix account on a " +
"different homeserver.",
)}</p>
<p>{_t(
"You can also set a custom identity server, but you won't be " +
"able to invite users by email address, or be invited by email " +
"address yourself.",
)}</p>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}>

View file

@ -187,7 +187,7 @@ export const RecaptchaAuthEntry = React.createClass({
return <Loader />;
}
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm");
const sitePublicKey = this.props.stageParams.public_key;
let errorSection;
@ -294,7 +294,7 @@ export const TermsAuthEntry = React.createClass({
_trySubmit: function() {
let allChecked = true;
for (const policy of this.state.policies) {
let checked = this.state.toggledPolicies[policy.id];
const checked = this.state.toggledPolicies[policy.id];
allChecked = allChecked && checked;
}
@ -334,7 +334,7 @@ export const TermsAuthEntry = React.createClass({
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_UserSettings_button"
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
}
@ -440,7 +440,6 @@ export const MsisdnAuthEntry = React.createClass({
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
submitAuthDict: PropTypes.func,
},
getInitialState: function() {
@ -526,7 +525,7 @@ export const MsisdnAuthEntry = React.createClass({
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
mx_GeneralButton: true,
});
let errorSection;
if (this.state.errorText) {

View file

@ -32,7 +32,8 @@ export default function LanguageSelector() {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={onChange} className="mx_Login_language" value={getCurrentLanguage()} />
</div>;
return <LanguageDropdown className="mx_AuthBody_language"
onOptionChange={onChange}
value={getCurrentLanguage()}
/>;
}

View file

@ -0,0 +1,128 @@
/*
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 { _t } from '../../../languageHandler';
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
/*
* Configure the Modular server name.
*
* This is a variant of ServerConfig with only the HS field and different body
* text that is specific to the Modular case.
*/
export default class ModularServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
// default URLs are defined in config.json (or the hardcoded defaults)
// they are used if the user has not overridden them with a custom URL.
// In other words, if the custom URL is blank, the default is used.
defaultHsUrl: PropTypes.string, // e.g. https://matrix.org
// This component always uses the default IS URL and doesn't allow it
// to be changed. We still receive it as a prop here to simplify
// consumers by still passing the IS URL via onServerConfigChange.
defaultIsUrl: PropTypes.string, // e.g. https://vector.im
// custom URLs are explicitly provided by the user and override the
// default URLs. The user enters them via the component's input fields,
// which is reflected on these properties whenever on..UrlChanged fires.
// They are persisted in localStorage by MatrixClientPeg, and so can
// override the default URLs when the component initially loads.
customHsUrl: PropTypes.string,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
delayTimeMs: 0,
}
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl) return;
this.setState({
hsUrl: newProps.customHsUrl,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: this.props.defaultIsUrl,
});
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.props.defaultIsUrl,
});
});
}
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
clearTimeout(existingTimeoutId);
}
return setTimeout(fn.bind(this), this.props.delayTimeMs);
}
render() {
const Field = sdk.getComponent('elements.Field');
return (
<div className="mx_ServerConfig">
<h3>{_t("Your Modular server")}</h3>
{_t(
"Enter the location of your Modular homeserver. It may use your own " +
"domain name or be a subdomain of <a>modular.im</a>.",
{}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noopener">
{sub}
</a>,
},
)}
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.defaultHsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
</div>
);
}
}

View file

@ -20,7 +20,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {field_input_incorrect} from '../../../UiEffects';
import SdkConfig from '../../../SdkConfig';
/**
@ -29,18 +28,23 @@ import SdkConfig from '../../../SdkConfig';
class PasswordLogin extends React.Component {
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
onUsernameChanged: function() {},
onUsernameBlur: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
onPhoneNumberBlur: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
hsDomain: "",
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about where to "sign in to".
hsName: null,
hsUrl: "",
disableSubmit: false,
}
@ -54,25 +58,22 @@ class PasswordLogin extends React.Component {
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
componentWillMount() {
this._passwordField = null;
this._loginField = null;
}
componentWillReceiveProps(nextProps) {
if (!this.props.loginIncorrect && nextProps.loginIncorrect) {
field_input_incorrect(this.isLoginEmpty() ? this._loginField : this._passwordField);
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
@ -93,7 +94,7 @@ class PasswordLogin extends React.Component {
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The user name field must not be blank.');
error = _t('The username field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
@ -129,10 +130,11 @@ class PasswordLogin extends React.Component {
}
onUsernameBlur(ev) {
this.props.onUsernameBlur(this.state.username);
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(loginType) {
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
@ -153,80 +155,77 @@ class PasswordLogin extends React.Component {
this.props.onPhoneNumberChanged(ev.target.value);
}
onPhoneNumberBlur(ev) {
this.props.onPhoneNumberBlur(ev.target.value);
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
}
renderLoginField(loginType, disabled) {
const classes = {
mx_Login_field: true,
mx_Login_field_disabled: disabled,
};
renderLoginField(loginType) {
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.mx_Login_email = true;
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
return <Field
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
id="mx_PasswordLogin_email"
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
autoFocus
disabled={disabled}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.mx_Login_username = true;
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="username_input"
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
placeholder={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("User name")}
value={this.state.username}
autoFocus
disabled={disabled}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_username"
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
classes.mx_Login_phoneNumberField = true;
classes.mx_Login_field_has_prefix = true;
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
return <div className="mx_Login_phoneSection">
<CountryDropdown
className="mx_Login_phoneCountry mx_Login_field_prefix"
onOptionChange={this.onPhoneCountryChanged}
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
disabled={disabled}
/>
<input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="phone_input"
type="text"
name="phoneNumber"
onChange={this.onPhoneNumberChanged}
placeholder={_t("Mobile phone number")}
value={this.state.phoneNumber}
autoFocus
disabled={disabled}
/>
</div>;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_phoneNumber"
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
autoFocus
/>;
}
}
}
@ -242,72 +241,113 @@ class PasswordLogin extends React.Component {
}
render() {
const Field = sdk.getComponent('elements.Field');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = (
<a className="mx_Login_forgot" onClick={this.props.onForgotPasswordClick} href="#">
{ _t('Forgot your password?') }
</a>
);
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => <a className="mx_Login_forgot"
onClick={this.onForgotPasswordClick}
href="#"
>
{sub}
</a>,
})}
</span>;
}
let matrixIdText = _t('Matrix ID');
let signInToText = _t('Sign in to your Matrix account');
if (this.props.hsName) {
matrixIdText = _t('%(serverName)s Matrix ID', {serverName: this.props.hsName});
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
});
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
matrixIdText = _t('%(serverName)s Matrix ID', {serverName: parsedHsUrl.hostname});
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
const pwFieldClass = classNames({
mx_Login_field: true,
mx_Login_field_disabled: matrixIdText === '',
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
const Dropdown = sdk.getComponent('elements.Dropdown');
const loginField = this.renderLoginField(this.state.loginType, matrixIdText === '');
const loginField = this.renderLoginField(this.state.loginType);
let loginType;
if (!SdkConfig.get().disable_3pid_login) {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Dropdown
<Field
className="mx_Login_type_dropdown"
id="mx_PasswordLogin_type"
element="select"
value={this.state.loginType}
disabled={matrixIdText === ''}
onOptionChange={this.onLoginTypeChange}>
<span key={PasswordLogin.LOGIN_FIELD_MXID}>{ matrixIdText }</span>
<span key={PasswordLogin.LOGIN_FIELD_EMAIL}>{ _t('Email address') }</span>
<span key={PasswordLogin.LOGIN_FIELD_PHONE}>{ _t('Phone') }</span>
</Dropdown>
onChange={this.onLoginTypeChange}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
const disableSubmit = this.props.disableSubmit || matrixIdText === '';
return (
<div>
<h3>
{signInToText}
{editLink}
</h3>
<form onSubmit={this.onSubmitForm}>
{ loginType }
{ loginField }
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
name="password"
value={this.state.password} onChange={this.onPasswordChanged}
placeholder={_t('Password')}
disabled={matrixIdText === ''}
/>
<br />
{ forgotPasswordJsx }
<input className="mx_Login_submit" type="submit" value={_t('Sign in')} disabled={disableSubmit} />
{loginType}
{loginField}
<Field
className={pwFieldClass}
id="mx_PasswordLogin_password"
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
/>
{forgotPasswordJsx}
<input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/>
</form>
</div>
);
@ -332,6 +372,7 @@ PasswordLogin.propTypes = {
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
hsName: PropTypes.string,
hsUrl: PropTypes.string,
disableSubmit: PropTypes.bool,
};

View file

@ -0,0 +1,462 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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 sdk from '../../../index';
import Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
/**
* A pure UI component which displays a registration form.
*/
module.exports = React.createClass({
displayName: 'RegistrationForm',
propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: PropTypes.string,
defaultPhoneCountry: PropTypes.string,
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onValidationChange: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about "your account".
hsName: PropTypes.string,
hsUrl: PropTypes.string,
},
getDefaultProps: function() {
return {
minPasswordLength: 6,
onValidationChange: console.error,
};
},
getInitialState: function() {
return {
// Field error codes by field ID
fieldErrors: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirm: "",
};
},
onSubmit: function(ev) {
ev.preventDefault();
// validate everything, in reverse order so
// the error that ends up being displayed
// is the one from the first invalid field.
// It's not super ideal that this just calls
// onValidationChange once for each invalid field.
this.validateField(FIELD_PHONE_NUMBER, ev.type);
this.validateField(FIELD_EMAIL, ev.type);
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
this.validateField(FIELD_PASSWORD, ev.type);
this.validateField(FIELD_USERNAME, ev.type);
const self = this;
if (this.allFieldsValid()) {
if (this.state.email == '') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?") }
</div>,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
}
}
},
_doSubmit: function(ev) {
const email = this.state.email.trim();
const promise = this.props.onRegisterClick({
username: this.state.username.trim(),
password: this.state.password.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.state.phoneNumber,
});
if (promise) {
ev.target.disabled = true;
promise.finally(function() {
ev.target.disabled = false;
});
}
},
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid: function() {
const keys = Object.keys(this.state.fieldErrors);
for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldErrors[keys[i]]) {
return false;
}
}
return true;
},
validateField: function(fieldID, eventType) {
const pwd1 = this.state.password.trim();
const pwd2 = this.state.passwordConfirm.trim();
const allowEmpty = eventType === "blur";
switch (fieldID) {
case FIELD_EMAIL: {
const email = this.state.email;
const emailValid = email === '' || Email.looksValid(email);
if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL");
} else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
}
case FIELD_PHONE_NUMBER: {
const phoneNumber = this.state.phoneNumber;
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER");
} else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break;
}
case FIELD_USERNAME: {
const username = this.state.username;
if (allowEmpty && username === '') {
this.markFieldValid(fieldID, true);
} else if (!SAFE_LOCALPART_REGEX.test(username)) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_INVALID",
);
} else if (username == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_BLANK",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
}
case FIELD_PASSWORD:
if (allowEmpty && pwd1 === "") {
this.markFieldValid(fieldID, true);
} else if (pwd1 == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_MISSING",
);
} else if (pwd1.length < this.props.minPasswordLength) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_LENGTH",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
case FIELD_PASSWORD_CONFIRM:
if (allowEmpty && pwd2 === "") {
this.markFieldValid(fieldID, true);
} else {
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
}
break;
}
},
markFieldValid: function(fieldID, valid, errorCode) {
const { fieldErrors } = this.state;
if (valid) {
fieldErrors[fieldID] = null;
} else {
fieldErrors[fieldID] = errorCode;
}
this.setState({
fieldErrors,
});
this.props.onValidationChange(fieldErrors);
},
_classForField: function(fieldID, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldErrors[fieldID]) {
if (cls) cls += ' ';
cls += 'error';
}
return cls;
},
onEmailBlur(ev) {
this.validateField(FIELD_EMAIL, ev.type);
},
onEmailChange(ev) {
this.setState({
email: ev.target.value,
});
},
onPasswordBlur(ev) {
this.validateField(FIELD_PASSWORD, ev.type);
},
onPasswordChange(ev) {
this.setState({
password: ev.target.value,
});
},
onPasswordConfirmBlur(ev) {
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
},
onPasswordConfirmChange(ev) {
this.setState({
passwordConfirm: ev.target.value,
});
},
onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
},
onPhoneNumberBlur(ev) {
this.validateField(FIELD_PHONE_NUMBER, ev.type);
},
onPhoneNumberChange(ev) {
this.setState({
phoneNumber: ev.target.value,
});
},
onUsernameBlur(ev) {
this.validateField(FIELD_USERNAME, ev.type);
},
onUsernameChange(ev) {
this.setState({
username: ev.target.value,
});
},
/**
* A step is required if all flows include that step.
*
* @param {string} step A stage name to check
* @returns {boolean} Whether it is required
*/
_authStepIsRequired(step) {
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
});
},
/**
* A step is used if any flows include that step.
*
* @param {string} step A stage name to check
* @returns {boolean} Whether it is used
*/
_authStepIsUsed(step) {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
},
render: function() {
const Field = sdk.getComponent('elements.Field');
let yourMatrixAccountText = _t('Create your Matrix account');
if (this.props.hsName) {
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
});
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
let emailSection;
if (this._authStepIsUsed('m.login.email.identity')) {
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
emailSection = (
<Field
className={this._classForField(FIELD_EMAIL)}
id="mx_RegistrationForm_email"
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onBlur={this.onEmailBlur}
onChange={this.onEmailChange}
/>
);
}
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
let phoneSection;
if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) {
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
phoneSection = <Field
className={this._classForField(FIELD_PHONE_NUMBER)}
id="mx_RegistrationForm_phoneNumber"
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onBlur={this.onPhoneNumberBlur}
onChange={this.onPhoneNumberChange}
/>;
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
);
return (
<div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
<Field
className={this._classForField(FIELD_USERNAME)}
id="mx_RegistrationForm_username"
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onBlur={this.onUsernameBlur}
onChange={this.onUsernameChange}
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
className={this._classForField(FIELD_PASSWORD)}
id="mx_RegistrationForm_password"
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onBlur={this.onPasswordBlur}
onChange={this.onPasswordChange}
/>
<Field
className={this._classForField(FIELD_PASSWORD_CONFIRM)}
id="mx_RegistrationForm_passwordConfirm"
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onBlur={this.onPasswordConfirmBlur}
onChange={this.onPasswordConfirmChange}
/>
</div>
<div className="mx_AuthBody_fieldRow">
{ emailSection }
{ phoneSection }
</div>
{_t(
"Use an email address to recover your account. Other users " +
"can invite you to rooms using your contact details.",
)}
{ registerButton }
</form>
</div>
);
},
});

View file

@ -0,0 +1,149 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
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 Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
/*
* A pure UI component which displays the HS and IS to use.
*/
export default class ServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
// default URLs are defined in config.json (or the hardcoded defaults)
// they are used if the user has not overridden them with a custom URL.
// In other words, if the custom URL is blank, the default is used.
defaultHsUrl: PropTypes.string, // e.g. https://matrix.org
defaultIsUrl: PropTypes.string, // e.g. https://vector.im
// custom URLs are explicitly provided by the user and override the
// default URLs. The user enters them via the component's input fields,
// which is reflected on these properties whenever on..UrlChanged fires.
// They are persisted in localStorage by MatrixClientPeg, and so can
// override the default URLs when the component initially loads.
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
customIsUrl: "",
delayTimeMs: 0,
}
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
isUrl: props.customIsUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl &&
newProps.customIsUrl === this.state.isUrl) return;
this.setState({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
});
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.state.isUrl,
});
});
}
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
onIdentityServerBlur = (ev) => {
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.state.isUrl,
});
});
}
onIdentityServerChange = (ev) => {
const isUrl = ev.target.value;
this.setState({ isUrl });
}
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
clearTimeout(existingTimeoutId);
}
return setTimeout(fn.bind(this), this.props.delayTimeMs);
}
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
}
render() {
const Field = sdk.getComponent('elements.Field');
return (
<div className="mx_ServerConfig">
<h3>{_t("Other servers")}</h3>
{_t("Enter custom server URLs <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{ sub }
</a>,
})}
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.defaultHsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.defaultIsUrl}
value={this.state.isUrl}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
/>
</div>
</div>
);
}
}

View file

@ -0,0 +1,144 @@
/*
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 { _t } from '../../../languageHandler';
import sdk from '../../../index';
import classnames from 'classnames';
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
export const FREE = 'Free';
export const PREMIUM = 'Premium';
export const ADVANCED = 'Advanced';
export const TYPES = {
FREE: {
id: FREE,
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
description: () => _t('Join millions for free on the largest public server'),
hsUrl: 'https://matrix.org',
isUrl: 'https://vector.im',
},
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noopener">
{sub}
</a>,
}),
},
ADVANCED: {
id: ADVANCED,
label: () => _t('Advanced'),
logo: () => <div>
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
{_t('Other')}
</div>,
description: () => _t('Find other public servers or use a custom server'),
},
};
export function getTypeFromHsUrl(hsUrl) {
if (!hsUrl) {
return null;
} else if (hsUrl === TYPES.FREE.hsUrl) {
return FREE;
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
// This is an unlikely case to reach, as Modular defaults to hiding the
// server type selector.
return PREMIUM;
} else {
return ADVANCED;
}
}
export default class ServerTypeSelector extends React.PureComponent {
static propTypes = {
// The default selected type.
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
const {
selected,
} = props;
this.state = {
selected,
};
}
updateSelectedType(type) {
if (this.state.selected === type) {
return;
}
this.setState({
selected: type,
});
if (this.props.onChange) {
this.props.onChange(type);
}
}
onClick = (e) => {
e.stopPropagation();
const type = e.currentTarget.dataset.id;
this.updateSelectedType(type);
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const serverTypes = [];
for (const type of Object.values(TYPES)) {
const { id, label, logo, description } = type;
const classes = classnames(
"mx_ServerTypeSelector_type",
`mx_ServerTypeSelector_type_${id}`,
{
"mx_ServerTypeSelector_type_selected": id === this.state.selected,
},
);
serverTypes.push(<div className={classes} key={id} >
<div className="mx_ServerTypeSelector_label">
{label()}
</div>
<AccessibleButton onClick={this.onClick} data-id={id}>
<div className="mx_ServerTypeSelector_logo">
{logo()}
</div>
<div className="mx_ServerTypeSelector_description">
{description()}
</div>
</AccessibleButton>
</div>);
}
return <div className="mx_ServerTypeSelector">
{serverTypes}
</div>;
}
}

View file

@ -0,0 +1,47 @@
/*
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 sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
export default class Welcome extends React.PureComponent {
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
const pagesConfig = SdkConfig.get().embeddedPages;
let pageUrl = null;
if (pagesConfig) {
pageUrl = pagesConfig.welcomeUrl;
}
if (!pageUrl) {
pageUrl = 'welcome.html';
}
return (
<AuthPage>
<div className="mx_Welcome">
<EmbeddedPage className="mx_WelcomePage"
url={pageUrl}
/>
<LanguageSelector />
</div>
</AuthPage>
);
}
}

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

@ -158,7 +158,8 @@ module.exports = React.createClass({
<BaseAvatar {...otherProps} name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls}
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null} />
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null}
disabled={!this.state.urls[0]} />
);
},
});

View file

@ -79,7 +79,7 @@ export default class GroupInviteTileContextMenu extends React.Component {
render() {
return <div>
<div className="mx_RoomTileContextMenu_leave" onClick={this._onClickReject} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_delete.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ _t('Reject') }
</div>
</div>;

View file

@ -26,7 +26,6 @@ import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import {makeEventPermalink} from '../../../matrix-to';
import { isUrlPermitted } from '../../../HtmlUtils';
module.exports = React.createClass({
@ -90,9 +89,16 @@ module.exports = React.createClass({
this.closeMenu();
},
e2eInfoClicked: function() {
this.props.e2eInfoCallback();
this.closeMenu();
},
onViewSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
content: this.props.mxEvent.event,
}, 'mx_Dialog_viewsource');
this.closeMenu();
@ -101,6 +107,8 @@ module.exports = React.createClass({
onViewClearSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
// FIXME: _clearEvent is private
content: this.props.mxEvent._clearEvent,
}, 'mx_Dialog_viewsource');
@ -188,6 +196,7 @@ module.exports = React.createClass({
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
},
@ -206,7 +215,8 @@ module.exports = React.createClass({
},
render: function() {
const eventStatus = this.props.mxEvent.status;
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
let resendButton;
let redactButton;
let cancelButton;
@ -246,8 +256,8 @@ module.exports = React.createClass({
);
}
if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (isSent && mxEvent.getType() === 'm.room.message') {
const content = mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -277,7 +287,7 @@ module.exports = React.createClass({
</div>
);
if (this.props.mxEvent.getType() !== this.props.mxEvent.getWireType()) {
if (mxEvent.getType() !== mxEvent.getWireType()) {
viewClearSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
{ _t('View Decrypted Source') }
@ -295,11 +305,18 @@ module.exports = React.createClass({
}
}
let permalink;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<div className="mx_MessageContextMenu_field">
<a href={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())}
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>{ _t('Share Message') }</a>
<a href={permalink}
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
</a>
</div>
);
@ -313,12 +330,12 @@ module.exports = React.createClass({
// Bridges can provide a 'external_url' to link back to the source.
if (
typeof(this.props.mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(this.props.mxEvent.event.content.external_url)
typeof(mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(mxEvent.event.content.external_url)
) {
externalURLButton = (
<div className="mx_MessageContextMenu_field">
<a href={this.props.mxEvent.event.content.external_url}
<a href={mxEvent.event.content.external_url}
rel="noopener" target="_blank" onClick={this.closeMenu}>{ _t('Source URL') }</a>
</div>
);
@ -332,6 +349,13 @@ module.exports = React.createClass({
);
}
let e2eInfo;
if (this.props.e2eInfoCallback) {
e2eInfo = <div className="mx_MessageContextMenu_field" onClick={this.e2eInfoClicked}>
{ _t('End-to-end encryption information') }
</div>;
}
return (
<div className="mx_MessageContextMenu">
{ resendButton }
@ -347,6 +371,7 @@ module.exports = React.createClass({
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }
</div>
);
},

View file

@ -245,32 +245,53 @@ module.exports = React.createClass({
return (
<div className="mx_RoomTileContextMenu">
<div className="mx_RoomTileContextMenu_notif_picker" >
<img src="img/notif-slider.svg" width="20" height="107" />
<img src={require("../../../../res/img/notif-slider.svg")} width="20" height="107" />
</div>
<div className={alertMeClasses} onClick={this._onClickAlertMe} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off-copy.svg" width="16" height="12" />
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-off-copy.svg")} width="16" height="12" />
{ _t('All messages (noisy)') }
</div>
<div className={allNotifsClasses} onClick={this._onClickAllNotifs} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off.svg" width="16" height="12" />
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-off.svg")} width="16" height="12" />
{ _t('All messages') }
</div>
<div className={mentionsClasses} onClick={this._onClickMentions} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-mentions.svg" width="16" height="12" />
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-mentions.svg")} width="16" height="12" />
{ _t('Mentions only') }
</div>
<div className={muteNotifsClasses} onClick={this._onClickMute} >
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute.svg" width="16" height="12" />
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute.svg")} width="16" height="12" />
{ _t('Mute') }
</div>
</div>
);
},
_onClickSettings: function() {
dis.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
if (this.props.onFinished) {
this.props.onFinished();
}
},
_renderSettingsMenu: function() {
return (
<div>
<div className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" />
{ _t('Settings') }
</div>
</div>
);
},
_renderLeaveMenu: function(membership) {
if (!membership) {
return null;
@ -298,7 +319,7 @@ module.exports = React.createClass({
return (
<div>
<div className="mx_RoomTileContextMenu_leave" onClick={leaveClickHandler} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_delete.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ leaveText }
</div>
</div>
@ -327,18 +348,18 @@ module.exports = React.createClass({
return (
<div>
<div className={favouriteClasses} onClick={this._onClickFavourite} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_fave.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_fave_on.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_fave.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_fave_on.svg")} width="15" height="15" />
{ _t('Favourite') }
</div>
<div className={lowPriorityClasses} onClick={this._onClickLowPriority} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_low.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_low_on.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_low.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_low_on.svg")} width="15" height="15" />
{ _t('Low Priority') }
</div>
<div className={dmClasses} onClick={this._onClickDM} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_person.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_person_on.svg" width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_person.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_person_on.svg")} width="15" height="15" />
{ _t('Direct Chat') }
</div>
</div>
@ -350,7 +371,11 @@ module.exports = React.createClass({
// Can't set notif level or tags on non-join rooms
if (myMembership !== 'join') {
return this._renderLeaveMenu(myMembership);
return <div>
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
}
return (
@ -360,6 +385,8 @@ module.exports = React.createClass({
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>
);
},

View file

@ -18,8 +18,8 @@ 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 = {
@ -31,56 +31,110 @@ export default class StatusMessageContextMenu extends React.Component {
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);
}
componentWillUnmount() {
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>;
}
}

Some files were not shown because too many files have changed in this diff Show more