Merge branch 'develop' into matthew/low_bandwidth

This commit is contained in:
Travis Ralston 2019-05-30 19:42:09 -06:00
commit d81804e0fe
589 changed files with 37701 additions and 15344 deletions

View file

@ -84,6 +84,11 @@ const customVariables = {
expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'),
example: 'off',
},
'Breadcrumbs': {
id: 9,
expl: _td("Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)"),
example: 'disabled',
},
'Homeserver URL': {
id: 7,
expl: _td('Your homeserver\'s URL'),
@ -201,6 +206,7 @@ class Analytics {
trackEvent(category, action, name, value) {
if (this.disabled) return;
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackEvent', category, action, name, value]);
}
@ -233,6 +239,11 @@ class Analytics {
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
}
setBreadcrumbs(state) {
if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
}
showDetailsModal() {
let rows = [];
if (window.Piwik) {
@ -267,7 +278,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

@ -17,6 +17,7 @@ limitations under the License.
'use strict';
import {ContentRepo} from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg';
import DMRoomMap from './utils/DMRoomMap';
module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) {
@ -51,11 +52,78 @@ 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 require('../res/img/' + images[total % images.length] + '.png');
},
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
* @param {string} name
* @return {string} the first letter
*/
getInitialLetter(name) {
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
avatarUrlForRoom(room, width, height, resizeMethod) {
const explicitRoomAvatar = room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
if (explicitRoomAvatar) {
return explicitRoomAvatar;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
width,
height,
resizeMethod,
false,
);
}
return null;
},
};

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

@ -361,7 +361,7 @@ async function _startCallApp(roomId, type) {
Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
title: _t('Could not connect to the integration server'),
description: _t('A conference call could not be started because the intgrations server is not available'),
description: _t('A conference call could not be started because the integrations server is not available'),
});
return;
}

View file

@ -1,86 +0,0 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
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 { Value } from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}
toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push(
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

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,21 +18,27 @@ 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");
import Modal from './Modal';
import RoomViewStore from './stores/RoomViewStore';
import encrypt from "browser-encrypt-attachment";
import extractPngChunks 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;
// scraped out of a macOS hidpi (5660ppm) screenshot png
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01];
export class UploadCanceledError extends Error {}
/**
* Create a thumbnail for a image DOM element.
@ -91,27 +98,51 @@ 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) {
const deferred = Promise.defer();
async function loadImageElement(imageFile) {
// Load the file into an html element
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
const imgPromise = new Promise((resolve, reject) => {
img.onload = function() {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = function(e) {
reject(e);
};
});
img.src = objectUrl;
// Once ready, create a thumbnail
img.onload = function() {
URL.revokeObjectURL(objectUrl);
deferred.resolve(img);
};
img.onerror = function(e) {
deferred.reject(e);
};
// check for hi-dpi PNGs and fudge display resolution as needed.
// this is mainly needed for macOS screencaps
let parsePromise;
if (imageFile.type === "image/png") {
// in practice macOS happens to order the chunks so they fall in
// the first 0x1000 bytes (thanks to a massive ICC header).
// Thus we could slice the file down to only sniff the first 0x1000
// bytes (but this makes extractPngChunks choke on the corrupt file)
const headers = imageFile; //.slice(0, 0x1000);
parsePromise = readFileAsArrayBuffer(headers).then(arrayBuffer => {
const buffer = new Uint8Array(arrayBuffer);
const chunks = extractPngChunks(buffer);
for (const chunk of chunks) {
if (chunk.name === 'pHYs') {
if (chunk.data.byteLength !== PHYS_HIDPI.length) return;
const hidpi = chunk.data.every((val, i) => val === PHYS_HIDPI[i]);
return hidpi;
}
}
return false;
});
}
return deferred.promise;
const [hidpi] = await Promise.all([parsePromise, imgPromise]);
const width = hidpi ? (img.width >> 1) : img.width;
const height = hidpi ? (img.height >> 1) : img.height;
return {width, height, img};
}
/**
@ -119,7 +150,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) {
@ -129,8 +160,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
}
let imageInfo;
return loadImageElement(imageFile).then(function(img) {
return createThumbnail(img, img.width, img.height, thumbnailType);
return loadImageElement(imageFile).then(function(r) {
return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) {
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
@ -144,7 +175,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) {
@ -179,7 +210,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) {
@ -200,6 +231,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.
*/
@ -233,28 +265,40 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory.
return readFileAsArrayBuffer(file).then(function(data) {
let canceled = false;
let uploadPromise;
let encryptInfo;
const prom = readFileAsArrayBuffer(file).then(function(data) {
if (canceled) throw new UploadCanceledError();
// Then encrypt the file.
return encrypt.encryptAttachment(data);
}).then(function(encryptResult) {
if (canceled) throw new UploadCanceledError();
// Record the information needed to decrypt the attachment.
const encryptInfo = encryptResult.info;
encryptInfo = encryptResult.info;
// Pass the encrypted data as a Blob to the uploader.
const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob, {
uploadPromise = matrixClient.uploadContent(blob, {
progressHandler: progressHandler,
includeFilename: false,
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
encryptInfo.url = url;
if (file.type) {
encryptInfo.mimetype = file.type;
}
return {"file": encryptInfo};
});
return uploadPromise;
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and
// add it under a file key.
encryptInfo.url = url;
if (file.type) {
encryptInfo.mimetype = file.type;
}
return {"file": encryptInfo};
});
prom.abort = () => {
canceled = true;
if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise);
};
return prom;
} else {
const basePromise = matrixClient.uploadContent(file, {
progressHandler: progressHandler,
@ -269,11 +313,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) {
@ -283,7 +359,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: {
@ -333,6 +492,9 @@ class ContentMessages {
this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'});
// Focus the composer view
dis.dispatch({action: 'focus_composer'});
let error;
function onProgress(ev) {
@ -357,9 +519,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 homeserver\'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, {
@ -377,15 +542,22 @@ 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'});
}
});
}
getCurrentUploads() {
return this.inprogress;
return this.inprogress.filter(u => !u.canceled);
}
cancelUpload(promise) {
@ -401,12 +573,7 @@ class ContentMessages {
if (upload) {
upload.canceled = true;
MatrixClientPeg.get().cancelUpload(upload.promise);
dis.dispatch({action: 'upload_canceled', upload});
}
}
}
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
@ -87,10 +116,8 @@ export default class FromWidgetPostMessageApi {
const origin = u.protocol + '//' + u.host;
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
const length = this.widgetMessagingEndpoints.length;
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints.
filter(function(endpoint) {
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
});
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints
.filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin);
return (length > this.widgetMessagingEndpoints.length);
}
return false;
@ -117,6 +144,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 +198,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

@ -38,7 +38,7 @@ export function showGroupInviteDialog(groupId) {
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
title: _t("Invite new community members"),
description: description,
placeholder: _t("Name or matrix ID"),
placeholder: _t("Name or Matrix ID"),
button: _t("Invite to Community"),
validAddressTypes: ['mx-user-id'],
onFinished: (success, addrs) => {

View file

@ -19,39 +19,46 @@ 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';
emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG)
emojione.imagePathPNG = 'emojione/png/';
// Use SVGs for emojis
emojione.imageType = 'svg';
import EMOJIBASE from 'emojibase-data/en/compact.json';
import EMOJIBASE_REGEX from 'emojibase-regex';
linkifyMatrix(linkify);
// Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
// And there a bunch more symbol characters that emojione has within the
// And there a bunch more symbol characters that emojibase has within the
// BMP, so this includes the ranges from 'letterlike symbols' to
// 'miscellaneous symbols and arrows' which should catch all of them
// (with plenty of false positives, but that's OK)
const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
// And this is emojione's complete regex
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
// 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");
const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
/*
* Return true if the given string contains emoji
* Uses a much, much simpler regex than emojione's so will give false
* Uses a much, much simpler regex than emojibase's so will give false
* positives, but useful for fast-path testing strings to see if they
* need emojification.
* unicodeToImage uses this function.
@ -60,56 +67,27 @@ export function containsEmoji(str) {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
/* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js
* because we want to include emoji shortnames in title text
/**
* Returns the shortcode for an emoji character.
*
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
function unicodeToImage(str) {
let replaceWith; let unicode; let alt; let short; let fname;
const mappedUnicode = emojione.mapUnicodeToShort();
str = str.replace(emojione.regUnicode, function(unicodeChar) {
if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) {
// if the unicodeChar doesnt exist just return the entire match
return unicodeChar;
} else {
// get the unicode codepoint from the actual char
unicode = emojione.jsEscapeMap[unicodeChar];
short = mappedUnicode[unicode];
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}"/>`;
return replaceWith;
}
});
return str;
export function unicodeToShortcode(char) {
const data = EMOJIBASE.find(e => e.unicode === char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
/**
* Given one or more unicode characters (represented by unicode
* character number), return an image node with the corresponding
* emoji.
* Returns the unicode character for an emoji shortcode
*
* @param alt {string} String to use for the image alt text
* @param useSvg {boolean} Whether to use SVG image src. If False, PNG will be used.
* @param unicode {integer} One or more integers representing unicode characters
* @returns A img node with the corresponding emoji
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
export function charactersToImageNode(alt, useSvg, ...unicode) {
const fileName = unicode.map((u) => {
return u.toString(16);
}).join('-');
const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG;
const fileType = useSvg ? 'svg' : 'png';
return <img
alt={alt}
src={`${path}${fileName}.${fileType}${emojione.cacheBustParam}`}
/>;
export function shortcodeToUnicode(shortcode) {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = EMOJIBASE.find(e => e.shortcodes && e.shortcodes.includes(shortcode));
return data ? data.unicode : null;
}
export function processHtmlForSending(html: string): string {
@ -220,7 +198,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(' ');
@ -416,13 +394,10 @@ class TextHighlighter extends BaseHighlighter {
* opts.disableBigEmoji: optional argument to disable the big emoji class.
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
* opts.returnString: return an HTML string rather than JSX elements
* opts.emojiOne: optional param to do emojiOne (default true)
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
*/
export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
let bodyHasEmoji = false;
let sanitizeParams = sanitizeHtmlParams;
@ -453,28 +428,12 @@ export function bodyToHtml(content, highlights, opts={}) {
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
if (doEmojiOne) {
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
}
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
// Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) {
isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeParams);
} else {
// ... or if there are emoji, which we insert as HTML alongside the
// escaped plaintext body.
if (bodyHasEmoji) {
isDisplayedWithHtml = true;
safeBody = sanitizeHtml(escape(strippedBody), sanitizeParams);
}
}
// An HTML message with emoji
// or a plaintext message with emoji that was escaped and sanitized into
// HTML.
if (bodyHasEmoji) {
safeBody = unicodeToImage(safeBody);
}
} finally {
delete sanitizeParams.textFilter;
@ -486,25 +445,65 @@ export function bodyToHtml(content, highlights, opts={}) {
let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0;
const contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
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 = BIGEMOJI_REGEX.exec(contentBodyTrimmed);
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length &&
// Prevent user pills expanding for users with only emoji in
// their username
(
content.formatted_body == undefined ||
!content.formatted_body.includes("https://matrix.to/")
);
}
const className = classNames({
'mx_EventTile_body': true,
'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtmlMessage,
'markdown-body': isHtmlMessage && !emojiBody,
});
return isDisplayedWithHtml ?
<span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span className={className} dir="auto">{ strippedBody }</span>;
<span key="body" className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span key="body" className={className} dir="auto">{ strippedBody }</span>;
}
export function emojifyText(text) {
return {
__html: unicodeToImage(escape(text)),
};
/**
* 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

@ -31,8 +31,9 @@ import Modal from './Modal';
import sdk from './index';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import PlatformPeg from "./PlatformPeg";
import { sendLoginRequest } from "./Login";
import * as StorageManager from './utils/StorageManager';
import SettingsStore from "./settings/SettingsStore";
import {sendLoginRequest} from "./Login";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -103,13 +104,38 @@ 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;
}
/**
* @returns {bool} True if the stored session is for a guest user or false if it is
* for a real user. If there is no stored session, return null.
*/
export function getStoredSessionIsGuest() {
const sessVars = _getLocalStorageSessionVars();
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
}
/**
* @param {Object} queryParams string->string map of the
* query-parameters extracted from the real query-string of the starting
@ -187,9 +213,6 @@ export function handleInvalidStoreError(e) {
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
console.log(`Doing guest login on ${hsUrl}`);
// TODO: we should probably de-duplicate this and Login.loginAsGuest.
// Not really sure where the right home for it is.
// create a temporary MatrixClient to do the login
const client = Matrix.createClient({
baseUrl: hsUrl,
@ -215,6 +238,24 @@ 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");
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest};
}
// returns a promise which resolves to true if a session is found in
// localstorage
//
@ -229,19 +270,8 @@ 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");
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = _getLocalStorageSessionVars();
if (accessToken && userId && hsUrl) {
console.log(`Restoring session for ${userId}`);
@ -261,7 +291,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 =
@ -336,6 +366,22 @@ 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.cryptoInited && !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);
if (localStorage) {
@ -366,6 +412,19 @@ async function _doSetLoggedIn(credentials, clearStorage) {
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);
@ -440,7 +499,7 @@ async function startMatrixClient() {
dis.dispatch({action: 'will_start_client'}, true);
Notifier.start();
UserActivity.start();
UserActivity.sharedInstance().start();
if (!SettingsStore.getValue("lowBandwidth")) {
Presence.start();
}
@ -488,7 +547,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

@ -81,26 +81,6 @@ export default class Login {
return flowStep ? flowStep.type : null;
}
loginAsGuest() {
const client = this._createTemporaryClient();
return client.registerGuest({
body: {
initial_device_display_name: this._defaultDeviceDisplayName,
},
}).then((creds) => {
return {
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: this._hsUrl,
identityServerUrl: this._isUrl,
guest: true,
};
}, (error) => {
throw error;
});
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;

View file

@ -30,6 +30,8 @@ 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";
import * as StorageManager from './utils/StorageManager';
interface MatrixClientCreds {
homeserverUrl: string,
@ -102,7 +104,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 {
@ -112,14 +114,17 @@ class MatrixClientPeg {
}
}
StorageManager.trackStores(this.matrixClient);
// try to initialise e2e on the new client
try {
// check that we have a version of the js-sdk which includes initCrypto
if (!SettingsStore.getValue("lowBandwidth") && this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
StorageManager.setCryptoInitialised(true);
}
} 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");
@ -129,23 +134,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);
@ -176,7 +175,10 @@ class MatrixClientPeg {
return matches[1];
}
_createClient(creds: MatrixClientCreds, useIndexedDb) {
_createClient(creds: MatrixClientCreds) {
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing");
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
@ -184,11 +186,12 @@ class MatrixClientPeg {
userId: creds.userId,
deviceId: creds.deviceId,
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcForcePeerToPeer', false),
verificationMethods: [verificationMethods.SAS]
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
verificationMethods: [verificationMethods.SAS],
unstableClientRelationAggregation: aggregateRelations || enableEdits,
};
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
@ -74,10 +75,9 @@ const AsyncWrapper = React.createClass({
},
render: function() {
const {loader, ...otherProps} = this.props;
if (this.state.component) {
const Component = this.state.component;
return <Component {...otherProps} />;
return <Component {...this.props} />;
} else if (this.state.error) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -106,7 +106,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 +123,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,13 +139,25 @@ 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);
}
createDialog(Element, ...rest) {
return this.createDialogAsync(new Promise(resolve => resolve(Element)), ...rest);
return this.createDialogAsync(Promise.resolve(Element), ...rest);
}
createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
@ -166,29 +187,40 @@ 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.
* @returns {object} Object with 'close' parameter being a function that will close the dialog
*/
createDialogAsync(prom, props, className, isPriorityModal) {
const self = this;
createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) {
const modal = {};
// never call this from onFinished() otherwise it will loop
//
// nb explicit function() rather than arrow function, to get `arguments`
const closeDialog = function() {
if (props && props.onFinished) props.onFinished.apply(null, arguments);
const i = self._modals.indexOf(modal);
const closeDialog = (...args) => {
if (props && props.onFinished) props.onFinished.apply(null, args);
const i = this._modals.indexOf(modal);
if (i >= 0) {
self._modals.splice(i, 1);
this._modals.splice(i, 1);
}
if (self._priorityModal === modal) {
self._priorityModal = null;
if (this._priorityModal === modal) {
this._priorityModal = null;
// XXX: This is destructive
self._modals = [];
this._modals = [];
}
self._reRender();
if (this._staticModal === modal) {
this._staticModal = null;
// XXX: This is destructive
this._modals = [];
}
this._reRender();
};
// don't attempt to reuse the same AsyncWrapper for different dialogs,
@ -207,6 +239,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 +251,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 +271,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 +289,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,7 +19,6 @@ limitations under the License.
export default {
HomePage: "home_page",
RoomView: "room_view",
UserSettings: "user_settings",
RoomDirectory: "room_directory",
UserView: "user_view",
GroupView: "group_view",

View file

@ -35,8 +35,10 @@ 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 = {};
@ -73,6 +75,8 @@ 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'});
}
},
});

View file

@ -1,40 +0,0 @@
/*
Copyright 2015 - 2017 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 * as emojione from 'emojione';
export function unicodeToEmojiUri(str) {
const mappedUnicode = emojione.mapUnicodeToShort();
// remove any zero width joiners/spaces used in conjugate emojis as the emojione URIs don't contain them
return str.replace(emojione.regUnicode, function(unicodeChar) {
if ((typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap))) {
// if the unicodeChar doesn't exist just return the entire match
return unicodeChar;
} else {
// get the unicode codepoint from the actual char
const unicode = emojione.jsEscapeMap[unicodeChar];
const short = mappedUnicode[unicode];
const fname = emojione.emojioneList[short].fname;
return emojione.imagePathSVG+fname+'.svg'+emojione.cacheBustParam;
}
});
}

View file

@ -45,7 +45,7 @@ export function showStartChatInviteDialog() {
Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, {
title: _t('Start a chat'),
description: _t("Who would you like to communicate with?"),
placeholder: _t("Email, name or matrix ID"),
placeholder: _t("Email, name or Matrix ID"),
validAddressTypes: ['mx-user-id', 'email'],
button: _t("Start Chat"),
onFinished: _onStartChatFinished,
@ -58,13 +58,31 @@ export function showRoomInviteDialog(roomId) {
title: _t('Invite new room members'),
description: _t('Who would you like to add to this room?'),
button: _t('Send Invites'),
placeholder: _t("Email, name or matrix ID"),
placeholder: _t("Email, name or Matrix ID"),
onFinished: (shouldInvite, addrs) => {
_onRoomInviteFinished(roomId, shouldInvite, addrs);
},
});
}
/**
* 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,49 @@ export const ALL_MESSAGES = 'all_messages';
export const MENTIONS_ONLY = 'mentions_only';
export const MUTE = 'mute';
export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY];
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;
@ -66,6 +109,28 @@ export function setRoomNotifsState(roomId, newState) {
}
}
export function getUnreadNotificationCount(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;
}
function setRoomNotifsStateMuted(roomId) {
const cli = MatrixClientPeg.get();
const promises = [];
@ -163,4 +228,3 @@ function isRuleForRoom(roomId, rule) {
function isMuteRule(rule) {
return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify');
}

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
@ -41,6 +41,12 @@ class SdkConfig {
static unset() {
global.mxReactSdkConfig = undefined;
}
static add(cfg) {
const liveConfig = SdkConfig.get();
const newConfig = Object.assign({}, liveConfig, cfg);
SdkConfig.put(newConfig);
}
}
module.exports = SdkConfig;

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector 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.
@ -27,7 +28,11 @@ 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";
import {textToHtmlRainbow} from "./utils/colour";
import Promise from "bluebird";
class Command {
constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
@ -70,6 +75,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>',
@ -92,7 +110,72 @@ export const CommandMap = {
description: _td('Upgrades a room to a new version'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().upgradeRoom(roomId, 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());
},
@ -110,6 +193,65 @@ export const CommandMap = {
},
}),
myroomnick: new Command({
name: 'myroomnick',
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());
},
}),
myroomavatar: new Command({
name: 'myroomavatar',
args: '[<mxc_url>]',
description: _td('Changes your avatar in this current room only'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
const userId = cli.getUserId();
let promise = Promise.resolve(args);
if (!args) {
promise = new Promise((resolve) => {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('type', 'file');
fileSelector.onchange = (ev) => {
const file = ev.target.files[0];
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
onFinished: (shouldContinue) => {
if (shouldContinue) resolve(cli.uploadContent(file));
},
});
};
fileSelector.click();
});
}
return success(promise.then((url) => {
const ev = room.currentState.getStateEvents('m.room.member', userId);
const content = {
...ev ? ev.getContent() : { membership: 'join' },
avatar_url: url,
};
return cli.sendStateEvent(roomId, 'm.room.member', content, userId);
}));
},
}),
tint: new Command({
name: 'tint',
args: '<color1> [<color2>]',
@ -137,13 +279,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();
},
}),
@ -321,7 +476,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'});
}),
);
@ -363,7 +518,7 @@ export const CommandMap = {
unban: new Command({
name: 'unban',
args: '<user-id>',
description: _td('Unbans user with given id'),
description: _td('Unbans user with given ID'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
@ -391,13 +546,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,
});
}),
);
@ -423,13 +577,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,
});
}),
);
@ -498,6 +651,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',
@ -546,8 +719,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>
@ -558,7 +731,6 @@ export const CommandMap = {
}
</p>
</div>,
hasCancelButton: false,
});
}),
);
@ -590,6 +762,26 @@ export const CommandMap = {
return success();
},
}),
rainbow: new Command({
name: "rainbow",
description: _td("Sends the given message coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlMessage(roomId, args, textToHtmlRainbow(args)));
},
}),
rainbowme: new Command({
name: "rainbowme",
description: _td("Sends the given emote coloured as a rainbow"),
args: '<message>',
runFn: function(roomId, args) {
if (!args) return reject(this.getUserId());
return success(MatrixClientPeg.get().sendHtmlEmote(roomId, args, textToHtmlRainbow(args)));
},
}),
};
/* eslint-enable babel/no-invalid-this */
@ -599,6 +791,7 @@ const aliases = {
j: "join",
newballsplease: "discardsession",
goto: "join", // because it handles event permalinks magically
roomnick: "myroomnick",
};

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;
@ -265,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">
@ -291,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>;
},
@ -353,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">
@ -392,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">
@ -455,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}
/>
@ -484,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

@ -19,47 +19,31 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import QueryMatcher from './QueryMatcher';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {Completion, SelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EmojiData from '../stripped-emoji.json';
const LIMIT = 20;
const CATEGORY_ORDER = [
'people',
'food',
'objects',
'activity',
'nature',
'travel',
'flags',
'regional',
'symbols',
'modifier',
];
// Match for ":wink:" or ascii-style ";-)" provided by emojione
// (^|\s|(emojiUnicode)) to make sure we're either at the start of the string or there's a
// whitespace character or an emoji before the emoji. The reason for unicodeRegexp is
// that we need to support inputting multiple emoji with no space between them.
const EMOJI_REGEX = new RegExp('(?:^|\\s|' + unicodeRegexp + ')(' + asciiRegexp + '|:[+-\\w]*:?)$', 'g');
// We also need to match the non-zero-length prefixes to remove them from the final match,
// and update the range so that we don't replace the whitespace or the previous emoji.
const MATCH_PREFIX_REGEX = new RegExp('(\\s|' + unicodeRegexp + ')');
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', 'g');
// XXX: it's very unclear why we bother with this generated emojidata file.
// all it means is that we end up bloating the bundle with precomputed stuff
// which would be trivial to calculate and cache on demand.
const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort(
(a, b) => {
if (a.category === b.category) {
return a.emoji_order - b.emoji_order;
}
return CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
return a.category - b.category;
},
).map((a, index) => {
return {
@ -101,26 +85,20 @@ export default class EmojiProvider extends AutocompleteProvider {
return []; // don't give any suggestions if the user doesn't want them
}
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
let matchedString = command[0];
// Remove prefix of any length (single whitespace or unicode emoji)
const prefixMatch = MATCH_PREFIX_REGEX.exec(matchedString);
if (prefixMatch) {
matchedString = matchedString.slice(prefixMatch[0].length);
range.start += prefixMatch[0].length;
}
const matchedString = command[0];
completions = this.matcher.match(matchedString);
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
const sorters = [];
// First, sort by score (Infinity if matchedString not in shortname)
// make sure that emoticons come first
sorters.push((c) => score(matchedString, c.aliases_ascii));
// then sort by score (Infinity if matchedString not in shortname)
sorters.push((c) => score(matchedString, c.shortname));
// If the matchedString is not empty, sort by length of shortname. Example:
// matchedString = ":bookmark"
@ -133,12 +111,12 @@ export default class EmojiProvider extends AutocompleteProvider {
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname);
const { shortname } = result;
const unicode = shortcodeToUnicode(shortname);
return {
completion: unicode,
component: (
<PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{ unicode }</EmojiText>} />
<PillCompletion title={shortname} initialComponent={<span style={{maxWidth: '1em'}}>{ unicode }</span>} />
),
range,
};

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

@ -114,10 +114,17 @@ export default class AutoHideScrollbar extends React.Component {
}
}
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}
onWheel={this.props.onWheel}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }

View file

@ -1,197 +0,0 @@
/*
Copyright 2015, 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.
*/
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 SettingsStore from '../../settings/SettingsStore';
const CALLOUT_ANIM_DURATION = 1000;
module.exports = React.createClass({
displayName: 'BottomLeftMenu',
propTypes: {
collapsed: React.PropTypes.bool.isRequired,
},
getInitialState: function() {
return ({
directoryHover: false,
roomsHover: false,
homeHover: false,
peopleHover: false,
settingsHover: false,
});
},
componentWillMount: function() {
this._dispatcherRef = dis.register(this.onAction);
this._peopleButton = null;
this._directoryButton = null;
this._createRoomButton = null;
this._lastCallouts = {};
},
componentWillUnmount: function() {
dis.unregister(this._dispatcherRef);
},
// Room events
onDirectoryClick: function() {
dis.dispatch({ action: 'view_room_directory' });
},
onDirectoryMouseEnter: function() {
this.setState({ directoryHover: true });
},
onDirectoryMouseLeave: function() {
this.setState({ directoryHover: false });
},
onRoomsClick: function() {
dis.dispatch({ action: 'view_create_room' });
},
onRoomsMouseEnter: function() {
this.setState({ roomsHover: true });
},
onRoomsMouseLeave: function() {
this.setState({ roomsHover: false });
},
// Home button events
onHomeClick: function() {
dis.dispatch({ action: 'view_home_page' });
},
onHomeMouseEnter: function() {
this.setState({ homeHover: true });
},
onHomeMouseLeave: function() {
this.setState({ homeHover: false });
},
// People events
onPeopleClick: function() {
dis.dispatch({ action: 'view_create_chat' });
},
onPeopleMouseEnter: function() {
this.setState({ peopleHover: true });
},
onPeopleMouseLeave: function() {
this.setState({ peopleHover: false });
},
// Settings events
onSettingsClick: function() {
dis.dispatch({ action: 'view_user_settings' });
},
onSettingsMouseEnter: function() {
this.setState({ settingsHover: true });
},
onSettingsMouseLeave: function() {
this.setState({ settingsHover: false });
},
onAction: function(payload) {
let calloutElement;
switch (payload.action) {
// Incoming instruction: dance!
case 'callout_start_chat':
calloutElement = this._peopleButton;
break;
case 'callout_room_directory':
calloutElement = this._directoryButton;
break;
case 'callout_create_room':
calloutElement = this._createRoomButton;
break;
}
if (calloutElement) {
const lastCallout = this._lastCallouts[payload.action];
const now = Date.now();
if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) {
this._lastCallouts[payload.action] = now;
Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION);
}
}
},
// 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} />;
}
},
_collectPeopleButton: function(e) {
this._peopleButton = e;
},
_collectDirectoryButton: function(e) {
this._directoryButton = e;
},
_collectCreateRoomButton: function(e) {
this._createRoomButton = e;
},
render: function() {
const HomeButton = sdk.getComponent('elements.HomeButton');
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
const SettingsButton = sdk.getComponent('elements.SettingsButton');
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const groupsButton = !SettingsStore.getValue("TagPanel.enableTagPanel") ?
<GroupsButton tooltip={true} /> : null;
return (
<div className="mx_BottomLeftMenu">
<div className="mx_BottomLeftMenu_options">
<HomeButton tooltip={true} />
<div ref={this._collectPeopleButton}>
<StartChatButton tooltip={true} />
</div>
<div ref={this._collectDirectoryButton}>
<RoomDirectoryButton tooltip={true} />
</div>
<div ref={this._collectCreateRoomButton}>
<CreateRoomButton tooltip={true} />
</div>
{ groupsButton }
<span className="mx_BottomLeftMenu_settings">
<SettingsButton tooltip={true} />
</span>
</div>
</div>
);
},
});

View file

@ -48,19 +48,13 @@ module.exports = React.createClass({
</p>
<p>
{ _t(
'Please install <chromeLink>Chrome</chromeLink> or <firefoxLink>Firefox</firefoxLink> ' +
'for the best experience.',
'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>

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

@ -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.
@ -26,22 +27,27 @@ import sdk from '../../index';
import { MatrixClient } from 'matrix-js-sdk';
import classnames from 'classnames';
class HomePage extends React.Component {
static displayName = 'HomePage';
export default class EmbeddedPage extends React.PureComponent {
static propTypes = {
// URL to use as the iFrame src. Defaults to /home.html.
homePageUrl: PropTypes.string,
// 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),
};
state = {
iframeSrc: '',
constructor(props) {
super(props);
this.state = {
page: '',
};
};
}
translate(s) {
// default implementation - skins may wish to extend this
@ -51,22 +57,24 @@ class HomePage extends React.Component {
componentWillMount() {
this._unmounted = false;
// we use request() to inline the homepage into the react component
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.
const src = this.props.homePageUrl || 'home.html';
request(
{ method: "GET", url: src },
{ 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 home page: ${err}`);
this.setState({ page: _t("Couldn't load home page") });
console.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
@ -81,28 +89,28 @@ class HomePage extends React.Component {
}
render() {
const isGuest = this.context.matrixClient.isGuest();
const client = this.context.matrixClient;
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
mx_HomePage: true,
mx_HomePage_guest: isGuest,
[className]: true,
[`${className}_guest`]: isGuest,
});
if (this.state.iframeSrc) {
return (
<div className={classes}>
<iframe src={ this.state.iframeSrc } />
</div>
);
} else {
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}>
<div className="mx_HomePage_body" dangerouslySetInnerHTML={{ __html: this.state.page }}>
</div>
</GeminiScrollbarWrapper>
);
return <GeminiScrollbarWrapper autoshow={true} className={classes}>
{content}
</GeminiScrollbarWrapper>;
} else {
return <div className={classes}>
{content}
</div>;
}
}
}
module.exports = HomePage;

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

@ -0,0 +1,38 @@
/*
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";
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
message: PropTypes.string.isRequired,
};
render() {
return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'>
<h1>{_t("Error loading Riot")}</h1>
<p>{this.props.message}</p>
<p>{_t(
"If this is unexpected, please contact your system administrator " +
"or technical support representative.",
)}</p>
</div>
</div>;
}
}

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>
@ -263,7 +265,7 @@ const RoleUserList = React.createClass({
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
title: _t('Add users to the community summary'),
description: _t("Who would you like to add to this summary?"),
placeholder: _t("Name or matrix ID"),
placeholder: _t("Name or Matrix ID"),
button: _t("Add to summary"),
validAddressTypes: ['mx-user-id'],
groupId: this.props.groupId,
@ -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() }
@ -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) {
@ -1248,13 +1267,17 @@ 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">
<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>,
@ -1262,16 +1285,20 @@ export default React.createClass({
} 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={require("../../../res/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={require("../../../res/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>,
);
}

View file

@ -15,9 +15,22 @@ 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,
// If true, when the user tries to use their mouse wheel in the component it will
// scroll horizontally rather than vertically. This should only be used on components
// with no vertical scroll opportunity.
verticalScrollsHorizontally: PropTypes.bool,
};
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
@ -25,6 +38,18 @@ 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,
};
}
moveToOrigin() {
if (!this._scrollElement) return;
this._scrollElement.scrollLeft = 0;
this._scrollElement.scrollTop = 0;
}
_collectScroller(scroller) {
@ -43,6 +68,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 +82,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() {
@ -65,13 +118,41 @@ export default class IndicatorScrollbar extends React.Component {
}
}
onMouseWheel = (e) => {
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
// xyThreshold is the amount of horizontal motion required for the component to
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
// strange ways. Should be positive.
const xyThreshold = 0;
// yRetention is the factor multiplied by the vertical delta to try and reduce
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
const yRetention = 1.0;
if (Math.abs(e.deltaX) <= xyThreshold) {
// noinspection JSSuspiciousNameCombination
this._scrollElement.scrollLeft += e.deltaY * yRetention;
}
}
};
render() {
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}
onWheel={this.onMouseWheel}
{... this.props}
>
{ leftOverflowIndicator }
{ this.props.children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -60,7 +61,7 @@ export default React.createClass({
inputs: PropTypes.object,
// As js-sdk interactive-auth
makeRegistrationUrl: PropTypes.func,
requestEmailToken: PropTypes.func,
sessionId: PropTypes.string,
clientSecret: PropTypes.string,
emailSid: PropTypes.string,
@ -96,6 +97,7 @@ export default React.createClass({
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.props.requestEmailToken,
});
this._authLogic.attemptAuth().then((result) => {
@ -202,7 +204,6 @@ export default React.createClass({
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
makeRegistrationUrl={this.props.makeRegistrationUrl}
showContinue={!this.props.continueIsManaged}
/>
);

View file

@ -24,8 +24,10 @@ 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";
import Analytics from "../../Analytics";
const LeftPanel = React.createClass({
@ -44,11 +46,23 @@ const LeftPanel = React.createClass({
getInitialState: function() {
return {
searchFilter: '',
breadcrumbs: false,
};
},
componentWillMount: function() {
this.focusedElement = null;
this._settingWatchRef = SettingsStore.watchSetting(
"feature_room_breadcrumbs", null, this._onBreadcrumbsChanged);
const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs");
Analytics.setBreadcrumbs(useBreadcrumbs);
this.setState({breadcrumbs: useBreadcrumbs});
},
componentWillUnmount: function() {
SettingsStore.unwatchSetting(this._settingWatchRef);
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -72,6 +86,22 @@ const LeftPanel = React.createClass({
return false;
},
componentDidUpdate(prevProps, prevState) {
if (prevState.breadcrumbs !== this.state.breadcrumbs) {
Analytics.setBreadcrumbs(this.state.breadcrumbs);
}
},
_onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) {
// Features are only possible at a single level, so we can get away with using valueAtLevel.
// The SettingsStore runs on the same tick as the update, so `value` will be wrong.
this.setState({breadcrumbs: valueAtLevel});
// For some reason the setState doesn't trigger a render of the component, so force one.
// Probably has to do with the change happening outside of a change detector cycle.
this.forceUpdate();
},
_onFocus: function(ev) {
this.focusedElement = ev.target;
},
@ -182,13 +212,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.enableTagPanel");
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
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,27 +241,35 @@ 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 (this.state.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}
toolbarShown={this.props.toolbarShown}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
</aside>
</div>
);
// <BottomLeftMenu collapsed={this.props.collapsed}/>
},
});

View file

@ -22,15 +22,16 @@ 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 { fixupColorFonts } from '../../utils/FontManager';
import sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import { getHomePageUrl } from '../../utils/pages';
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
@ -57,7 +58,6 @@ 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)
@ -119,6 +119,20 @@ const LoggedInView = React.createClass({
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
fixupColorFonts();
},
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() {
@ -173,6 +187,7 @@ const LoggedInView = React.createClass({
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.notifyLeftHandleResized();
},
};
const resizer = new Resizer(
@ -310,6 +325,18 @@ const LoggedInView = React.createClass({
handled = true;
}
break;
case KeyCode.KEY_I:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// will have to do.
if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
handled = true;
}
break;
}
if (handled) {
@ -421,8 +448,8 @@ 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 HomePage = sdk.getComponent('structures.HomePage');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
@ -448,16 +475,10 @@ const LoggedInView = React.createClass({
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier}
/>;
break;
case PageTypes.UserSettings:
pageElement = <UserSettings
onClose={this.props.onCloseAllSettings}
brand={this.props.config.brand}
/>;
break;
case PageTypes.MyGroups:
pageElement = <MyGroups />;
break;
@ -468,16 +489,16 @@ const LoggedInView = React.createClass({
case PageTypes.HomePage:
{
pageElement = <HomePage
homePageUrl={this.props.config.welcomePageUrl}
const pageUrl = getHomePageUrl(this.props.config);
pageElement = <EmbeddedPage className="mx_HomePage"
url={pageUrl}
scrollbar={true}
/>;
}
break;
case PageTypes.UserView:
pageElement = 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:
pageElement = <GroupView
@ -496,7 +517,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}
@ -520,10 +540,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 />;
}
@ -541,7 +558,7 @@ const LoggedInView = React.createClass({
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
toolbarShown={!!topBar}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>

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() {

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,8 +49,8 @@ import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
const AutoDiscovery = Matrix.AutoDiscovery;
import ResizeNotifier from "../../utils/ResizeNotifier";
import {ValidatedServerConfig} from "../../utils/AutoDiscoveryUtils";
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
@ -60,27 +62,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 we need to clear out indexeddb. While it is going on we
// show a big spinner.
LOGGING_IN: 5,
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
@ -103,6 +108,7 @@ export default React.createClass({
propTypes: {
config: PropTypes.object,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig),
ConferenceHandler: PropTypes.any,
onNewScreen: PropTypes.func,
registrationUrl: PropTypes.string,
@ -136,10 +142,6 @@ export default React.createClass({
appConfig: PropTypes.object,
},
AuxPanel: {
RoomSettings: "room_settings",
},
getChildContext: function() {
return {
appConfig: this.props.config,
@ -179,21 +181,15 @@ export default React.createClass({
// Parameters used in the registration dance with the IS
register_client_secret: null,
register_session_id: null,
register_hs_url: null,
register_is_url: null,
register_id_sid: null,
// 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,
defaultServerDiscoveryError: null,
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(),
showNotifierToolbar: false,
};
return s;
},
@ -207,42 +203,19 @@ export default React.createClass({
};
},
getDefaultServerName: function() {
return this.state.defaultServerName;
},
getCurrentHsUrl: function() {
if (this.state.register_hs_url) {
return this.state.register_hs_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getHomeserverUrl();
} else {
return this.getDefaultHsUrl();
}
},
getDefaultHsUrl(defaultToMatrixDotOrg) {
defaultToMatrixDotOrg = typeof(defaultToMatrixDotOrg) !== 'boolean' ? true : defaultToMatrixDotOrg;
if (!this.state.defaultHsUrl && defaultToMatrixDotOrg) return "https://matrix.org";
return this.state.defaultHsUrl;
},
getFallbackHsUrl: function() {
return this.props.config.fallback_hs_url;
},
getCurrentIsUrl: function() {
if (this.state.register_is_url) {
return this.state.register_is_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getIdentityServerUrl();
if (this.props.serverConfig && this.props.serverConfig.isDefault) {
return this.props.config.fallback_hs_url;
} else {
return this.getDefaultIsUrl();
return null;
}
},
getDefaultIsUrl() {
return this.state.defaultIsUrl || "https://vector.im";
getServerProperties() {
let props = this.state.serverConfig;
if (!props) props = this.props.serverConfig; // for unit tests
if (!props) props = SdkConfig.get()["validated_server_config"];
return {serverConfig: props};
},
componentWillMount: function() {
@ -256,40 +229,6 @@ export default React.createClass({
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
}
// Set up the default URLs (async)
if (this.getDefaultServerName() && !this.getDefaultHsUrl(false)) {
this.setState({loadingDefaultHomeserver: true});
this._tryDiscoverDefaultHomeserver(this.getDefaultServerName());
} else if (this.getDefaultServerName() && this.getDefaultHsUrl(false)) {
// Ideally we would somehow only communicate this to the server admins, but
// given this is at login time we can't really do much besides hope that people
// 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",
),
});
}
// Set a default HS with query param `hs_url`
const paramHs = this.props.startingFragmentQueryParams.hs_url;
if (paramHs) {
console.log('Setting register_hs_url ', paramHs);
this.setState({
register_hs_url: paramHs,
});
}
// Set a default IS with query param `is_url`
const paramIs = this.props.startingFragmentQueryParams.is_url;
if (paramIs) {
console.log('Setting register_is_url ', paramIs);
this.setState({
register_is_url: paramIs,
});
}
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
// rerender.
@ -305,6 +244,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() {
@ -346,25 +288,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")) {
@ -378,11 +302,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.getServerProperties().serverConfig.hsUrl,
guestIsUrl: this.getServerProperties().serverConfig.isUrl,
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) {
@ -541,19 +488,8 @@ export default React.createClass({
},
});
break;
case 'view_user':
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
if (this.state.collapsedRhs) {
setTimeout(()=>{
dis.dispatch({
action: 'show_right_panel',
});
dis.dispatch({
action: 'view_user',
member: payload.member,
});
}, 0);
}
case 'view_user_info':
this._viewUser(payload.userId, payload.subAction);
break;
case 'view_room':
// Takes either a room ID or room alias: if switching to a room the client is already
@ -572,40 +508,14 @@ export default React.createClass({
this._viewIndexedRoom(payload.roomIndex);
break;
case 'view_user_settings': {
if (true || SettingsStore.isFeatureEnabled("feature_tabbed_settings")) {
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog');
} else {
this._setPage(PageTypes.UserSettings);
this.notifyNewScreen('settings');
}
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
break;
}
case 'view_old_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();
}
}
break;
case 'view_create_room':
this._createRoom();
break;
@ -620,11 +530,8 @@ export default React.createClass({
config: this.props.config,
}, 'mx_RoomDirectory_dialogWrapper');
// View the home page if we need something to look at
if (!this.state.currentGroupId && !this.state.currentRoomId) {
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
}
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
}
break;
case 'view_my_groups':
@ -634,6 +541,9 @@ export default React.createClass({
case 'view_group':
this._viewGroup(payload);
break;
case 'view_welcome_page':
this._viewWelcome();
break;
case 'view_home_page':
this._viewHome();
break;
@ -649,8 +559,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({
@ -682,11 +593,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
@ -847,6 +756,7 @@ export default React.createClass({
this.focusComposer = true;
const newState = {
view: VIEWS.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView,
thirdPartyInvite: roomInfo.third_party_invite,
@ -909,6 +819,23 @@ export default React.createClass({
this.notifyNewScreen('group/' + groupId);
},
_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() {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
@ -918,6 +845,22 @@ export default React.createClass({
this.notifyNewScreen('home');
},
_viewUser: function(userId, subAction) {
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitForSync = this.firstSyncPromise ?
this.firstSyncPromise.promise : Promise.resolve();
waitForSync.then(() => {
if (subAction === 'chat') {
this._chatCreateOrReuse(userId);
return;
}
this.notifyNewScreen('user/' + userId);
this.setState({currentUserId: userId});
this._setPage(PageTypes.UserView);
});
},
_setMxId: function(payload) {
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
@ -982,11 +925,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;
}
@ -1053,35 +996,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'});
dis.dispatch({action: 'close_room_settings'});
}
}, (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"),
});
});
}
@ -1169,7 +1125,7 @@ export default React.createClass({
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
this.setStateForNewView({view: VIEWS.LOGGED_IN});
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
@ -1209,7 +1165,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'});
});
}
}
},
@ -1294,7 +1258,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
@ -1495,6 +1462,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',
@ -1519,7 +1490,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 = {
@ -1560,31 +1540,13 @@ 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);
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitFor = this.firstSyncPromise ?
this.firstSyncPromise.promise : Promise.resolve();
waitFor.then(() => {
if (params.action === 'chat') {
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,
});
dis.dispatch({
action: 'view_user_info',
userId: userId,
subAction: params.action,
});
} else if (screen.indexOf('group/') == 0) {
const groupId = screen.substring(6);
@ -1654,9 +1616,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",
@ -1676,13 +1643,47 @@ export default React.createClass({
this.showScreen("forgot_password");
},
onReturnToAppClick: function() {
// treat it the same as if the user had completed the login
this._onLoggedIn();
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) {
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, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
if (sessionOwner && !sessionIsGuest && 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 MatrixClientPeg.get();
}
}
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
@ -1722,7 +1723,7 @@ export default React.createClass({
},
_setPageSubtitle: function(subtitle='') {
document.title = `Riot ${subtitle}`;
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
},
updateStatusIndicator: function(state, prevState) {
@ -1761,44 +1762,7 @@ export default React.createClass({
},
onServerConfigChange(config) {
const newState = {};
if (config.hsUrl) {
newState.register_hs_url = config.hsUrl;
}
if (config.isUrl) {
newState.register_is_url = config.isUrl;
}
this.setState(newState);
},
_tryDiscoverDefaultHomeserver: async function(serverName) {
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS) {
console.error("Failed to discover homeserver on startup:", discovery);
this.setState({
defaultServerDiscoveryError: discovery["m.homeserver"].error,
loadingDefaultHomeserver: false,
});
} else {
const hsUrl = discovery["m.homeserver"].base_url;
const isUrl = discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "https://vector.im";
this.setState({
defaultHsUrl: hsUrl,
defaultIsUrl: isUrl,
loadingDefaultHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
defaultServerDiscoveryError: _t("Unknown error discovering homeserver"),
loadingDefaultHomeserver: false,
});
}
this.setState({serverConfig: config});
},
_makeRegistrationUrl: function(params) {
@ -1817,8 +1781,7 @@ export default React.createClass({
if (
this.state.view === VIEWS.LOADING ||
this.state.view === VIEWS.LOGGING_IN ||
this.state.loadingDefaultHomeserver
this.state.view === VIEWS.LOGGING_IN
) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
@ -1883,6 +1846,11 @@ 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.auth.Registration');
return (
@ -1891,21 +1859,13 @@ export default React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand}
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}
/>
{...this.getServerProperties()}
/>
);
}
@ -1914,14 +1874,11 @@ export default React.createClass({
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
return (
<ForgotPassword
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick}
onLoginClick={this.onLoginClick} />
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()}
/>
);
}
@ -1931,17 +1888,11 @@ export default React.createClass({
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
fallbackHsUrl={this.getFallbackHsUrl()}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()}
/>
);
}

View file

@ -21,10 +21,10 @@ 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';
import SettingsStore from '../../settings/SettingsStore';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -93,6 +93,12 @@ module.exports = React.createClass({
// show timestamps always
alwaysShowTimestamps: PropTypes.bool,
// helper function to access relations for an event
getRelationsForEvent: PropTypes.func,
// whether to show reactions for an event
showReactions: PropTypes.bool,
},
componentWillMount: function() {
@ -228,6 +234,13 @@ module.exports = React.createClass({
}
},
scrollToEventIfNeeded: function(eventId) {
const node = this.eventNodes[eventId];
if (node) {
node.scrollIntoView({block: "nearest", behavior: "instant"});
}
},
/* check the scroll state and send out pagination requests if necessary.
*/
checkFillState: function() {
@ -246,6 +259,10 @@ module.exports = React.createClass({
return false; // ignored = no show (only happens if the ignore happens after an event was received)
}
if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
return true;
}
const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
@ -387,7 +404,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 }
@ -451,6 +468,7 @@ module.exports = React.createClass({
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId();
// is this a continuation of the previous message?
let continuation = false;
@ -512,21 +530,30 @@ module.exports = React.createClass({
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
ret.push(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
last={last} isSelectedEvent={highlight} />
</li>,
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
isEditing={isEditing}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.replacementOrOwnStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
/>
</li>,
);
return ret;
@ -624,38 +651,57 @@ module.exports = React.createClass({
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
_onWidgetLoad: function() {
_onHeightChanged: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.forceUpdate();
scrollPanel.checkScroll();
}
},
_onTypingVisible: function() {
_onTypingShown: function() {
const scrollPanel = this.refs.scrollPanel;
// this will make the timeline grow, so checkScroll
scrollPanel.checkScroll();
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.blockShrinking();
// scroll down if at bottom
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;
const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
if (scrollPanel) {
if (isTypingVisible) {
scrollPanel.blockShrinking();
} else {
scrollPanel.clearBlockShrinking();
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();
}
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
onTimelineReset: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearPreventShrinking();
}
},
render: function() {
@ -681,8 +727,13 @@ module.exports = React.createClass({
);
let whoIsTyping;
if (this.props.room) {
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._onTypingVisible} ref="whoIsTyping" />);
if (this.props.room && !this.props.tileShape) {
whoIsTyping = (<WhoIsTypingTile
room={this.props.room}
onShown={this._onTypingShown}
onHidden={this._onTypingHidden}
ref="whoIsTyping" />
);
}
return (
@ -692,7 +743,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

@ -1,5 +1,6 @@
/*
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.
@ -15,12 +16,9 @@ limitations under the License.
*/
const React = require('react');
const ReactDOM = require("react-dom");
import { _t } from '../../languageHandler';
const Matrix = require("matrix-js-sdk");
const sdk = require('../../index');
const MatrixClientPeg = require("../../MatrixClientPeg");
const dis = require("../../dispatcher");
/*
* Component which shows the global notification list using a TimelinePanel
@ -44,7 +42,7 @@ const NotificationPanel = React.createClass({
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview = {false}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>

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');
@ -166,6 +183,8 @@ export default class RightPanel extends React.Component {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
} else if (this.state.phase === RightPanel.Phase.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,20 +24,18 @@ 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 { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
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',
@ -58,6 +56,7 @@ module.exports = React.createClass({
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: null,
includeAll: false,
roomServer: null,
@ -83,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});
@ -95,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 homeserver', '', ErrorDialog, {
title: _t('Failed to get protocol list from homeserver'),
description: _t('The homeserver 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.',
),
});
});
@ -187,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.')}`
,
});
});
},
@ -438,7 +446,7 @@ module.exports = React.createClass({
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyString(sanitizeHtml(topic));
topic = linkifyAndSanitizeHtml(topic);
rows.push(
<tr key={ rooms[i].room_id }
@ -511,25 +519,15 @@ module.exports = React.createClass({
},
render: function() {
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
// TODO: clean this up
if (this.state.protocolsLoading) {
return (
<div className="mx_RoomDirectory">
<Loader />
</div>
);
}
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
@ -551,39 +549,53 @@ 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('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;
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 createRoomButton = (<AccessibleButton
@ -591,8 +603,6 @@ module.exports = React.createClass({
className="mx_RoomDirectory_createRoom"
>{_t("Create new room")}</AccessibleButton>);
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
return (
<BaseDialog
className={'mx_RoomDirectory_dialog'}
@ -603,14 +613,7 @@ module.exports = React.createClass({
>
<div className="mx_RoomDirectory">
<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} />
</div>
{listHeader}
{content}
</div>
</div>

View file

@ -290,7 +290,7 @@ module.exports = React.createClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-icons/e2e/warning.svg")} width="24" height="24" 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 }
@ -304,12 +304,10 @@ module.exports = React.createClass({
// return suitable content for the main (text) part of the status bar.
_getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-icons/e2e/warning.svg")} width="24" height="24" 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.') }

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.
@ -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 {_t} from "../../languageHandler";
// turn this on for drop & drag console debugging galore
const debug = false;
@ -41,6 +43,7 @@ const RoomSubList = React.createClass({
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
addRoomLabel: PropTypes.string,
order: PropTypes.string.isRequired,
@ -60,6 +63,9 @@ const RoomSubList = React.createClass({
getInitialState: function() {
return {
hidden: this.props.startAsHidden || false,
// some values to get LazyRenderList starting
scrollerHeight: 800,
scrollTop: 0,
};
},
@ -127,46 +133,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 +140,33 @@ 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}
/>;
});
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 || RoomNotifs.getUnreadNotificationCount(room, 'highlight') > 0}
notificationCount={RoomNotifs.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,
});
}
},
@ -240,9 +194,11 @@ const RoomSubList = React.createClass({
_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) {
@ -278,7 +234,11 @@ const RoomSubList = React.createClass({
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
<AccessibleButton
onClick={ this.props.onAddRoom }
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
@ -317,6 +277,21 @@ const RoomSubList = React.createClass({
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() {
@ -333,12 +308,24 @@ const RoomSubList = React.createClass({
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
</div>;
} else {
const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles);
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
<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 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>;

View file

@ -19,28 +19,28 @@ limitations under the License.
// TODO: This component is enormous! There's several things which could stand-alone:
// - Search results component
// - Drag and drop
// - File uploading - uploadFile()
import shouldHideEvent from "../../shouldHideEvent";
import shouldHideEvent from '../../shouldHideEvent';
const React = require("react");
const ReactDOM = require("react-dom");
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import filesize from 'filesize';
const classNames = require("classnames");
import classNames from 'classnames';
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../matrix-to';
const MatrixClientPeg = require("../../MatrixClientPeg");
const ContentMessages = require("../../ContentMessages");
const Modal = require("../../Modal");
const sdk = require('../../index');
const CallHandler = require('../../CallHandler');
const dis = require("../../dispatcher");
const Tinter = require("../../Tinter");
const rate_limited_func = require('../../ratelimitedfunc');
const ObjectUtils = require('../../ObjectUtils');
const Rooms = require('../../Rooms');
import MatrixClientPeg from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import sdk from '../../index';
import CallHandler from '../../CallHandler';
import dis from '../../dispatcher';
import Tinter from '../../Tinter';
import rate_limited_func from '../../ratelimitedfunc';
import ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
@ -51,6 +51,7 @@ import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
import AccessibleButton from "../views/elements/AccessibleButton";
const DEBUG = false;
let debuglog = function() {};
@ -119,8 +120,6 @@ module.exports = React.createClass({
isInitialEventHighlighted: null,
forwardingEvent: null,
editingRoomSettings: false,
uploadingRoomSettings: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
@ -145,6 +144,7 @@ module.exports = React.createClass({
// the end of the live timeline. It has the effect of hiding the
// 'scroll to bottom' knob, among a couple of other things.
atEndOfLiveTimeline: true,
atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks
showTopUnreadMessagesBar: false,
@ -169,7 +169,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this._fetchMediaConfig();
// Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true);
@ -177,27 +176,6 @@ module.exports = React.createClass({
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
},
_fetchMediaConfig: function(invalidateCache: boolean = false) {
/// NOTE: Using global here so we don't make repeated requests for the
/// config every time we swap room.
if(global.mediaConfig !== undefined && !invalidateCache) {
this.setState({mediaConfig: global.mediaConfig});
return;
}
console.log("[Media Config] Fetching");
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) => {
global.mediaConfig = config;
this.setState({mediaConfig: config});
});
},
_onRoomViewStoreUpdate: function(initial) {
if (this.unmounted) {
return;
@ -229,11 +207,8 @@ module.exports = React.createClass({
forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()),
editingRoomSettings: RoomViewStore.isEditingSettings(),
};
if (this.state.editingRoomSettings && !newState.editingRoomSettings) dis.dispatch({action: 'focus_composer'});
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
console.log(
'RVS update:',
@ -288,6 +263,37 @@ module.exports = React.createClass({
}
},
_getRoomId() {
// According to `_onRoomViewStoreUpdate`, `state.roomId` can be null
// if we have a room alias we haven't resolved yet. To work around this,
// first we'll try the room object if it's there, and then fallback to
// the bare room ID. (We may want to update `state.roomId` after
// resolving aliases, so we could always trust it.)
return this.state.room ? this.state.room.roomId : this.state.roomId;
},
_getPermalinkCreatorForRoom: function(room) {
if (!this._permalinkCreators) this._permalinkCreators = {};
if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId];
this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room);
if (this.state.room && room.roomId === this.state.room.roomId) {
// We want to watch for changes in the creator for the primary room in the view, but
// don't need to do so for search results.
this._permalinkCreators[room.roomId].start();
} else {
this._permalinkCreators[room.roomId].load();
}
return this._permalinkCreators[room.roomId];
},
_stopAllPermalinkCreators: function() {
if (!this._permalinkCreators) return;
for (const roomId of Object.keys(this._permalinkCreators)) {
this._permalinkCreators[roomId].stop();
}
},
_onWidgetEchoStoreUpdate: function() {
this.setState({
showApps: this._shouldShowApps(this.state.room),
@ -387,7 +393,9 @@ module.exports = React.createClass({
this._updateConfCallNotification();
window.addEventListener('beforeunload', this.onPageUnload);
window.addEventListener('resize', this.onResize);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize();
document.addEventListener("keydown", this.onKeyDown);
@ -423,6 +431,18 @@ module.exports = React.createClass({
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
}
}
// Note: We check the ref here with a flag because componentDidMount, despite
// documentation, does not define our messagePanel ref. It looks like our spinner
// in render() prevents the ref from being set on first mount, so we try and
// catch the messagePanel when it does mount. Because we only want the ref once,
// we use a boolean flag to avoid duplicate work.
if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) {
this.setState({
atEndOfLiveTimelineInit: true,
atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(),
});
}
},
componentWillUnmount: function() {
@ -437,6 +457,9 @@ module.exports = React.createClass({
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
}
// stop tracking room changes to format permalinks
this._stopAllPermalinkCreators();
if (this.refs.roomView) {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
@ -462,7 +485,9 @@ module.exports = React.createClass({
}
window.removeEventListener('beforeunload', this.onPageUnload);
window.removeEventListener('resize', this.onResize);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onKeyDown);
@ -482,7 +507,7 @@ module.exports = React.createClass({
},
onPageUnload(event) {
if (ContentMessages.getCurrentUploads().length > 0) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
return event.returnValue =
_t("You seem to be uploading files, are you sure you want to quit?");
} else if (this._getCallForRoom() && this.state.callState !== 'ended') {
@ -531,16 +556,14 @@ module.exports = React.createClass({
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
this.uploadFile(payload.file);
return ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
);
break;
case 'notifier_enabled':
case 'upload_failed':
// 413: File was too big or upset the server in some way.
if(payload.error.http_status === 413) {
this._fetchMediaConfig(true);
}
case 'upload_started':
case 'upload_finished':
case 'upload_canceled':
this.forceUpdate();
break;
case 'call_state':
@ -723,8 +746,19 @@ module.exports = React.createClass({
if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) {
return;
}
if (!MatrixClientPeg.get().isCryptoEnabled()) {
// If crypto is not currently enabled, we aren't tracking devices at all,
// so we don't know what the answer is. Let's error on the safe side and show
// a warning for this case.
this.setState({
e2eStatus: "warning",
});
return;
}
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({e2eStatus: hasUnverifiedDevices ? "warning" : "verified"});
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
});
});
},
@ -771,7 +805,7 @@ module.exports = React.createClass({
return;
}
this._updateRoomMembers();
this._updateRoomMembers(member);
},
onMyMembership: function(room, membership, oldMembership) {
@ -783,15 +817,24 @@ module.exports = React.createClass({
// rate limited because a power level change will emit an event for every
// member in the room.
_updateRoomMembers: new rate_limited_func(function() {
_updateRoomMembers: new rate_limited_func(function(dueToMember) {
// a member state changed in this room
// refresh the conf call notification state
this._updateConfCallNotification();
this._updateDMState();
this._checkIfAlone(this.state.room);
let memberCountInfluence = 0;
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
// A member got invited, but the room hasn't detected that change yet. Influence the member
// count by 1 to counteract this.
memberCountInfluence = 1;
}
this._checkIfAlone(this.state.room, memberCountInfluence);
this._updateE2EStatus(this.state.room);
}, 500),
_checkIfAlone: function(room) {
_checkIfAlone: function(room, countInfluence) {
let warnedAboutLonelyRoom = false;
if (localStorage) {
warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId);
@ -801,7 +844,8 @@ module.exports = React.createClass({
return;
}
const joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
},
@ -840,10 +884,6 @@ module.exports = React.createClass({
}
},
onSearchResultsResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
onSearchResultsFillRequest: function(backwards) {
if (!backwards) {
return Promise.resolve(false);
@ -882,13 +922,12 @@ module.exports = React.createClass({
// If the user is a ROU, allow them to transition to a PWLU
if (cli && cli.isGuest()) {
// Join this room once the user has registered and logged in
const signUrl = this.props.thirdPartyInvite ?
this.props.thirdPartyInvite.inviteSignUrl : undefined;
// (If we failed to peek, we may not have a valid room object.)
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_room',
room_id: this.state.room.roomId,
room_id: this._getRoomId(),
},
});
@ -977,9 +1016,11 @@ module.exports = React.createClass({
onDrop: function(ev) {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(),
);
this.setState({ draggingFile: false });
const files = [...ev.dataTransfer.files];
files.forEach(this.uploadFile);
dis.dispatch({action: 'focus_composer'});
},
onDragLeaveOrEnd: function(ev) {
@ -988,55 +1029,13 @@ module.exports = React.createClass({
this.setState({ draggingFile: false });
},
isFileUploadAllowed(file) {
if (this.state.mediaConfig !== undefined &&
this.state.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.state.mediaConfig["m.upload.size"]) {
return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])});
}
return true;
},
uploadFile: async function(file) {
dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
try {
await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get());
} catch (error) {
if (error.name === "UnknownDeviceError") {
// Let the status bar handle this
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload file " + file + " " + error);
Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
title: _t('Failed to upload file'),
description: ((error && error.message)
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
});
// bail early to avoid calling the dispatch below
return;
}
// Send message_sent callback, for things like _checkIfAlone because after all a file is still a message.
dis.dispatch({
action: 'message_sent',
});
},
injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
.done(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
@ -1117,7 +1116,7 @@ module.exports = React.createClass({
// favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) {
return b.length - a.length;
});
});
self.setState({
searchHighlights: highlights,
@ -1171,7 +1170,7 @@ module.exports = React.createClass({
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onWidgetLoad = () => {
const onHeightChanged = () => {
const scrollPanel = this.refs.searchResultsPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
@ -1185,6 +1184,7 @@ module.exports = React.createClass({
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = cli.getRoom(roomId);
if (!EventTile.haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
@ -1194,7 +1194,6 @@ module.exports = React.createClass({
if (this.state.searchScope === 'All') {
if (roomId != lastRoomId) {
const room = cli.getRoom(roomId);
// XXX: if we've left the room, we might not know about
// it. We should tell the js sdk to go and find out about
@ -1215,7 +1214,8 @@ module.exports = React.createClass({
searchResult={result}
searchHighlights={this.state.searchHighlights}
resultLink={resultLink}
onWidgetLoad={onWidgetLoad} />);
permalinkCreator={this._getPermalinkCreatorForRoom(room)}
onHeightChanged={onHeightChanged} />);
}
return ret;
},
@ -1231,50 +1231,9 @@ module.exports = React.createClass({
dis.dispatch({ action: 'open_room_settings' });
},
onSettingsSaveClick: function() {
if (!this.refs.room_settings) return;
this.setState({
uploadingRoomSettings: true,
});
const newName = this.refs.header.getEditedName();
if (newName !== undefined) {
this.refs.room_settings.setName(newName);
}
const newTopic = this.refs.header.getEditedTopic();
if (newTopic !== undefined) {
this.refs.room_settings.setTopic(newTopic);
}
this.refs.room_settings.save().then((results) => {
const fails = results.filter(function(result) { return result.state !== "fulfilled"; });
console.log("Settings saved with %s errors", fails.length);
if (fails.length) {
fails.forEach(function(result) {
console.error(result.reason);
});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to save room settings', '', ErrorDialog, {
title: _t("Failed to save settings"),
description: fails.map(function(result) { return result.reason; }).join("\n"),
});
// still editing room settings
} else {
dis.dispatch({ action: 'close_settings' });
}
}).finally(() => {
this.setState({
uploadingRoomSettings: false,
});
dis.dispatch({ action: 'close_settings' });
}).done();
},
onCancelClick: function() {
console.log("updateTint from onCancelClick");
this.updateTint();
dis.dispatch({ action: 'close_settings' });
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
@ -1342,7 +1301,10 @@ module.exports = React.createClass({
},
onSearchClick: function() {
this.setState({ searching: true, showingPinned: false });
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
},
onCancelSearchClick: function() {
@ -1377,8 +1339,7 @@ module.exports = React.createClass({
const showBar = this.refs.messagePanel.canJumpToReadMarker();
if (this.state.showTopUnreadMessagesBar != showBar) {
this.setState({showTopUnreadMessagesBar: showBar},
this.onChildResize);
this.setState({showTopUnreadMessagesBar: showBar});
}
},
@ -1421,7 +1382,7 @@ module.exports = React.createClass({
};
},
onResize: function(e) {
onResize: function() {
// It seems flexbox doesn't give us a way to constrain the auxPanel height to have
// a minimum of the height of the video element, whilst also capping it from pushing out the page
// so we have to do it via JS instead. In this implementation we cap the height by putting
@ -1432,16 +1393,13 @@ module.exports = React.createClass({
(83 + // height of RoomHeader
36 + // height of the status area
72 + // minimum height of the message compmoser
(this.state.editingRoomSettings ? (window.innerHeight * 0.3) : 120)); // amount of desired scrollback
120); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
// changing the maxHeight on the auxpanel will trigger a callback go
// onChildResize, so no need to worry about that here.
},
onFullscreenClick: function() {
@ -1471,10 +1429,6 @@ module.exports = React.createClass({
this.forceUpdate(); // TODO: just update the voip buttons
},
onChildResize: function() {
// no longer anything to do here
},
onStatusBarVisible: function() {
if (this.unmounted) return;
this.setState({
@ -1528,27 +1482,50 @@ module.exports = React.createClass({
}
},
_getOldRoom: function() {
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
if (!createEvent || !createEvent.getContent()['predecessor']) return null;
return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']);
},
_getHiddenHighlightCount: function() {
const oldRoom = this._getOldRoom();
if (!oldRoom) return 0;
return oldRoom.getUnreadNotificationCount('highlight');
},
_onHiddenHighlightsClick: function() {
const oldRoom = this._getOldRoom();
if (!oldRoom) return;
dis.dispatch({action: "view_room", room_id: oldRoom.roomId});
},
render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
const RoomSettings = sdk.getComponent("rooms.RoomSettings");
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
const SearchBar = sdk.getComponent("rooms.SearchBar");
const PinnedEventsPanel = sdk.getComponent("rooms.PinnedEventsPanel");
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) {
const loading = this.state.roomLoading || this.state.peekLoading;
if (loading) {
return (
<div className="mx_RoomView">
<Loader />
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
/>
</div>
);
} else {
@ -1566,28 +1543,16 @@ module.exports = React.createClass({
const roomAlias = this.state.roomAlias;
return (
<div className="mx_RoomView">
<RoomHeader ref="header"
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
oobData={this.props.oobData}
collapsedRhs={this.props.collapsedRhs}
e2eStatus={this.state.e2eStatus}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
);
}
@ -1597,9 +1562,12 @@ module.exports = React.createClass({
if (myMembership == 'invite') {
if (this.state.joining || this.state.rejecting) {
return (
<div className="mx_RoomView">
<Loader />
</div>
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
joining={this.state.joining}
rejecting={this.state.rejecting}
/>
);
} else {
const myUserId = MatrixClientPeg.get().credentials.userId;
@ -1614,26 +1582,14 @@ module.exports = React.createClass({
// We have a regular invite for this room.
return (
<div className="mx_RoomView">
<RoomHeader
ref="header"
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
room={this.state.room}
collapsedRhs={this.props.collapsedRhs}
e2eStatus={this.state.e2eStatus}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
);
}
@ -1655,7 +1611,7 @@ module.exports = React.createClass({
let statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
const UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) {
@ -1668,7 +1624,6 @@ module.exports = React.createClass({
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
/>;
@ -1682,19 +1637,18 @@ module.exports = React.createClass({
);
const showRoomRecoveryReminder = (
SettingsStore.isFeatureEnabled("feature_keybackup") &&
SettingsStore.getValue("showRoomRecoveryReminder") &&
MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) &&
!MatrixClientPeg.get().getKeyBackupEnabled()
);
const hiddenHighlightCount = this._getHiddenHighlightCount();
let aux = null;
let previewBar;
let hideCancel = false;
if (this.state.editingRoomSettings) {
aux = <RoomSettings ref="room_settings" onSaveClick={this.onSettingsSaveClick} onCancelClick={this.onCancelClick} room={this.state.room} />;
} else if (this.state.uploadingRoomSettings) {
aux = <Loader />;
} else if (this.state.forwardingEvent !== null) {
let hideRightPanel = false;
if (this.state.forwardingEvent !== null) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
@ -1720,31 +1674,48 @@ module.exports = React.createClass({
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
}
hideCancel = true;
aux = (
previewBar = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
spinner={this.state.joining}
spinnerState="joining"
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
canPreview={this.state.canPeek}
room={this.state.room}
/>
);
if (!this.state.canPeek) {
return (
<div className="mx_RoomView">
{ previewBar }
</div>
);
} else {
hideRightPanel = true;
}
} else if (hiddenHighlightCount > 0) {
aux = (
<AccessibleButton element="div" className="mx_RoomView_auxPanel_hiddenHighlights"
onClick={this._onHiddenHighlightsClick}>
{_t(
"You have %(count)s unread notifications in a prior version of this room.",
{count: hiddenHighlightCount},
)}
</AccessibleButton>
);
}
const auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
fullHeight={this.state.editingRoomSettings}
fullHeight={false}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize}
showApps={this.state.showApps}
hideAppsDrawer={this.state.editingRoomSettings} >
hideAppsDrawer={false} >
{ aux }
</AuxPanel>
);
@ -1758,21 +1729,14 @@ module.exports = React.createClass({
messageComposer =
<MessageComposer
room={this.state.room}
onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
uploadAllowed={this.isFileUploadAllowed}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
if (MatrixClientPeg.get().isGuest()) {
const AuthButtons = sdk.getComponent('views.auth.AuthButtons');
messageComposer = <AuthButtons />;
}
// TODO: Why aren't we storing the term/scope/count in this format
// in this.state if this is what RoomHeader desires?
if (this.state.searchResults) {
@ -1832,7 +1796,7 @@ module.exports = React.createClass({
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
resizeNotifier={this.props.resizeNotifier}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
@ -1866,6 +1830,9 @@ module.exports = React.createClass({
showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
/>);
let topUnreadMessagesBar = null;
@ -1898,26 +1865,29 @@ module.exports = React.createClass({
},
);
const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} /> : undefined;
const rightPanel = !hideRightPanel && this.state.room &&
<RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />;
const collapsedRhs = hideRightPanel || this.props.collapsedRhs;
return (
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
editing={this.state.editingRoomSettings}
saving={this.state.uploadingRoomSettings}
inRoom={myMembership === 'join'}
collapsedRhs={this.props.collapsedRhs}
collapsedRhs={collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onSaveClick={this.onSettingsSaveClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
/>
<MainSplit panel={rightPanel} collapsedRhs={this.props.collapsedRhs}>
<MainSplit
panel={rightPanel}
collapsedRhs={collapsedRhs}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{ auxPanel }
<div className="mx_RoomView_timeline">
@ -1932,6 +1902,7 @@ module.exports = React.createClass({
{ statusBar }
</div>
</div>
{ previewBar }
{ messageComposer }
</div>
</MainSplit>

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,56 +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();
// clear min-height as the height might have changed
this.clearBlockShrinking();
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
@ -239,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
@ -286,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
@ -325,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();
}
},
@ -341,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
@ -352,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
@ -374,28 +404,31 @@ module.exports = React.createClass({
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
// if timeline shrinks, min-height should be cleared
this.clearBlockShrinking();
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;
@ -406,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
@ -426,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.
*/
@ -447,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();
},
@ -466,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) {
@ -528,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
@ -669,56 +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;
},
/**
* Set the current height as the min height for the message list
* so the timeline cannot shrink. This is used to avoid
* jumping when the typing indicator gets replaced by a smaller message.
*/
blockShrinking: function() {
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;
if (messageList) {
const currentHeight = messageList.clientHeight;
messageList.style.minHeight = `${currentHeight}px`;
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");
},
/**
* Clear the previously set min height
*/
clearBlockShrinking: function() {
const messageList = this.refs.itemlist;
if (messageList) {
messageList.style.minHeight = null;
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

@ -74,7 +74,6 @@ export class TabbedView extends React.Component {
const idx = this.props.tabs.indexOf(tab);
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
if (tab.label === "Visit old settings") classes += "mx_TabbedView_tabLabel_TEMP_HACK";
let tabIcon = null;
if (tab.icon) {

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
@ -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.createTrackedDialog('Report bugs & give feedback', '', 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.
@ -43,11 +44,10 @@ const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
let debuglog = function() {};
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
debuglog = console.log.bind(console);
}
/*
@ -55,7 +55,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
var TimelinePanel = React.createClass({
const TimelinePanel = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
@ -106,6 +106,9 @@ var TimelinePanel = React.createClass({
// placeholder text to use if the timeline is empty
empty: PropTypes.string,
// whether to show reactions for an event
showReactions: PropTypes.bool,
},
statics: {
@ -208,6 +211,7 @@ var TimelinePanel = React.createClass({
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
MatrixClientPeg.get().on("sync", this.onSync);
this._initTimeline(this.props);
@ -286,6 +290,7 @@ var TimelinePanel = React.createClass({
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
client.removeListener("Room.accountData", this.onAccountData);
client.removeListener("Event.decrypted", this.onEventDecrypted);
client.removeListener("Event.replaced", this.onEventReplaced);
client.removeListener("sync", this.onSync);
}
},
@ -402,6 +407,15 @@ var TimelinePanel = React.createClass({
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
if (payload.action === "edit_event") {
this.setState({editEvent: payload.event}, () => {
if (payload.event && this.refs.messagePanel) {
this.refs.messagePanel.scrollToEventIfNeeded(
payload.event.getId(),
);
}
});
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
@ -444,6 +458,7 @@ var TimelinePanel = React.createClass({
const updatedState = {events: events};
let callRMUpdated;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
@ -451,12 +466,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 callRMUpdated = false;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
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
@ -501,6 +516,17 @@ var TimelinePanel = React.createClass({
this.forceUpdate();
},
onEventReplaced: function(replacedEvent, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.timelineSet.room) return;
// we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation.
this.forceUpdate();
},
onRoomReceipt: function(ev, room) {
if (this.unmounted) return;
@ -536,6 +562,9 @@ var TimelinePanel = React.createClass({
},
onEventDecrypted: function(ev) {
// Can be null for the notification timeline, etc.
if (!this.props.timelineSet.room) return;
// Need to update as we don't display event tiles for events that
// haven't yet been decrypted. The event will have just been updated
// in place so we just need to re-render.
@ -562,10 +591,10 @@ 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 */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
@ -574,10 +603,10 @@ 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 */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
@ -733,7 +762,8 @@ var TimelinePanel = React.createClass({
const events = this._timelineWindow.getEvents();
// first find where the current RM is
for (var i = 0; i < events.length; i++) {
let i;
for (i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId) {
break;
}
@ -745,7 +775,7 @@ var TimelinePanel = React.createClass({
// now think about advancing it
const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) {
var ev = events[i];
const ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) {
break;
}
@ -753,7 +783,7 @@ var TimelinePanel = React.createClass({
// i is now the first unread message which we didn't send ourselves.
i--;
var ev = events[i];
const ev = events[i];
this._setReadMarker(ev.getId(), ev.getTs());
},
@ -883,7 +913,7 @@ var TimelinePanel = React.createClass({
return ret;
},
/**
/*
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
@ -937,6 +967,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
@ -971,11 +1006,10 @@ var TimelinePanel = React.createClass({
};
const onError = (error) => {
this.setState({timelineLoading: false});
this.setState({ timelineLoading: false });
console.error(
`Error loading timeline panel at ${eventId}: ${error}`,
);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let onFinished;
@ -993,9 +1027,18 @@ var TimelinePanel = React.createClass({
});
};
}
const message = (error.errcode == 'M_FORBIDDEN')
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
: _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
let message;
if (error.errcode == 'M_FORBIDDEN') {
message = _t(
"Tried to load a specific point in this room's timeline, but you " +
"do not have permission to view the message in question.",
);
} else {
message = _t(
"Tried to load a specific point in this room's timeline, but was " +
"unable to find it.",
);
}
Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
title: _t("Failed to load timeline position"),
description: message,
@ -1100,12 +1143,13 @@ var TimelinePanel = React.createClass({
},
/**
* get the id of the event corresponding to our user's latest read-receipt.
* Get the id of the event corresponding to our user's latest read-receipt.
*
* @param {Boolean} ignoreSynthesized If true, return only receipts that
* have been sent by the server, not
* implicit ones generated by the JS
* SDK.
* @return {String} the event ID
*/
_getCurrentReadReceipt: function(ignoreSynthesized) {
const client = MatrixClientPeg.get();
@ -1154,6 +1198,10 @@ var TimelinePanel = React.createClass({
});
},
getRelationsForEvent(...args) {
return this.props.timelineSet.getRelationsForEvent(...args);
},
render: function() {
const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner");
@ -1179,9 +1227,9 @@ var TimelinePanel = React.createClass({
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{ this.props.empty }</div>
</div>
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{this.props.empty}</div>
</div>
);
}
@ -1203,26 +1251,31 @@ var TimelinePanel = React.createClass({
);
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}
events={this.state.events}
highlightedEventId={this.props.highlightedEventId}
readMarkerEventId={this.state.readMarkerEventId}
readMarkerVisible={this.state.readMarkerVisible}
suppressFirstDateSeparator={this.state.canBackPaginate}
showUrlPreview={this.props.showUrlPreview}
showReadReceipts={this.props.showReadReceipts}
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
room={this.props.timelineSet.room}
permalinkCreator={this.props.permalinkCreator}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}
events={this.state.events}
highlightedEventId={this.props.highlightedEventId}
readMarkerEventId={this.state.readMarkerEventId}
readMarkerVisible={this.state.readMarkerVisible}
suppressFirstDateSeparator={this.state.canBackPaginate}
showUrlPreview={this.props.showUrlPreview}
showReadReceipts={this.props.showReadReceipts}
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
editEvent={this.state.editEvent}
showReactions={this.props.showReactions}
/>
);
},

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,6 +24,8 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
import { _t } from '../../languageHandler';
import dis from "../../dispatcher";
import {focusCapturedRef} from "../../utils/Accessibility";
const AVATAR_SIZE = 28;
@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component {
super();
this.state = {
menuDisplayed: false,
menuFunctions: null, // should be { close: fn }
profileInfo: null,
};
@ -59,6 +63,8 @@ export default class TopLeftMenuButton extends React.Component {
}
async componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);
try {
const profileInfo = await this._getProfileInfo();
this.setState({profileInfo});
@ -68,17 +74,29 @@ export default class TopLeftMenuButton extends React.Component {
}
}
render() {
const fallbackUserId = MatrixClientPeg.get().getUserId();
const profileInfo = this.state.profileInfo;
let name;
if (MatrixClientPeg.get().isGuest()) {
name = _t("Guest");
} else if (profileInfo) {
name = profileInfo.name;
} else {
name = fallbackUserId;
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
onAction = (payload) => {
// For accessibility
if (payload.action === "toggle_top_left_menu") {
if (this._buttonRef) this._buttonRef.click();
}
};
_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 name = this._getDisplayName();
let nameElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
@ -87,17 +105,23 @@ export default class TopLeftMenuButton extends React.Component {
}
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<AccessibleButton
className="mx_TopLeftMenuButton"
role="button"
onClick={this.onToggleMenu}
inputRef={(r) => this._buttonRef = r}
aria-label={_t("Your profile")}
>
<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 }
<span className="mx_TopLeftMenuButton_chevron"></span>
<span className="mx_TopLeftMenuButton_chevron" />
</AccessibleButton>
);
}
@ -106,18 +130,26 @@ export default class TopLeftMenuButton extends React.Component {
e.preventDefault();
e.stopPropagation();
if (this.state.menuDisplayed && this.state.menuFunctions) {
this.state.menuFunctions.close();
return;
}
const elementRect = e.currentTarget.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;
ContextualMenu.createMenu(TopLeftMenu, {
const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, {
chevronFace: "none",
left: x,
top: y,
userId: MatrixClientPeg.get().getUserId(),
displayName: this._getDisplayName(),
containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render
onFinished: () => {
this.setState({ menuDisplayed: false });
this.setState({ menuDisplayed: false, menuFunctions: null });
},
});
this.setState({ menuDisplayed: true });
this.setState({ menuDisplayed: true, menuFunctions });
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
const React = require('react');
import PropTypes from 'prop-types';
const ContentMessages = require('../../ContentMessages');
import ContentMessages from '../../ContentMessages';
const dis = require('../../dispatcher');
const filesize = require('filesize');
import { _t } from '../../languageHandler';
@ -40,6 +40,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
switch (payload.action) {
case 'upload_progress':
case 'upload_finished':
case 'upload_canceled':
case 'upload_failed':
if (this.mounted) this.forceUpdate();
break;
@ -47,7 +48,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
@ -93,7 +94,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
</div>
<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.cancelUpload(upload.promise); }}
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

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector 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.
@ -20,55 +20,55 @@ 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";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// 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: {
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onServerConfigChange: PropTypes.func.isRequired,
onLoginClick: 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,
phase: PHASE_FORGOT,
email: "",
password: "",
password2: "",
errorText: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
submitPasswordReset: function(email, password) {
this.setState({
progress: "sending_email",
phase: PHASE_SENDING_EMAIL,
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
progress: "sent_email",
phase: PHASE_EMAIL_SENT,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
progress: null,
phase: PHASE_FORGOT,
});
});
},
@ -80,7 +80,7 @@ module.exports = React.createClass({
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
this.setState({ progress: "complete" });
this.setState({ phase: PHASE_DONE });
}, (err) => {
this.showErrorDialog(err.message);
});
@ -89,13 +89,6 @@ module.exports = React.createClass({
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) {
@ -109,56 +102,40 @@ module.exports = React.createClass({
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.',
"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'),
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,
);
this.submitPasswordReset(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);
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_FORGOT,
});
},
onEditServerDetailsClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
},
onLoginClick: function(ev) {
@ -175,95 +152,160 @@ module.exports = React.createClass({
});
},
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")}
submitClass="mx_Login_submit"
/>;
},
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 on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
// 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");
const ServerConfig = sdk.getComponent("auth.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>
{ _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>
<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"
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>;
}
resetPasswordJsx = (
<div>
<p>
{ _t('To reset your password, enter the email address linked to your account') }:
</p>
<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_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>
</div>
</div>
);
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 />

View file

@ -20,13 +20,12 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t, _td } from '../../../languageHandler';
import {_t, _td} from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { AutoDiscovery } from "matrix-js-sdk";
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -37,13 +36,18 @@ const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Disable phases for now, pending UX discussion on WK discovery
const PHASES_ENABLED = false;
// 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");
_td("Failed to get autodiscovery configuration from server");
_td("Invalid base_url for m.homeserver");
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
_td("Invalid identity server discovery response");
_td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
/**
@ -55,30 +59,28 @@ module.exports = React.createClass({
propTypes: {
onLoggedIn: PropTypes.func.isRequired,
enableGuest: PropTypes.bool,
// An error passed along from higher up explaining that something
// went wrong. May be replaced with a different error within the
// Login component.
errorText: PropTypes.string,
// If true, the component will consider itself busy.
busy: PropTypes.bool,
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 homeserver without confusing users.
fallbackHsUrl: 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.
// login shouldn't know or care how registration, password recovery,
// etc is done.
onRegisterClick: PropTypes.func.isRequired,
// login shouldn't care how password recovery is done.
onForgotPasswordClick: PropTypes.func,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
},
getInitialState: function() {
@ -87,23 +89,15 @@ module.exports = React.createClass({
errorText: null,
loginIncorrect: false,
serverType: null,
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_SERVER_DETAILS,
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: "m.login.password",
// .well-known discovery
discoveryError: "",
findingHomeserver: false,
};
},
@ -127,6 +121,14 @@ module.exports = React.createClass({
this._unmounted = true;
},
componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
},
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
@ -134,10 +136,17 @@ module.exports = React.createClass({
});
},
isBusy: function() {
return this.state.busy || this.props.busy;
},
hasError: function() {
return this.state.errorText || this.props.errorText;
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
// Prevent people from submitting their password when homeserver
// discovery went wrong
if (this.state.discoveryError || this.props.defaultServerDiscoveryError) return;
// Prevent people from submitting their password when something isn't right.
if (this.isBusy() || this.hasError()) return;
this.setState({
busy: true,
@ -159,7 +168,7 @@ module.exports = React.createClass({
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This homeserver does not support login using email address.');
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
@ -189,11 +198,10 @@ module.exports = React.createClass({
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
<div className="mx_Login_smallError">
{ _t('Please note you are logging into the %(hs)s server, not matrix.org.',
{
hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
})
}
{_t(
'Please note you are logging into the %(hs)s server, not matrix.org.',
{hs: this.props.serverConfig.hsName},
)}
</div>
</div>
);
@ -223,61 +231,30 @@ module.exports = React.createClass({
}).done();
},
_onLoginAsGuestClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
const self = this;
self.setState({
busy: true,
errorText: null,
loginIncorrect: false,
});
this._loginLogic.loginAsGuest().then(function(data) {
self.props.onLoggedIn(data);
}, function(error) {
let errorText;
if (error.httpStatus === 403) {
errorText = _t("Guest access is disabled on this homeserver.");
} else {
errorText = self._errorTextFromError(error);
}
self.setState({
errorText: errorText,
loginIncorrect: false,
});
}).finally(function() {
self.setState({
busy: false,
});
}).done();
},
onUsernameChanged: function(username) {
this.setState({ username: username });
},
onUsernameBlur: function(username) {
onUsernameBlur: async function(username) {
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
discoveryError: null,
busy: doWellknownLookup, // unset later by the result of onServerConfigChange
errorText: null,
});
// If the free server type is selected, we don't show server details at all,
// so it doesn't make sense to try .well-known discovery.
if (this.state.serverType === ServerType.FREE) {
return;
}
if (username[0] === "@") {
if (doWellknownLookup) {
const serverName = username.split(':').slice(1).join(':');
try {
// we have to append 'https://' to make the URL constructor happy
// otherwise we get things like 'protocol: matrix.org, pathname: 8448'
const url = new URL("https://" + serverName);
this._tryWellKnownDiscovery(url.hostname);
const result = await AutoDiscoveryUtils.validateServerName(serverName);
this.props.onServerConfigChange(result);
} catch (e) {
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
this.setState({discoveryError: _t("Failed to perform homeserver discovery")});
let message = _t("Failed to perform homeserver discovery");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({errorText: message, busy: false});
}
}
},
@ -305,65 +282,13 @@ module.exports = React.createClass({
}
},
onServerConfigChange: function(config) {
const self = this;
const newState = {
errorText: null, // reset err messages
};
if (config.hsUrl !== undefined) {
newState.enteredHsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIsUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl);
});
},
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,
});
// Move directly to the login phase since the server details are fixed.
this.setState({
phase: PHASE_LOGIN,
});
break;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
this.onServerConfigChange({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
this.setState({
phase: PHASE_SERVER_DETAILS,
});
break;
}
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_LOGIN,
});
@ -377,72 +302,13 @@ module.exports = React.createClass({
});
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
this.setState({
discoveryError: "",
findingHomeserver: false,
});
return;
}
this.setState({findingHomeserver: true});
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
// The server type may have changed while discovery began in the background.
// If it has become the free server type which doesn't show server details,
// ignore discovery results.
if (this.state.serverType === ServerType.FREE) {
this.setState({findingHomeserver: false});
return;
}
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) {
this.setState({
discoveryError: discovery["m.homeserver"].error,
findingHomeserver: false,
});
} else if (state === AutoDiscovery.PROMPT) {
this.setState({
discoveryError: "",
findingHomeserver: false,
});
} else if (state === AutoDiscovery.SUCCESS) {
this.setState({
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({
discoveryError: _t("Unknown failure discovering homeserver"),
findingHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
findingHomeserver: false,
discoveryError: _t("Unknown error discovering homeserver"),
});
}
},
_initLoginLogic: function(hsUrl, isUrl) {
const self = this;
hsUrl = hsUrl || this.state.enteredHsUrl;
isUrl = isUrl || this.state.enteredIsUrl;
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
// TODO: TravisR - Only use this if the homeserver is the default homeserver
const fallbackHsUrl = this.props.fallbackHsUrl;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@ -450,8 +316,6 @@ module.exports = React.createClass({
this._loginLogic = loginLogic;
this.setState({
enteredHsUrl: hsUrl,
enteredIsUrl: isUrl,
busy: true,
loginIncorrect: false,
});
@ -517,8 +381,8 @@ module.exports = React.createClass({
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
(this.state.enteredHsUrl.startsWith("http:") ||
!this.state.enteredHsUrl.startsWith("http"))
(this.props.serverConfig.hsUrl.startsWith("http:") ||
!this.props.serverConfig.hsUrl.startsWith("http"))
) {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
@ -541,9 +405,9 @@ module.exports = React.createClass({
"is not blocking requests.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noopener"
href={this.state.enteredHsUrl}
>{ sub }</a>;
return <a target="_blank" rel="noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>;
},
},
) }
@ -554,70 +418,30 @@ module.exports = React.createClass({
return errorText;
},
renderServerComponentForStep() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// TODO: May need to adjust the behavior of this config option
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
defaultHsUrl={this.props.defaultHsUrl}
onChange={this.onServerTypeChange}
/>
</div>;
return null;
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
customHsUrl={this.state.enteredHsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
}
let nextButton = null;
const serverDetailsProps = {};
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
},
renderLoginComponentForStep() {
@ -642,16 +466,13 @@ module.exports = React.createClass({
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
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
) {
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
@ -667,9 +488,9 @@ module.exports = React.createClass({
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsUrl={this.state.enteredHsUrl}
disableSubmit={this.state.findingHomeserver}
/>
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
/>
);
},
@ -692,17 +513,9 @@ module.exports = React.createClass({
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 loader = this.isBusy() ? <div className="mx_Login_loader"><Loader /></div> : null;
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
let loginAsGuestJsx;
if (this.props.enableGuest) {
loginAsGuestJsx =
<a className="mx_AuthBody_changeFlow" onClick={this._onLoginAsGuestClick} href="#">
{ _t('Try the app first') }
</a>;
}
const errorText = this.state.errorText || this.props.errorText;
let errorTextSection;
if (errorText) {
@ -718,16 +531,15 @@ module.exports = React.createClass({
<AuthHeader />
<AuthBody>
<h2>
{_t('Sign in to your account')}
{_t('Sign in')}
{loader}
</h2>
{ errorTextSection }
{ this.renderServerComponentForStep() }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
{ _t('Create account') }
</a>
{ loginAsGuestJsx }
</AuthBody>
</AuthPage>
);

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
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,15 @@ 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;
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// Phases
// Show controls to configure server details
@ -48,27 +46,17 @@ module.exports = React.createClass({
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
brand: PropTypes.string,
email: PropTypes.string,
referrer: 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,
},
getInitialState: function() {
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
return {
busy: false,
errorText: null,
@ -87,9 +75,9 @@ module.exports = React.createClass({
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType: null,
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
};
},
@ -99,18 +87,35 @@ module.exports = React.createClass({
this._replaceClient();
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point.
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
if (serverType !== this.state.serverType) {
// Reset the phase to default phase for the server type.
this.setState({
serverType,
phase: this.getDefaultPhaseForServerType(serverType),
});
}
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
},
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;
}
this.props.onServerConfigChange(config);
this.setState(newState, () => {
this._replaceClient();
});
},
onServerTypeChange(type) {
@ -122,37 +127,35 @@ module.exports = React.createClass({
// the new type.
switch (type) {
case ServerType.FREE: {
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
this.onServerConfigChange({
hsUrl,
isUrl,
});
// Move directly to the registration phase since the server details are fixed.
this.setState({
phase: PHASE_REGISTRATION,
});
const { serverConfig } = ServerType.TYPES.FREE;
this.props.onServerConfigChange(serverConfig);
break;
}
case ServerType.PREMIUM:
// We can accept whatever server config was the default here as this essentially
// acts as a slightly different "custom server"/ADVANCED option.
break;
case ServerType.ADVANCED:
this.onServerConfigChange({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
this.setState({
phase: PHASE_SERVER_DETAILS,
});
// Use the default config from the config
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
},
_replaceClient: async function() {
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
const {hsUrl, isUrl} = serverConfig;
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
try {
await this._makeRegisterRequest({});
@ -177,12 +180,6 @@ module.exports = React.createClass({
},
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,
@ -191,11 +188,25 @@ module.exports = React.createClass({
});
},
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
return this._matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
sendAttempt,
this.props.makeRegistrationUrl({
client_secret: clientSecret,
hs_url: this._matrixClient.getHomeserverUrl(),
is_url: this._matrixClient.getIdentityServerUrl(),
session_id: sessionId,
}),
);
},
_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') {
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
@ -273,54 +284,24 @@ module.exports = React.createClass({
});
},
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 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();
},
onServerDetailsNextPhaseClick(ev) {
onGoToFormClicked(ev) {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
});
},
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_REGISTRATION,
});
@ -365,9 +346,7 @@ module.exports = React.createClass({
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// TODO: May need to adjust the behavior of this config option
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
@ -377,53 +356,47 @@ module.exports = React.createClass({
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</div>;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
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}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
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}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
break;
}
let nextButton = null;
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
}
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
},
@ -442,14 +415,16 @@ module.exports = React.createClass({
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
requestEmailToken={this._requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>;
} else if (this.state.busy || !this.state.flows) {
return <Spinner />;
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
@ -461,18 +436,17 @@ module.exports = React.createClass({
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
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}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
hsUrl={this.state.hsUrl}
serverConfig={this.props.serverConfig}
/>;
}
},
@ -483,18 +457,21 @@ module.exports = React.createClass({
const AuthPage = sdk.getComponent('auth.AuthPage');
let errorText;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>
);
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 (
@ -505,6 +482,7 @@ module.exports = React.createClass({
{ errorText }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</AuthBody>
</AuthPage>

View file

@ -1,57 +0,0 @@
/*
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.
*/
'use strict';
const React = require('react');
import { _t } from '../../../languageHandler';
const dis = require('../../../dispatcher');
const AccessibleButton = require('../elements/AccessibleButton');
module.exports = React.createClass({
displayName: 'AuthButtons',
propTypes: {
},
onLoginClick: function() {
dis.dispatch({ action: 'start_login' });
},
onRegisterClick: function() {
dis.dispatch({ action: 'start_registration' });
},
render: function() {
const loginButton = (
<div className="mx_AuthButtons_loginButton_wrapper">
<AccessibleButton className="mx_AuthButtons_loginButton" element="button" onClick={this.onLoginClick}>
{ _t("Login") }
</AccessibleButton>
<AccessibleButton className="mx_AuthButtons_registerButton" element="button" onClick={this.onRegisterClick}>
{ _t("Register") }
</AccessibleButton>
</div>
);
return (
<div className="mx_AuthButtons">
{ loginButton }
</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,29 +60,15 @@ module.exports = React.createClass({
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
const protocol = global.location.protocol;
let protocol = global.location.protocol;
if (protocol === "vector:") {
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 target="_blank" rel="noopener" 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);
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);
}
},
@ -141,8 +126,9 @@ module.exports = React.createClass({
return (
<div ref="recaptchaContainer">
{ _t("This homeserver 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

@ -113,7 +113,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ country.name } <span>(+{ country.prefix })</span>
{ country.name } (+{ country.prefix })
</div>;
});

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -57,7 +58,6 @@ import SettingsStore from "../../../settings/SettingsStore";
* session to be failed and the process to go back to the start.
* setEmailSid: m.login.email.identity only: a function to be called with the
* email sid after a token is requested.
* makeRegistrationUrl A function that makes a registration URL
*
* Each component may also provide the following functions (beyond the standard React ones):
* focus: set the input focus appropriately in the form.
@ -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>;
}
@ -365,7 +365,6 @@ export const EmailIdentityAuthEntry = React.createClass({
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
makeRegistrationUrl: PropTypes.func.isRequired,
},
getInitialState: function() {
@ -374,38 +373,6 @@ export const EmailIdentityAuthEntry = React.createClass({
};
},
componentWillMount: function() {
if (this.props.stageState.emailSid === null) {
this.setState({requestingToken: true});
this._requestEmailToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
}
},
/*
* Requests a verification token by email.
*/
_requestEmailToken: function() {
const nextLink = this.props.makeRegistrationUrl({
client_secret: this.props.clientSecret,
hs_url: this.props.matrixClient.getHomeserverUrl(),
is_url: this.props.matrixClient.getIdentityServerUrl(),
session_id: this.props.authSessionId,
});
return this.props.matrixClient.requestRegisterEmailToken(
this.props.inputs.emailAddress,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
nextLink,
).then((result) => {
this.props.setEmailSid(result.sid);
});
},
render: function() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
@ -525,7 +492,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

@ -18,9 +18,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as ServerType from '../../views/auth/ServerTypeSelector';
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
// TODO: TravisR - Can this extend ServerConfig for most things?
/*
* Configure the Modular server name.
*
@ -31,65 +37,107 @@ 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,
// The current configuration that the user is expecting to change.
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
// Called after the component calls onServerConfigChange
onAfterSubmit: PropTypes.func,
// Optional text for the submit button. If falsey, no button will be shown.
submitText: PropTypes.string,
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
};
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
delayTimeMs: 0,
}
};
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl) return;
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl: newProps.customHsUrl,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: this.props.defaultIsUrl,
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
}
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.props.defaultIsUrl,
});
this.validateServer();
});
}
};
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
await this.validateServer();
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
@ -100,6 +148,16 @@ export default class ModularServerConfig extends React.PureComponent {
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
@ -113,15 +171,18 @@ export default class ModularServerConfig extends React.PureComponent {
</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>
<form onSubmit={this.onSubmit} autoComplete={false} action={null}>
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
{submitButton}
</form>
</div>
);
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 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.
@ -21,11 +22,29 @@ import classNames from 'classnames';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
/**
* A pure UI component which displays a username/password form.
*/
class PasswordLogin extends React.Component {
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
@ -40,9 +59,12 @@ class PasswordLogin extends React.Component {
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
hsUrl: "",
disableSubmit: false,
}
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
@ -54,6 +76,7 @@ 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);
@ -65,9 +88,10 @@ class PasswordLogin extends React.Component {
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
componentWillMount() {
this._passwordField = null;
this._loginField = null;
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
@ -127,7 +151,8 @@ class PasswordLogin extends React.Component {
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,
@ -158,67 +183,64 @@ class PasswordLogin extends React.Component {
}
renderLoginField(loginType) {
const classes = {
mx_Login_field: true,
};
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
className="mx_Login_field"
ref={(e) => {this._loginField = e;}}
return <Field
className={classNames(classes)}
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}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
return <Field
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
id="mx_PasswordLogin_username"
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
name="username" // make it a little easier for browser's remember-password
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
placeholder={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("Username")}
value={this.state.username}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.mx_Login_field_has_prefix = true;
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}
/>
<input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="phone_input"
type="text"
name="phoneNumber"
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
placeholder={_t("Mobile phone number")}
value={this.state.phoneNumber}
autoFocus
/>
</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
/>;
}
}
}
@ -234,13 +256,15 @@ class PasswordLogin extends React.Component {
}
render() {
const Field = sdk.getComponent('elements.Field');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => <a className="mx_Login_forgot"
onClick={this.props.onForgotPasswordClick}
onClick={this.onForgotPasswordClick}
href="#"
>
{sub}
@ -249,14 +273,22 @@ class PasswordLogin extends React.Component {
</span>;
}
let yourMatrixAccountText = _t('Your account');
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Your %(serverName)s account', {
serverName: parsedHsUrl.hostname,
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
} catch (e) {
// ignore
}
let editLink = null;
@ -264,17 +296,14 @@ class PasswordLogin extends React.Component {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Edit')}
{_t('Change')}
</a>;
}
const pwFieldClass = classNames({
mx_Login_field: true,
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);
let loginType;
@ -282,14 +311,32 @@ class PasswordLogin extends React.Component {
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}
onOptionChange={this.onLoginTypeChange}>
<span key={PasswordLogin.LOGIN_FIELD_MXID}>{ _t('Username') }</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>
);
}
@ -297,19 +344,22 @@ class PasswordLogin extends React.Component {
return (
<div>
<h3>
{yourMatrixAccountText}
{signInToText}
{editLink}
</h3>
<form onSubmit={this.onSubmitForm}>
{ loginType }
{ loginField }
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
{loginType}
{loginField}
<Field
className={pwFieldClass}
id="mx_PasswordLogin_password"
type="password"
name="password"
value={this.state.password} onChange={this.onPasswordChanged}
placeholder={_t('Password')}
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
/>
<br />
{ forgotPasswordJsx }
{forgotPasswordJsx}
<input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
@ -320,25 +370,3 @@ class PasswordLogin extends React.Component {
);
}
}
PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email";
PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid";
PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone";
PasswordLogin.propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
};
module.exports = PasswordLogin;

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.
@ -25,6 +25,8 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -32,6 +34,8 @@ const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
/**
* A pure UI component which displays a registration form.
*/
@ -45,77 +49,72 @@ module.exports = React.createClass({
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onError: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
hsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
},
getDefaultProps: function() {
return {
minPasswordLength: 6,
onError: function(e) {
console.error(e);
},
onValidationChange: console.error,
};
},
getInitialState: function() {
return {
// Field error codes by field ID
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirm: "",
passwordComplexity: null,
passwordSafe: false,
};
},
onSubmit: function(ev) {
onSubmit: async 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
// onError once for each invalid field.
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
this.validateField(FIELD_PASSWORD, ev.type);
this.validateField(FIELD_USERNAME, ev.type);
this.validateField(FIELD_PHONE_NUMBER, ev.type);
this.validateField(FIELD_EMAIL, ev.type);
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
const self = this;
if (this.allFieldsValid()) {
if (this.refs.email.value == '') {
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);
}
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.refs.email.value.trim();
const email = this.state.email.trim();
const promise = this.props.onRegisterClick({
username: this.refs.username.value.trim(),
password: this.refs.password.value.trim(),
username: this.state.username.trim(),
password: this.state.password.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber ? this.refs.phoneNumber.value.trim() : '',
phoneNumber: this.state.phoneNumber,
});
if (promise) {
@ -126,135 +125,211 @@ module.exports = React.createClass({
}
},
async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
},
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid: function() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldValid[keys[i]] == false) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
},
validateField: function(fieldID, eventType) {
const pwd1 = this.refs.password.value.trim();
const pwd2 = this.refs.passwordConfirm.value.trim();
const allowEmpty = eventType === "blur";
switch (fieldID) {
case FIELD_EMAIL: {
const email = this.refs.email.value;
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;
findFirstInvalidField(fieldIDs) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
case FIELD_PHONE_NUMBER: {
const phoneNumber = this.refs.phoneNumber ? this.refs.phoneNumber.value : '';
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.refs.username.value.trim();
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:
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
break;
}
return null;
},
markFieldValid: function(fieldID, val, errorCode) {
const fieldValid = this.state.fieldValid;
fieldValid[fieldID] = val;
this.setState({fieldValid: fieldValid});
if (!val) {
this.props.onError(errorCode);
}
markFieldValid: function(fieldID, valid) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
},
fieldElementById(fieldID) {
switch (fieldID) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
return this.refs.password;
case FIELD_PASSWORD_CONFIRM:
return this.refs.passwordConfirm;
}
onEmailChange(ev) {
this.setState({
email: ev.target.value,
});
},
_classForField: function(fieldID, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldValid[fieldID] === false) {
if (cls) cls += ' ';
cls += 'error';
}
return cls;
async onEmailValidate(fieldState) {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
return result;
},
onEmailBlur(ev) {
this.validateField(FIELD_EMAIL, ev.type);
validateEmailRules: withValidation({
description: () => _t("Use an email address to recover your account"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
}),
onPasswordChange(ev) {
this.setState({
password: ev.target.value,
});
},
onPasswordBlur(ev) {
this.validateField(FIELD_PASSWORD, ev.type);
async onPasswordValidate(fieldState) {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(FIELD_PASSWORD, result.valid);
return result;
},
onPasswordConfirmBlur(ev) {
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
validatePasswordRules: withValidation({
description: function() {
const complexity = this.state.passwordComplexity;
const score = complexity ? complexity.score : 0;
return <progress
className="mx_AuthBody_passwordScore"
max={PASSWORD_MIN_SCORE}
value={score}
/>;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter password"),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
const safe = complexity.score >= PASSWORD_MIN_SCORE;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
this.setState({
passwordComplexity: complexity,
passwordSafe: safe,
});
return allowUnsafe || safe;
},
valid: function() {
// Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe.
if (!this.state.passwordSafe) {
return _t("Password is allowed, but unsafe");
}
return _t("Nice, strong password!");
},
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
],
}),
onPasswordConfirmChange(ev) {
this.setState({
passwordConfirm: ev.target.value,
});
},
async onPasswordConfirmValidate(fieldState) {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
return result;
},
validatePasswordConfirmRules: withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test: function({ value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
}),
onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
@ -262,14 +337,64 @@ module.exports = React.createClass({
});
},
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);
async onPhoneNumberValidate(fieldState) {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
return result;
},
validatePhoneNumberRules: withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("Doesn't look like a valid phone number"),
},
],
}),
onUsernameChange(ev) {
this.setState({
username: ev.target.value,
});
},
async onUsernameValidate(fieldState) {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
return result;
},
validateUsernameRules: withValidation({
description: () => _t("Use letters, numbers, dashes and underscores only"),
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter username"),
},
{
key: "safeLocalpart",
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
invalid: () => _t("Some characters not allowed"),
},
],
}),
/**
* A step is required if all flows include that step.
*
@ -294,15 +419,115 @@ module.exports = React.createClass({
});
},
renderEmail() {
if (!this._authStepIsUsed('m.login.email.identity')) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
id="mx_RegistrationForm_email"
ref={field => this[FIELD_EMAIL] = field}
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>;
},
renderPassword() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_password"
ref={field => this[FIELD_PASSWORD] = field}
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
/>;
},
renderPasswordConfirm() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
/>;
},
renderPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
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}
/>;
return <Field
id="mx_RegistrationForm_phoneNumber"
ref={field => this[FIELD_PHONE_NUMBER] = field}
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;
},
renderUsername() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
/>;
},
render: function() {
let yourMatrixAccountText = _t('Create your account');
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Create your %(serverName)s account', {
serverName: parsedHsUrl.hostname,
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
} catch (e) {
// ignore
}
let editLink = null;
@ -310,66 +535,14 @@ module.exports = React.createClass({
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Edit')}
{_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 = (
<div>
<input type="text" ref="email"
placeholder={emailPlaceholder}
defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={this.onEmailBlur}
value={this.state.email} />
</div>
);
}
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
let phoneSection;
if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) {
const phonePlaceholder = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
phoneSection = (
<div className="mx_Login_phoneSection">
<CountryDropdown ref="phone_country"
className="mx_Login_phoneCountry mx_Login_field_prefix"
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>
<input type="text" ref="phoneNumber"
placeholder={phonePlaceholder}
defaultValue={this.props.defaultPhoneNumber}
className={this._classForField(
FIELD_PHONE_NUMBER,
'mx_Login_phoneNumberField',
'mx_Login_field',
'mx_Login_field_has_prefix',
)}
onBlur={this.onPhoneNumberBlur}
value={this.state.phoneNumber}
/>
</div>
);
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
);
const placeholderUsername = _t("Username");
return (
<div>
<h3>
@ -378,31 +551,18 @@ module.exports = React.createClass({
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
<input type="text" ref="username"
autoFocus={true}
placeholder={placeholderUsername} defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}
onBlur={this.onUsernameBlur} />
{this.renderUsername()}
</div>
<div className="mx_AuthBody_fieldRow">
<input type="password" ref="password"
className={this._classForField(FIELD_PASSWORD, 'mx_Login_field')}
onBlur={this.onPasswordBlur}
placeholder={_t("Password")} defaultValue={this.props.defaultPassword} />
<input type="password" ref="passwordConfirm"
placeholder={_t("Confirm")}
className={this._classForField(FIELD_PASSWORD_CONFIRM, 'mx_Login_field')}
onBlur={this.onPasswordConfirmBlur}
defaultValue={this.props.defaultPassword} />
{this.renderPassword()}
{this.renderPasswordConfirm()}
</div>
<div className="mx_AuthBody_fieldRow">
{ emailSection }
{ phoneSection }
{this.renderEmail()}
{this.renderPhoneNumber()}
</div>
{_t(
"Use an email address to recover your account. Other users " +
"can invite you to rooms using your contact details.",
)}
{_t("Use an email address to recover your account.") + " "}
{_t("Other users can invite you to rooms using your contact details.")}
{ registerButton }
</form>
</div>

View file

@ -20,6 +20,9 @@ import PropTypes from 'prop-types';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
/*
* A pure UI component which displays the HS and IS to use.
@ -27,82 +30,119 @@ import { _t } from '../../../languageHandler';
export default class ServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
// 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,
// The current configuration that the user is expecting to change.
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
// Called after the component calls onServerConfigChange
onAfterSubmit: PropTypes.func,
// Optional text for the submit button. If falsey, no button will be shown.
submitText: PropTypes.string,
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
};
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
customIsUrl: "",
delayTimeMs: 0,
}
};
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
isUrl: props.customIsUrl,
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl &&
newProps.customIsUrl === this.state.isUrl) return;
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
}
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.state.isUrl,
});
this.validateServer();
});
}
};
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,
});
this.validateServer();
});
}
};
onIdentityServerChange = (ev) => {
const isUrl = ev.target.value;
this.setState({ isUrl });
}
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
await this.validateServer();
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
@ -114,10 +154,24 @@ export default class ServerConfig extends React.PureComponent {
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
}
};
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
: null;
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
@ -127,22 +181,28 @@ export default class ServerConfig extends React.PureComponent {
{ 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>
{errorText}
<form onSubmit={this.onSubmit} autoComplete={false} action={null}>
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>
{submitButton}
</form>
</div>
);
}

View file

@ -19,6 +19,8 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import classnames from 'classnames';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import {makeType} from "../../../utils/TypeUtils";
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
@ -30,40 +32,47 @@ export const TYPES = {
FREE: {
id: FREE,
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/feather-icons/matrix-org-bw-logo.svg')} />,
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',
serverConfig: makeType(ValidatedServerConfig, {
hsUrl: "https://matrix.org",
hsName: "matrix.org",
hsNameIsDifferent: false,
isUrl: "https://vector.im",
identityEnabled: true,
}),
},
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/feather-icons/modular-bw-logo.svg')} />,
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>,
}),
identityServerUrl: "https://vector.im",
},
ADVANCED: {
id: ADVANCED,
label: () => _t('Advanced'),
logo: () => <div>
<img src={require('../../../../res/img/feather-icons/globe.svg')} />
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
{_t('Other')}
</div>,
description: () => _t('Find other public servers or use a custom server'),
},
};
function getDefaultType(defaultHsUrl) {
if (!defaultHsUrl) {
export function getTypeFromServerConfig(config) {
const {hsUrl} = config;
if (!hsUrl) {
return null;
} else if (defaultHsUrl === TYPES.FREE.hsUrl) {
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
return FREE;
} else if (new URL(defaultHsUrl).hostname.endsWith('.modular.im')) {
// TODO: Use a Riot config parameter to detect Modular-ness.
// https://github.com/vector-im/riot-web/issues/8253
} 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;
@ -72,26 +81,22 @@ function getDefaultType(defaultHsUrl) {
export default class ServerTypeSelector extends React.PureComponent {
static propTypes = {
// The default HS URL as another way to set the initially selected type.
defaultHsUrl: PropTypes.string,
// The default selected type.
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
}
};
constructor(props) {
super(props);
const {
defaultHsUrl,
onChange,
selected,
} = props;
const type = getDefaultType(defaultHsUrl);
this.state = {
selected: type,
selected,
};
if (onChange) {
onChange(type);
}
}
updateSelectedType(type) {
@ -110,7 +115,7 @@ export default class ServerTypeSelector extends React.PureComponent {
e.stopPropagation();
const type = e.currentTarget.dataset.id;
this.updateSelectedType(type);
}
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');

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

@ -138,40 +138,7 @@ module.exports = React.createClass({
}
},
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
*/
_getInitialLetter: function(name) {
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
render: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
const {
@ -181,20 +148,20 @@ module.exports = React.createClass({
} = this.props;
if (imageUrl === this.state.defaultImageUrl) {
const initialLetter = this._getInitialLetter(name);
const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
<span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}
>
{ initialLetter }
</EmojiText>
</span>
);
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
width={width} height={height} aria-hidden="true" />
);
if (onClick != null) {
return (

View file

@ -19,7 +19,7 @@ import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
import Modal from '../../../Modal';
import sdk from "../../../index";
import DMRoomMap from '../../../utils/DMRoomMap';
import Avatar from '../../../Avatar';
module.exports = React.createClass({
displayName: 'RoomAvatar',
@ -89,7 +89,6 @@ module.exports = React.createClass({
props.resizeMethod,
), // highest priority
this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props), // lowest priority
].filter(function(url) {
return (url != null && url != "");
});
@ -98,41 +97,14 @@ module.exports = React.createClass({
getRoomAvatarUrl: function(props) {
if (!props.room) return null;
return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
},
getOneToOneAvatar: function(props) {
const room = props.room;
if (!room) {
return null;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
}
return null;
},
onRoomAvatarClick: function() {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),

View file

@ -26,8 +26,8 @@ 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';
import { isContentActionable } from '../../../utils/EventUtils';
module.exports = React.createClass({
displayName: 'MessageContextMenu',
@ -90,9 +90,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 +108,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,14 +197,7 @@ module.exports = React.createClass({
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
});
this.closeMenu();
},
onReplyClick: function() {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
},
@ -206,7 +208,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;
@ -216,7 +219,6 @@ module.exports = React.createClass({
let unhidePreviewButton;
let externalURLButton;
let quoteButton;
let replyButton;
let collapseReplyThread;
// status is SENT before remote-echo, null after
@ -246,28 +248,19 @@ module.exports = React.createClass({
);
}
if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
if (isContentActionable(mxEvent)) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
}
}
}
@ -277,7 +270,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 +288,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 +313,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 +332,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 }
@ -344,9 +351,9 @@ module.exports = React.createClass({
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }
</div>
);
},

View file

@ -271,6 +271,27 @@ module.exports = React.createClass({
);
},
_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;
@ -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

@ -25,8 +25,6 @@ export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
// True when waiting for status change to complete.
waiting: false,
};
constructor(props, context) {
@ -45,7 +43,7 @@ export default class StatusMessageContextMenu extends React.Component {
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
componentWillUmount() {
componentWillUnmount() {
const { user } = this.props;
if (!user) {
return;

View file

@ -68,7 +68,7 @@ export default class TagTileContextMenu extends React.Component {
<hr className="mx_TagTileContextMenu_separator" />
<div className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick} >
<img className="mx_TagTileContextMenu_item_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ _t('Remove') }
{ _t('Hide') }
</div>
</div>;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,34 +16,116 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import LogoutDialog from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import SdkConfig from '../../../SdkConfig';
import { getHostingLink } from '../../../utils/HostingLink';
import MatrixClientPeg from '../../../MatrixClientPeg';
export class TopLeftMenu extends React.Component {
static propTypes = {
displayName: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
onFinished: PropTypes.func,
// Optional function to collect a reference to the container
// of this component directly.
containerRef: PropTypes.func,
};
constructor() {
super();
this.viewHomePage = this.viewHomePage.bind(this);
this.openSettings = this.openSettings.bind(this);
this.signIn = this.signIn.bind(this);
this.signOut = this.signOut.bind(this);
}
hasHomePage() {
const config = SdkConfig.get();
const pagesConfig = config.embeddedPages;
if (pagesConfig && pagesConfig.homeUrl) {
return true;
}
// This is a deprecated config option for the home page
// (despite the name, given we also now have a welcome
// page, which is not the same).
return !!config.welcomePageUrl;
}
render() {
return <div className="mx_TopLeftMenu">
<ul className="mx_TopLeftMenu_section">
<li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>{_t("Settings")}</li>
</ul>
<ul className="mx_TopLeftMenu_section">
<li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>{_t("Sign out")}</li>
const isGuest = MatrixClientPeg.get().isGuest();
const hostingSignupLink = getHostingLink('user-context-menu');
let hostingSignup = null;
if (hostingSignupLink) {
hostingSignup = <div className="mx_TopLeftMenu_upgradeLink">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener" tabIndex="0">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener" aria-hidden={true}>
<img src={require("../../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
}
let homePageItem = null;
if (this.hasHomePage()) {
homePageItem = <li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage} tabIndex={0}>
{_t("Home")}
</li>;
}
let signInOutItem;
if (isGuest) {
signInOutItem = <li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn} tabIndex={0}>
{_t("Sign in")}
</li>;
} else {
signInOutItem = <li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut} tabIndex={0}>
{_t("Sign out")}
</li>;
}
const settingsItem = <li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings} tabIndex={0}>
{_t("Settings")}
</li>;
return <div className="mx_TopLeftMenu mx_HiddenFocusable" tabIndex={0} ref={this.props.containerRef}>
<div className="mx_TopLeftMenu_section_noIcon" aria-readonly={true}>
<div>{this.props.displayName}</div>
<div className="mx_TopLeftMenu_greyedText" aria-hidden={true}>{this.props.userId}</div>
{hostingSignup}
</div>
<ul className="mx_TopLeftMenu_section_withIcon">
{homePageItem}
{settingsItem}
{signInOutItem}
</ul>
</div>;
}
viewHomePage() {
dis.dispatch({action: 'view_home_page'});
this.closeMenu();
}
openSettings() {
dis.dispatch({action: 'view_user_settings'});
this.closeMenu();
}
signIn() {
dis.dispatch({action: 'start_login'});
this.closeMenu();
}
signOut() {
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
this.closeMenu();

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector 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.
@ -566,7 +566,7 @@ module.exports = React.createClass({
rows="1"
id="textinput"
ref="textinput"
className="mx_ChatInviteDialog_input"
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
defaultValue={this.props.value}
@ -578,7 +578,7 @@ module.exports = React.createClass({
let addressSelector;
if (this.state.error) {
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
error = <div className="mx_ChatInviteDialog_error">
error = <div className="mx_AddressPickerDialog_error">
{ _t("You have entered an invalid address.") }
<br />
{ _t("Try using one of the following valid address types: %(validTypesList)s.", {
@ -586,9 +586,9 @@ module.exports = React.createClass({
}) }
</div>;
} else if (this.state.searchError) {
error = <div className="mx_ChatInviteDialog_error">{ this.state.searchError }</div>;
error = <div className="mx_AddressPickerDialog_error">{ this.state.searchError }</div>;
} else if (this.state.query.length > 0 && filteredSuggestedList.length === 0 && !this.state.busy) {
error = <div className="mx_ChatInviteDialog_error">{ _t("No results") }</div>;
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
@ -601,13 +601,13 @@ module.exports = React.createClass({
}
return (
<BaseDialog className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<div className="mx_ChatInviteDialog_label">
<div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
<div className="mx_Dialog_content">
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error }
{ addressSelector }
{ this.props.extraNode }

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.
@ -55,6 +55,11 @@ export default React.createClass({
// CSS class to apply to dialog div
className: PropTypes.string,
// if true, dialog container is 60% of the viewport width. Otherwise,
// the container will have no fixed size, allowing its contents to
// determine its size. Default: true.
fixedWidth: PropTypes.bool,
// Title for the dialog.
title: PropTypes.node.isRequired,
@ -72,6 +77,7 @@ export default React.createClass({
getDefaultProps: function() {
return {
hasCancel: true,
fixedWidth: true,
};
},
@ -113,7 +119,10 @@ export default React.createClass({
return (
<FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
className={classNames({
[this.props.className]: true,
'mx_Dialog_fixedWidth': this.props.fixedWidth,
})}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
@ -131,8 +140,8 @@ export default React.createClass({
{ this.props.title }
</div>
{ this.props.headerButton }
{ cancelButton }
</div>
{ cancelButton }
{ this.props.children }
</FocusTrap>
);

View file

@ -108,6 +108,7 @@ export default class BugReportDialog extends React.Component {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
@ -154,36 +155,29 @@ export default class BugReportDialog extends React.Component {
},
) }
</b></p>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_issueUrl"
className="mx_BugReportDialog_field_label"
>
{ _t("What GitHub issue are these logs for?") }
</label>
<input
id="mx_BugReportDialog_issueUrl"
type="text"
className="mx_BugReportDialog_field_input"
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/..."
/>
</div>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_notes_label"
className="mx_BugReportDialog_field_label"
>
{ _t("Notes:") }
</label>
<textarea
className="mx_BugReportDialog_field_input"
rows={5}
onChange={this._onTextChange}
value={this.state.text}
/>
</div>
<Field
id="mx_BugReportDialog_issueUrl"
type="text"
className="mx_BugReportDialog_field_input"
label={_t("GitHub issue")}
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/..."
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("Notes")}
rows={5}
onChange={this._onTextChange}
value={this.state.text}
placeholder={_t(
"If there is additional context that would help in " +
"analysing the issue, such as what you were doing at " +
"the time, room IDs, user IDs, etc., " +
"please include those things here.",
)}
/>
{progress}
{error}
</div>

View file

@ -51,7 +51,7 @@ export default class ChangelogDialog extends React.Component {
return (
<li key={commit.sha} className="mx_ChangelogDialog_li">
<a href={commit.html_url} target="_blank" rel="noopener">
{commit.commit.message}
{commit.commit.message.split('\n')[0]}
</a>
</li>
);

View file

@ -21,7 +21,7 @@ import sdk from '../../../index';
import Analytics from '../../../Analytics';
import MatrixClientPeg from '../../../MatrixClientPeg';
import * as Lifecycle from '../../../Lifecycle';
import Velocity from 'velocity-vector';
import Velocity from 'velocity-animate';
import { _t } from '../../../languageHandler';
export default class DeactivateAccountDialog extends React.Component {

View file

@ -22,7 +22,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
import SettingsStore from '../../../settings/SettingsStore';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
const MODE_LEGACY = 'legacy';
@ -48,7 +47,7 @@ export default class DeviceVerifyDialog extends React.Component {
this._showSasEvent = null;
this.state = {
phase: PHASE_START,
mode: SettingsStore.isFeatureEnabled("feature_sas") ? MODE_SAS : MODE_LEGACY,
mode: MODE_SAS,
sasVerified: false,
};
}
@ -61,6 +60,11 @@ export default class DeviceVerifyDialog extends React.Component {
}
_onSwitchToLegacyClick = () => {
if (this._verifier) {
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier.cancel('User cancel');
this._verifier = null;
}
this.setState({mode: MODE_LEGACY});
}
@ -185,11 +189,21 @@ export default class DeviceVerifyDialog extends React.Component {
_renderSasVerificationPhaseWaitAccept() {
const Spinner = sdk.getComponent("views.elements.Spinner");
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
return (
<div>
<Spinner />
<p>{_t("Waiting for partner to accept...")}</p>
<p>{_t(
"Nothing appearing? Not all clients support interactive verification yet. " +
"<button>Use legacy verification</button>.",
{}, {button: sub => <AccessibleButton element='span' className="mx_linkButton"
onClick={this._onSwitchToLegacyClick}
>
{sub}
</AccessibleButton>},
)}</p>
</div>
);
}
@ -241,7 +255,7 @@ export default class DeviceVerifyDialog extends React.Component {
"and ask them whether the key they see in their User Settings " +
"for this device matches the key below:") }
</p>
<div className="mx_UserSettings_cryptoSection">
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li><label>{ _t("Device name") }:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>{ _t("Device ID") }:</label> <span><code>{ this.props.device.deviceId }</code></span></li>

View file

@ -20,6 +20,7 @@ import sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Field from "../elements/Field";
class DevtoolsComponent extends React.Component {
static contextTypes = {
@ -56,14 +57,8 @@ class GenericEditor extends DevtoolsComponent {
}
textInput(id, label) {
return <div className="mx_DevTools_inputRow">
<div className="mx_DevTools_inputLabelCell">
<label htmlFor={id}>{ label }</label>
</div>
<div className="mx_DevTools_inputCell">
<input id={id} className="mx_TextInputDialog_input" onChange={this._onChange} value={this.state[id]} size="32" autoFocus={true} />
</div>
</div>;
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on"
value={this.state[id]} onChange={this._onChange} />;
}
}
@ -133,17 +128,15 @@ class SendCustomEvent extends GenericEditor {
return <div>
<div className="mx_DevTools_content">
{ this.textInput('eventType', _t('Event Type')) }
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
<div className="mx_DevTools_eventTypeStateKeyGroup">
{ this.textInput('eventType', _t('Event Type')) }
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
</div>
<br />
<div className="mx_DevTools_inputLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_DevTools_textarea" />
</div>
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -223,12 +216,8 @@ class SendAccountData extends GenericEditor {
{ this.textInput('eventType', _t('Event Type')) }
<br />
<div className="mx_DevTools_inputLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_DevTools_textarea" />
</div>
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -302,14 +291,12 @@ class FilteredList extends React.Component {
render() {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div>
<input size="64"
autoFocus={true}
onChange={this.onQuery}
value={this.props.query}
placeholder={_t('Filter results')}
<Field id="DevtoolsDialog_FilteredList_filter" label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
@ -566,11 +553,53 @@ class AccountDataExplorer extends DevtoolsComponent {
}
}
class ServersInRoomList extends DevtoolsComponent {
static getLabel() { return _t('View Servers in Room'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
const room = MatrixClientPeg.get().getRoom(this.context.roomId);
const servers = new Set();
room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
this.servers = Array.from(servers).map(s =>
<button key={s} className="mx_DevTools_ServersInRoomList_button">
{ s }
</button>);
this.state = {
query: '',
};
}
onQuery = (query) => {
this.setState({ query });
}
render() {
return <div>
<div className="mx_Dialog_content">
<FilteredList query={this.state.query} onChange={this.onQuery}>
{ this.servers }
</FilteredList>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onBack}>{ _t('Back') }</button>
</div>
</div>;
}
}
const Entries = [
SendCustomEvent,
RoomStateExplorer,
SendAccountData,
AccountDataExplorer,
ServersInRoomList,
];
export default class DevtoolsDialog extends React.Component {

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -37,9 +38,12 @@ export default class IncomingSasDialog extends React.Component {
this.state = {
phase: PHASE_START,
sasVerified: false,
opponentProfile: null,
opponentProfileError: null,
};
this.props.verifier.on('show_sas', this._onVerifierShowSas);
this.props.verifier.on('cancel', this._onVerifierCancel);
this._fetchOpponentProfile();
}
componentWillUnmount() {
@ -49,6 +53,21 @@ export default class IncomingSasDialog extends React.Component {
this.props.verifier.removeListener('show_sas', this._onVerifierShowSas);
}
async _fetchOpponentProfile() {
try {
const prof = await MatrixClientPeg.get().getProfileInfo(
this.props.verifier.userId,
);
this.setState({
opponentProfile: prof,
});
} catch (e) {
this.setState({
opponentProfileError: e,
});
}
}
_onFinished = () => {
this.props.onFinished(this.state.phase === PHASE_VERIFIED);
}
@ -93,10 +112,39 @@ export default class IncomingSasDialog extends React.Component {
_renderPhaseStart() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent("views.elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
let profile;
if (this.state.opponentProfile) {
profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={this.state.opponentProfile.displayname}
idName={this.props.verifier.userId}
url={MatrixClientPeg.get().mxcUrlToHttp(
this.state.opponentProfile.avatar_url,
Math.floor(48 * window.devicePixelRatio),
Math.floor(48 * window.devicePixelRatio),
'crop',
)}
width={48} height={48} resizeMethod='crop'
/>
<h2>{this.state.opponentProfile.displayname}</h2>
</div>;
} else if (this.state.opponentProfileError) {
profile = <div>
<BaseAvatar name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId}
width={48} height={48}
/>
<h2>{this.props.verifier.userId}</h2>
</div>;
} else {
profile = <Spinner />;
}
return (
<div>
<h2>{this.props.verifier.userId}</h2>
{profile}
<p>{_t(
"Verify this user to mark them as trusted. " +
"Trusting users gives you extra peace of mind when using " +

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
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';
export default React.createClass({
displayName: 'InfoDialog',
propTypes: {
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.string,
onFinished: PropTypes.func,
},
getDefaultProps: function() {
return {
title: '',
description: '',
};
},
onFinished: function() {
this.props.onFinished();
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
>
</DialogButtons>
</BaseDialog>
);
},
});

View file

@ -77,7 +77,7 @@ export default React.createClass({
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
<br />
<AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button"
className="mx_GeneralButton"
autoFocus="true"
>
{ _t("Dismiss") }

View file

@ -20,9 +20,12 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component {
defaultProps = {
onFinished: function() {},
}
constructor() {
super();
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
@ -30,13 +33,39 @@ export default class LogoutDialog extends React.Component {
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const shouldLoadBackupStatus = !MatrixClientPeg.get().getKeyBackupEnabled();
this.state = {
loading: shouldLoadBackupStatus,
backupInfo: null,
error: null,
};
if (shouldLoadBackupStatus) {
this._loadBackupStatus();
}
}
async _loadBackupStatus() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({
loading: false,
backupInfo,
});
} catch (e) {
console.log("Unable to fetch key backup status", e);
this.setState({
loading: false,
error: e,
});
}
}
_onSettingsLinkClick() {
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onExportE2eKeysClicked() {
@ -53,112 +82,102 @@ export default class LogoutDialog extends React.Component {
dis.dispatch({action: 'logout'});
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onSetRecoveryMethodClick() {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
);
if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
);
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onLogoutConfirm() {
dis.dispatch({action: 'logout'});
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
render() {
let description;
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
description = <div>
<p>{_t(
"When you log out, you'll lose your secure message history. To prevent " +
"this, set up a recovery method.",
)}</p>
<p>{_t(
"Alternatively, advanced users can also manually export encryption keys in " +
"<a>Settings</a> before logging out.", {},
{
a: sub => <a href='#/settings' onClick={this._onSettingsLinkClick}>{sub}</a>,
},
)}</p>
</div>;
} else {
description = <div>{_t(
"For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.",
)}</div>;
}
const description = <div>
<p>{_t(
"Encrypted messages are secured with end-to-end encryption. " +
"Only you and the recipient(s) have the keys to read these messages.",
)}</p>
<p>{_t("Back up your keys before signing out to avoid losing them.")}</p>
</div>;
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let dialogContent;
if (this.state.loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
dialogContent = <Spinner />;
} else {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("Warning!")}
contentId='mx_Dialog_content'
hasCancel={false}
onFinsihed={this._onFinished}
>
let setupButtonCaption;
if (this.state.backupInfo) {
setupButtonCaption = _t("Connect this device to Key Backup");
} else {
// if there's an error fetching the backup info, we'll just assume there's
// no backup for the purpose of the button caption
setupButtonCaption = _t("Start using Key Backup");
}
dialogContent = <div>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ description }
</div>
<DialogButtons primaryButton={_t('Set a Recovery Method')}
<DialogButtons primaryButton={setupButtonCaption}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
{_t("I understand, log out without")}
{_t("I don't want my encrypted messages")}
</button>
</DialogButtons>
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
// TODO: This is made up by me and would need to also mention verifying
// once you can restorew a backup by verifying a device
description={_t(
"When signing in again, you can access encrypted chat history by " +
"restoring your key backup. You'll need your recovery passphrase " +
"or, if you didn't set a recovery passphrase, your recovery key " +
"(that you downloaded).",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
/>);
<details>
<summary>{_t("Advanced")}</summary>
<p><button onClick={this._onExportE2eKeysClicked}>
{_t("Manually export keys")}
</button></p>
</details>
</div>;
}
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content'
hasCancel={true}
onFinished={this._onFinished}
>
{dialogContent}
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
description={description}
description={_t(
"Are you sure you want to sign out?",
)}
button={_t("Sign out")}
extraButtons={[
(<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>),
]}
onFinished={this._onFinished}
/>);
}

View file

@ -20,14 +20,12 @@ import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
"?q=is%3Aopen+is%3Aissue+label%3Aredesign+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new" +
"?assignees=&labels=redesign&template=redesign_issue.md&title=";
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new";
const description1 =
_t("Thanks for testing the Riot Redesign. " +
"If you run into any bugs or visual issues, " +
"please let us know on GitHub.");
_t("If you run into any bugs or have feedback you'd like to share, " +
"please let us know on GitHub.");
const description2 = _t("To help avoid duplicate issues, " +
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +

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