Merge remote-tracking branch 'origin/develop' into dbkr/group_userlist

This commit is contained in:
David Baker 2017-08-09 13:46:15 +01:00
commit c1546ceb59
28 changed files with 858 additions and 258 deletions

View file

@ -4,7 +4,7 @@ set -e
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 4
nvm use 6
set -x

View file

@ -93,7 +93,18 @@ module.exports = function (config) {
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'junit'],
reporters: ['logcapture', 'spec', 'junit', 'summary'],
specReporter: {
suppressErrorSummary: false, // do print error summary
suppressFailed: false, // do print information about failed tests
suppressPassed: false, // do print information about passed tests
showSpecTiming: true, // print the time elapsed for each spec
},
client: {
captureLogs: true,
},
// web server port
port: 9876,
@ -104,7 +115,10 @@ module.exports = function (config) {
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR ||
// config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
//
// This is strictly for logs that would be generated by the browser itself and we
// don't want to log about missing images, which are emitted on LOG_WARN.
logLevel: config.LOG_ERROR,
// enable / disable watching file and executing tests whenever any file
// changes

View file

@ -53,9 +53,9 @@
"classnames": "^2.1.2",
"commonmark": "^0.27.0",
"counterpart": "^0.18.0",
"draft-js": "^0.9.1",
"draft-js-export-html": "^0.5.0",
"draft-js-export-markdown": "^0.2.0",
"draft-js": "^0.11.0-alpha",
"draft-js-export-html": "^0.6.0",
"draft-js-export-markdown": "^0.3.0",
"emojione": "2.2.7",
"file-saver": "^1.3.3",
"filesize": "3.5.6",
@ -102,12 +102,15 @@
"eslint-plugin-react": "^6.9.0",
"expect": "^1.16.0",
"json-loader": "^0.5.3",
"karma": "^0.13.22",
"karma": "^1.7.0",
"karma-chrome-launcher": "^0.2.3",
"karma-cli": "^0.1.2",
"karma-junit-reporter": "^0.4.1",
"karma-logcapture-reporter": "0.0.1",
"karma-mocha": "^0.2.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "^0.0.31",
"karma-summary-reporter": "^1.3.3",
"karma-webpack": "^1.7.0",
"matrix-react-test-utils": "^0.1.1",
"mocha": "^2.4.5",

View file

@ -55,6 +55,25 @@ function is_multi_line(node) {
return par.firstChild != par.lastChild;
}
import linkifyMatrix from './linkify-matrix';
import * as linkify from 'linkifyjs';
linkifyMatrix(linkify);
// Thieved from draft-js-export-markdown
function escapeMarkdown(s) {
return s.replace(/[*_`]/g, '\\$&');
}
// Replace URLs, room aliases and user IDs with md-escaped URLs
function linkifyMarkdown(s) {
const links = linkify.find(s);
links.forEach((l) => {
// This may replace several instances of `l.value` at once, but that's OK
s = s.replace(l.value, escapeMarkdown(l.value));
});
return s;
}
/**
* Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether
@ -62,7 +81,7 @@ function is_multi_line(node) {
*/
export default class Markdown {
constructor(input) {
this.input = input;
this.input = linkifyMarkdown(input);
const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input);

View file

@ -51,7 +51,8 @@ export const contentStateToHTML = (contentState: ContentState) => {
};
export function htmlToContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
}
function unicodeToEmojiUri(str) {
@ -90,7 +91,7 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
@ -119,7 +120,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
export function getScopedMDDecorators(scope: any): CompositeDecorator {
let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
},
component: (props) => (
@ -130,7 +131,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
}));
markdownDecorators.push({
strategy: (contentBlock, callback) => {
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
@ -201,10 +202,8 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
for (let block of contentBlocks) {
let blockLength = block.getLength();
for (const block of contentBlocks) {
const blockLength = block.getLength();
if (start !== -1 && start < blockLength) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
@ -212,9 +211,8 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange,
});
start = -1;
} else {
start -= blockLength;
start -= blockLength + 1; // +1 to account for newline between blocks
}
if (end !== -1 && end <= blockLength) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
@ -222,10 +220,9 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange,
});
end = -1;
} else {
end -= blockLength;
end -= blockLength + 1; // +1 to account for newline between blocks
}
}
return selectionState;
}
@ -242,7 +239,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = Entity.get(existingEntityKey);
const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
@ -252,7 +249,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText });
newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText(
newContentState,
selection,

View file

@ -15,6 +15,8 @@ limitations under the License.
*/
var MatrixClientPeg = require('./MatrixClientPeg');
import UserSettingsStore from './UserSettingsStore';
import shouldHideEvent from './shouldHideEvent';
var sdk = require('./index');
module.exports = {
@ -63,6 +65,7 @@ module.exports = {
// we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess.
const syncedSettings = UserSettingsStore.getSyncedSettings();
// Loop through messages, starting with the most recent...
for (var i = room.timeline.length - 1; i >= 0; --i) {
var ev = room.timeline[i];
@ -72,7 +75,7 @@ module.exports = {
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
} else if (this.eventTriggersUnreadCount(ev)) {
} else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit
// the read marker, so this room is definitely unread.
return true;

58
src/WidgetUtils.js Normal file
View file

@ -0,0 +1,58 @@
/*
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 MatrixClientPeg from './MatrixClientPeg';
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
}

View file

@ -71,6 +71,15 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor
let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
@ -104,8 +113,20 @@ export default class EmojiProvider extends AutocompleteProvider {
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
// Reinstate original order
completions = _sortBy(_uniq(completions), '_orderBy');
const sorters = [];
// First, 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"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
sorters.push((c) => c.shortname.length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
completions = _sortBy(_uniq(completions), sorters);
completions = completions.map((result) => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname);

View file

@ -23,35 +23,58 @@ import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX);
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'roomId', 'aliases'],
keys: ['displayedAlias', 'name'],
});
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get();
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get();
let completions = [];
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(room => !!room && !!getDisplayAliasForRoom(room)).map(room => {
this.matcher.setObjects(client.getRooms().filter(
(room) => !!room && !!getDisplayAliasForRoom(room),
).map((room) => {
return {
room: room,
name: room.name,
aliases: room.getAliases(),
displayedAlias: getDisplayAliasForRoom(room),
};
}));
completions = this.matcher.match(command[0]).map(room => {
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
(c) => score(matchedString, c.displayedAlias),
(c) => c.displayedAlias.length,
]).map((room) => {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
@ -62,7 +85,9 @@ export default class RoomProvider extends AutocompleteProvider {
),
range,
};
}).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4);
})
.filter((completion) => !!completion.completion && completion.completion.length > 0)
.slice(0, 4);
}
return completions;
}

View file

@ -48,13 +48,21 @@ export default class UserProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
completions = this.matcher.match(command[0]).map((user) => {
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
return {
completion: displayName,
// Length of completion should equal length of text in decorator. draft-js
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ',
href: 'https://matrix.to/#/' + user.userId,
component: (

View file

@ -131,9 +131,6 @@ module.exports = React.createClass({
// the master view we are showing.
view: VIEWS.LOADING,
// a thing to call showScreen with once login completes.
screenAfterLogin: this.props.initialScreenAfterLogin,
// What the LoggedInView would be showing if visible
page_type: null,
@ -147,8 +144,6 @@ module.exports = React.createClass({
collapse_lhs: false,
collapse_rhs: false,
ready: false,
width: 10000,
leftOpacity: 1.0,
middleOpacity: 1.0,
rightOpacity: 1.0,
@ -274,6 +269,15 @@ module.exports = React.createClass({
register_hs_url: paramHs,
});
}
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
// rerender.
this._screenAfterLogin = this.props.initialScreenAfterLogin;
this._windowWidth = 10000;
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
componentDidMount: function() {
@ -294,9 +298,6 @@ module.exports = React.createClass({
linkifyMatrix.onGroupClick = this.onGroupClick;
}
window.addEventListener('resize', this.handleResize);
this.handleResize();
const teamServerConfig = this.props.config.teamServerConfig || {};
Lifecycle.initRtsClient(teamServerConfig.teamServerURL);
@ -312,13 +313,12 @@ module.exports = React.createClass({
// if the user has followed a login or register link, don't reanimate
// the old creds, but rather go straight to the relevant page
const firstScreen = this.state.screenAfterLogin ?
this.state.screenAfterLogin.screen : null;
const firstScreen = this._screenAfterLogin ?
this._screenAfterLogin.screen : null;
if (firstScreen === 'login' ||
firstScreen === 'register' ||
firstScreen === 'forgot_password') {
this.setState({loading: false});
this._showScreenAfterLogin();
return;
}
@ -367,9 +367,9 @@ module.exports = React.createClass({
}
const newState = {
viewUserId: null,
};
Object.assign(newState, state);
this.setState(newState);
};
Object.assign(newState, state);
this.setState(newState);
},
onAction: function(payload) {
@ -992,14 +992,12 @@ module.exports = React.createClass({
_showScreenAfterLogin: function() {
// If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory
if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) {
if (this._screenAfterLogin && this._screenAfterLogin.screen) {
this.showScreen(
this.state.screenAfterLogin.screen,
this.state.screenAfterLogin.params,
this._screenAfterLogin.screen,
this._screenAfterLogin.params,
);
// XXX: is this necessary? `showScreen` should do it for us.
this.notifyNewScreen(this.state.screenAfterLogin.screen);
this.setState({screenAfterLogin: null});
this._screenAfterLogin = null;
} else if (localStorage && localStorage.getItem('mx_last_room_id')) {
// Before defaulting to directory, show the last viewed room
dis.dispatch({
@ -1276,20 +1274,20 @@ module.exports = React.createClass({
const hideRhsThreshold = 820;
const showRhsThreshold = 820;
if (this.state.width > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (this.state.width <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
dis.dispatch({ action: 'show_left_panel' });
}
if (this.state.width > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) {
dis.dispatch({ action: 'hide_right_panel' });
}
if (this.state.width <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) {
dis.dispatch({ action: 'show_right_panel' });
}
this.setState({width: window.innerWidth});
this._windowWidth = window.innerWidth;
},
onRoomCreated: function(roomId) {

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var dis = require("../../dispatcher");
var sdk = require('../../index');
import React from 'react';
import ReactDOM from 'react-dom';
import UserSettingsStore from '../../UserSettingsStore';
import shouldHideEvent from '../../shouldHideEvent';
import dis from "../../dispatcher";
import sdk from '../../index';
var MatrixClientPeg = require('../../MatrixClientPeg');
import MatrixClientPeg from '../../MatrixClientPeg';
const MILLIS_IN_DAY = 86400000;
@ -90,9 +92,6 @@ module.exports = React.createClass({
// show timestamps always
alwaysShowTimestamps: React.PropTypes.bool,
// hide redacted events as per old behaviour
hideRedactions: React.PropTypes.bool,
},
componentWillMount: function() {
@ -113,6 +112,8 @@ module.exports = React.createClass({
// Velocity requires
this._readMarkerGhostNode = null;
this._syncedSettings = UserSettingsStore.getSyncedSettings();
this._isMounted = true;
},
@ -238,8 +239,20 @@ module.exports = React.createClass({
return !this._isMounted;
},
_getEventTiles: function() {
// TODO: Implement granular (per-room) hide options
_shouldShowEvent: function(mxEv) {
const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
}
// Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv, this._syncedSettings);
},
_getEventTiles: function() {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
@ -249,20 +262,21 @@ module.exports = React.createClass({
// first figure out which is the last event in the list which we're
// actually going to show; this allows us to behave slightly
// differently for the last event in the list.
// differently for the last event in the list. (eg show timestamp)
//
// we also need to figure out which is the last event we show which isn't
// a local echo, to manage the read-marker.
var lastShownEventIndex = -1;
let lastShownEvent;
var lastShownNonLocalEchoIndex = -1;
for (i = this.props.events.length-1; i >= 0; i--) {
var mxEv = this.props.events[i];
if (!EventTile.haveTileForEvent(mxEv)) {
if (!this._shouldShowEvent(mxEv)) {
continue;
}
if (lastShownEventIndex < 0) {
lastShownEventIndex = i;
if (lastShownEvent === undefined) {
lastShownEvent = mxEv;
}
if (mxEv.status) {
@ -288,25 +302,18 @@ module.exports = React.createClass({
this.currentGhostEventId = null;
}
var isMembershipChange = (e) => e.getType() === 'm.room.member';
const isMembershipChange = (e) => e.getType() === 'm.room.member';
for (i = 0; i < this.props.events.length; i++) {
let mxEv = this.props.events[i];
let wantTile = true;
let eventId = mxEv.getId();
let readMarkerInMels = false;
let last = (mxEv === lastShownEvent);
if (!EventTile.haveTileForEvent(mxEv)) {
wantTile = false;
}
let last = (i == lastShownEventIndex);
const wantTile = this._shouldShowEvent(mxEv);
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) &&
EventTile.haveTileForEvent(mxEv) &&
!mxEv.isRedacted()
) {
if (isMembershipChange(mxEv) && wantTile) {
let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
@ -325,35 +332,36 @@ module.exports = React.createClass({
let summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) {
let collapsedMxEv = this.props.events[i + 1];
// Ignore redacted member events
if (!EventTile.haveTileForEvent(collapsedMxEv)) {
continue;
}
const collapsedMxEv = this.props.events[i + 1];
if (!isMembershipChange(collapsedMxEv) ||
this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) {
break;
}
// If RM event is in MELS mark it as such and the RM will be appended after MELS.
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
continue;
}
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map(
(e) => {
if (e.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
// In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted.
let ret = this._getTilesForEvent(e, e);
prevEvent = e;
return ret;
}
).reduce((a, b) => a.concat(b));
// At this point, i = the index of the last event in the summary sequence
let eventTiles = summarisedEvents.map((e) => {
// In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted.
const ret = this._getTilesForEvent(e, e, e === lastShownEvent);
prevEvent = e;
return ret;
}).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) {
eventTiles = null;
@ -466,8 +474,6 @@ module.exports = React.createClass({
continuation = false;
}
if (mxEv.isRedacted() && this.props.hideRedactions) return ret;
var eventId = mxEv.getId();
var highlight = (eventId == this.props.highlightedEventId);

View file

@ -181,9 +181,6 @@ var TimelinePanel = React.createClass({
// always show timestamps on event tiles?
alwaysShowTimestamps: syncedSettings.alwaysShowTimestamps,
// hide redacted events as per old behaviour
hideRedactions: syncedSettings.hideRedactions,
};
},
@ -1122,7 +1119,6 @@ var TimelinePanel = React.createClass({
return (
<MessagePanel ref="messagePanel"
hidden={ this.props.hidden }
hideRedactions={ this.state.hideRedactions }
backPaginating={ this.state.backPaginating }
forwardPaginating={ forwardPaginating }
events={ this.state.events }

View file

@ -81,6 +81,10 @@ const SETTINGS_LABELS = [
id: 'showTwelveHourTimestamps',
label: 'Show timestamps in 12 hour format (e.g. 2:30pm)',
},
{
id: 'hideJoinLeaves',
label: 'Hide join/leave messages (invites/kicks/bans unaffected)',
},
{
id: 'useCompactLayout',
label: 'Use compact timeline layout',
@ -97,6 +101,10 @@ const SETTINGS_LABELS = [
id: 'MessageComposerInput.autoReplaceEmoji',
label: 'Automatically replace plain text Emoji',
},
{
id: 'Pill.shouldHidePillAvatar',
label: 'Hide avatars in user and room mentions',
},
/*
{
id: 'useFixedWidthFont',
@ -1136,7 +1144,7 @@ module.exports = React.createClass({
const threepidsSection = this.state.threepids.map((val, pidIndex) => {
const id = "3pid-" + val.address;
// TODO; make a separate component to avoid having to rebind onClick
// TODO: make a separate component to avoid having to rebind onClick
// each time we render
const onRemoveClick = (e) => this.onRemoveThreepidClicked(val);
return (

View file

@ -0,0 +1,75 @@
import React from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import { _t } from '../../../languageHandler';
export default class AppPermission extends React.Component {
constructor(props) {
super(props);
const curlBase = this.getCurlBase();
this.state = { curlBase: curlBase};
}
// Return string representation of content URL without query parameters
getCurlBase() {
const wurl = url.parse(this.props.url);
let curl;
let curlString;
const searchParams = new URLSearchParams(wurl.search);
if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) {
curl = url.parse(searchParams.get('url'));
if(curl) {
curl.search = curl.query = "";
curlString = curl.format();
}
}
if (!curl && wurl) {
wurl.search = wurl.query = "";
curlString = wurl.format();
}
return curlString;
}
isScalarWurl(wurl) {
if(wurl && wurl.hostname && (
wurl.hostname === 'scalar.vector.im' ||
wurl.hostname === 'scalar-staging.riot.im' ||
wurl.hostname === 'scalar-develop.riot.im' ||
wurl.hostname === 'demo.riot.im' ||
wurl.hostname === 'localhost'
)) {
return true;
}
return false;
}
render() {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>Do you want to load widget from URL:</span> <span className='mx_AppPermissionWarningTextURL'>{this.state.curlBase}</span>
</div>
<input
className='mx_AppPermissionButton'
type='button'
value={_t('Allow')}
onClick={this.props.onPermissionGranted}
/>
</div>
);
}
}
AppPermission.propTypes = {
url: PropTypes.string.isRequired,
onPermissionGranted: PropTypes.func.isRequired,
};
AppPermission.defaultProps = {
onPermissionGranted: function() {},
};

View file

@ -24,6 +24,10 @@ import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import WidgetUtils from '../../../WidgetUtils';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const betaHelpMsg = 'This feature is currently experimental and is intended for beta testing only';
@ -37,6 +41,9 @@ export default React.createClass({
name: React.PropTypes.string.isRequired,
room: React.PropTypes.object.isRequired,
type: React.PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: React.PropTypes.bool,
},
getDefaultProps: function() {
@ -46,9 +53,13 @@ export default React.createClass({
},
getInitialState: function() {
const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return {
loading: false,
widgetUrl: this.props.url,
widgetPermissionId: widgetPermissionId,
hasPermissionToLoad: Boolean(hasPermissionToLoad === 'true'),
error: null,
deleting: false,
};
@ -60,6 +71,18 @@ export default React.createClass({
return scalarUrl && this.props.url.startsWith(scalarUrl);
},
isMixedContent: function() {
const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.url);
const childContentProtocol = u.protocol;
if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') {
console.warn("Refusing to load mixed-content app:",
parentContentProtocol, childContentProtocol, window.location, this.props.url);
return true;
}
return false;
},
componentWillMount: function() {
if (!this.isScalarUrl()) {
return;
@ -71,6 +94,7 @@ export default React.createClass({
this._scalarClient = new ScalarAuthClient();
this._scalarClient.getScalarToken().done((token) => {
// Append scalar_token as a query param
this._scalarClient.scalarToken = token;
const u = url.parse(this.props.url);
if (!u.search) {
u.search = "?scalar_token=" + encodeURIComponent(token);
@ -91,6 +115,10 @@ export default React.createClass({
});
},
_canUserModify: function() {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
},
_onEditClick: function(e) {
console.log("Edit widget ID ", this.props.id);
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
@ -100,20 +128,49 @@ export default React.createClass({
}, "mx_IntegrationsManager");
},
/* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser
*/
_onDeleteClick: function() {
console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
if (this._canUserModify()) {
console.log("Delete widget %s", this.props.id);
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).then(() => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e);
this.setState({deleting: false});
});
} else {
console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission();
}
},
// Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method
_deleteWidgetLabel() {
if (this._canUserModify()) {
return 'Delete widget';
}
return 'Revoke widget access';
},
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true});
},
_revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false});
},
formatAppTileName: function() {
@ -133,34 +190,66 @@ export default React.createClass({
return <div></div>;
}
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
if (this.state.loading) {
appTileBody = (
<div> Loading... </div>
<div className='mx_AppTileBody mx_AppLoading'>
<MessageSpinner msg='Loading...'/>
</div>
);
} else {
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
// because that would allow the iframe to prgramatically remove the sandbox attribute, but
// this would only be for content hosted on the same origin as the riot client: anything
// hosted on the same origin as the client will get the same access as if you clicked
// a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
<div className="mx_AppTileBody">
<AppWarning
errorMsg="Error - Mixed content"
/>
</div>
);
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe
ref="appFrame"
src={safeWidgetUrl}
allowFullScreen="true"
sandbox={sandboxFlags}
></iframe>
</div>
);
}
} else {
appTileBody = (
<div className="mx_AppTileBody">
<iframe ref="appFrame" src={safeWidgetUrl} allowFullScreen="true"
sandbox={sandboxFlags}
></iframe>
<AppPermission
url={this.state.widgetUrl}
onPermissionGranted={this._grantWidgetPermission}
/>
</div>
);
}
// editing is done in scalar
const showEditButton = Boolean(this._scalarClient);
const showEditButton = Boolean(this._scalarClient && this._canUserModify());
const deleteWidgetLabel = this._deleteWidgetLabel();
let deleteIcon = 'img/cancel.svg';
let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget';
if(this._canUserModify()) {
deleteIcon = 'img/cancel-red.svg';
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
}
return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
@ -172,14 +261,18 @@ export default React.createClass({
{showEditButton && <img
src="img/edit.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8" alt="Edit"
width="8" height="8"
alt={_t('Edit')}
title={_t('Edit')}
onClick={this._onEditClick}
/>}
{/* Delete widget */}
<img src="img/cancel.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget"
width="8" height="8" alt={_t("Cancel")}
<img src={deleteIcon}
className={deleteClasses}
width="8" height="8"
alt={_t(deleteWidgetLabel)}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
/>
</span>

View file

@ -0,0 +1,25 @@
import React from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const AppWarning = (props) => {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src='img/warning.svg' alt={_t('Warning!')}/>
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{props.errorMsg}</span>
</div>
</div>
);
};
AppWarning.propTypes = {
errorMsg: PropTypes.string,
};
AppWarning.defaultProps = {
errorMsg: 'Error',
};
export default AppWarning;

View file

@ -0,0 +1,34 @@
/*
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';
module.exports = React.createClass({
displayName: 'MessageSpinner',
render: function() {
const w = this.props.w || 32;
const h = this.props.h || 32;
const imgClass = this.props.imgClassName || "";
const msg = this.props.msg || "Loading...";
return (
<div className="mx_Spinner">
<div className="mx_Spinner_Msg">{msg}</div>&nbsp;
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
</div>
);
},
});

View file

@ -25,8 +25,8 @@ import { getDisplayAliasForRoom } from '../../../Rooms';
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+]).*)$/;
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+])[^\/]*)$/;
const Pill = React.createClass({
statics: {
@ -47,6 +47,8 @@ const Pill = React.createClass({
inMessage: PropTypes.bool,
// The room in which this pill is being rendered
room: PropTypes.instanceOf(Room),
// Whether to include an avatar in the pill
shouldShowPillAvatar: PropTypes.bool,
},
getInitialState() {
@ -63,16 +65,15 @@ const Pill = React.createClass({
};
},
componentWillMount() {
this._unmounted = false;
componentWillReceiveProps(nextProps) {
let regex = REGEX_MATRIXTO;
if (this.props.inMessage) {
if (nextProps.inMessage) {
regex = REGEX_LOCAL_MATRIXTO;
}
// Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing
const matrixToMatch = regex.exec(this.props.url) || [];
const matrixToMatch = regex.exec(nextProps.url) || [];
const resourceId = matrixToMatch[1]; // The room/user ID
const prefix = matrixToMatch[2]; // The first character of prefix
@ -87,7 +88,7 @@ const Pill = React.createClass({
let room;
switch (pillType) {
case Pill.TYPE_USER_MENTION: {
const localMember = this.props.room.getMember(resourceId);
const localMember = nextProps.room.getMember(resourceId);
member = localMember;
if (!localMember) {
member = new RoomMember(null, resourceId);
@ -112,6 +113,11 @@ const Pill = React.createClass({
this.setState({resourceId, pillType, member, room});
},
componentWillMount() {
this._unmounted = false;
this.componentWillReceiveProps(this.props);
},
componentWillUnmount() {
this._unmounted = true;
},
@ -150,8 +156,10 @@ const Pill = React.createClass({
const member = this.state.member;
if (member) {
userId = member.userId;
linkText = member.name;
avatar = <MemberAvatar member={member} width={16} height={16}/>;
linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done
if (this.props.shouldShowPillAvatar) {
avatar = <MemberAvatar member={member} width={16} height={16}/>;
}
pillClass = 'mx_UserPill';
}
}
@ -160,7 +168,9 @@ const Pill = React.createClass({
const room = this.state.room;
if (room) {
linkText = (room ? getDisplayAliasForRoom(room) : null) || resource;
avatar = <RoomAvatar room={room} width={16} height={16}/>;
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16}/>;
}
pillClass = 'mx_RoomPill';
}
}

View file

@ -170,6 +170,7 @@ module.exports = React.createClass({
},
pillifyLinks: function(nodes) {
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href")) {
@ -181,7 +182,12 @@ module.exports = React.createClass({
const pillContainer = document.createElement('span');
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pill = <Pill url={href} inMessage={true} room={room}/>;
const pill = <Pill
url={href}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
ReactDOM.render(pill, pillContainer);
node.parentNode.replaceChild(pillContainer, node);

View file

@ -26,6 +26,7 @@ import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../WidgetUtils';
module.exports = React.createClass({
@ -147,6 +148,15 @@ module.exports = React.createClass({
});
},
_canUserModify: function() {
try {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
} catch(err) {
console.error(err);
return false;
}
},
onClickAddWidget: function(e) {
if (e) {
e.preventDefault();
@ -164,7 +174,7 @@ module.exports = React.createClass({
render: function() {
const apps = this.state.apps.map(
(app, index, arr) => {
return <AppTile
return (<AppTile
key={app.id}
id={app.id}
url={app.url}
@ -173,10 +183,10 @@ module.exports = React.createClass({
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
/>;
/>);
});
const addWidget = this.state.apps && this.state.apps.length < 2 &&
const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() &&
(<div onClick={this.onClickAddWidget}
role="button"
tabIndex="0"

View file

@ -172,7 +172,7 @@ export default class Autocomplete extends React.Component {
}
hide() {
this.setState({hide: true, selectionOffset: 0});
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
}
forceComplete() {

View file

@ -155,7 +155,9 @@ module.exports = withMatrixClient(React.createClass({
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.mxEvent !== this.props.mxEvent) {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
this._verifyEvent(nextProps.mxEvent);
}
},
@ -386,6 +388,36 @@ module.exports = withMatrixClient(React.createClass({
});
},
_renderE2EPadlock: function() {
const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClicked};
if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable {...props}/>;
} else if (ev.isEncrypted()) {
if (this.state.verified) {
return <E2ePadlockVerified {...props}/>;
} else {
return <E2ePadlockUnverified {...props}/>;
}
} else {
// XXX: if the event is being encrypted (ie eventSendStatus ===
// encrypting), it might be nice to show something other than the
// open padlock?
// if the event is not encrypted, but it's an e2e room, show the
// open padlock
const e2eEnabled = this.props.matrixClient.isRoomEncrypted(ev.getRoomId());
if (e2eEnabled) {
return <E2ePadlockUnencrypted {...props}/>;
}
}
// no padlock needed
return null;
},
render: function() {
var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
var SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -407,7 +439,6 @@ module.exports = withMatrixClient(React.createClass({
throw new Error("Event type not supported");
}
var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId());
var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
@ -485,26 +516,7 @@ module.exports = withMatrixClient(React.createClass({
const editButton = (
<span className="mx_EventTile_editButton" title={ _t("Options") } onClick={this.onEditClicked} />
);
let e2e;
// cosmetic padlocks:
if ((e2eEnabled && this.props.eventSendStatus) || this.props.mxEvent.getType() === 'm.room.encryption') {
e2e = <img style={{ cursor: 'initial', marginLeft: '-1px' }} className="mx_EventTile_e2eIcon" alt={_t("Encrypted by a verified device")} src="img/e2e-verified.svg" width="10" height="12" />;
}
// real padlocks
else if (this.props.mxEvent.isEncrypted() || (e2eEnabled && this.props.eventSendStatus)) {
if (this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted') {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Undecryptable")} src="img/e2e-blocked.svg" width="12" height="12" style={{ marginLeft: "-1px" }} />;
}
else if (this.state.verified == true || (e2eEnabled && this.props.eventSendStatus)) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Encrypted by a verified device")} src="img/e2e-verified.svg" width="10" height="12"/>;
}
else {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Encrypted by an unverified device")} src="img/e2e-warning.svg" width="15" height="12" style={{ marginLeft: "-2px" }}/>;
}
}
else if (e2eEnabled) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" alt={_t("Unencrypted message")} src="img/e2e-unencrypted.svg" width="12" height="12"/>;
}
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -572,7 +584,7 @@ module.exports = withMatrixClient(React.createClass({
<a href={ permalink } onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ e2e }
{ this._renderE2EPadlock() }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
@ -597,3 +609,39 @@ module.exports.haveTileForEvent = function(e) {
return true;
}
};
function E2ePadlockUndecryptable(props) {
return (
<E2ePadlock alt={_t("Undecryptable")}
src="img/e2e-blocked.svg" width="12" height="12"
style={{ marginLeft: "-1px" }} {...props} />
);
}
function E2ePadlockVerified(props) {
return (
<E2ePadlock alt={_t("Encrypted by a verified device")}
src="img/e2e-verified.svg" width="10" height="12"
{...props} />
);
}
function E2ePadlockUnverified(props) {
return (
<E2ePadlock alt={_t("Encrypted by an unverified device")}
src="img/e2e-warning.svg" width="15" height="12"
style={{ marginLeft: "-2px" }} {...props} />
);
}
function E2ePadlockUnencrypted(props) {
return (
<E2ePadlock alt={_t("Unencrypted message")}
src="img/e2e-unencrypted.svg" width="12" height="12"
{...props} />
);
}
function E2ePadlock(props) {
return <img className="mx_EventTile_e2eIcon" {...props} />;
}

View file

@ -50,7 +50,7 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g')
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(' + asciiRegexp + ')\\s$');
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -97,20 +97,39 @@ export default class MessageComposerInput extends React.Component {
onInputStateChanged: React.PropTypes.func,
};
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode';
static getKeyBinding(ev: SyntheticKeyboardEvent): string {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
// Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I
if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) {
// When null is returned, draft-js will NOT preventDefault, allowing dev tools
// to be toggled when the editor is focussed
return null;
// Restrict a subset of key bindings to ONLY having ctrl/meta* pressed and
// importantly NOT having alt, shift, meta/ctrl* pressed. draft-js does not
// handle this in `getDefaultKeyBinding` so we do it ourselves here.
//
// * if macOS, read second option
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underline',
[KeyCode.KEY_J]: 'code',
[KeyCode.KEY_O]: 'split-block',
}[ev.keyCode];
if (ctrlCmdCommand) {
if (!ctrlCmdOnly) {
return null;
}
return ctrlCmdCommand;
}
return getDefaultKeyBinding(e);
// Handle keys such as return, left and right arrows etc.
return getDefaultKeyBinding(ev);
}
static getBlockStyle(block: ContentBlock): ?string {
@ -165,17 +184,18 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get();
}
findLinkEntities(contentBlock, callback) {
findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) {
contentBlock.findEntityRanges(
(character) => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
Entity.get(entityKey).getType() === 'LINK'
contentState.getEntity(entityKey).getType() === 'LINK'
);
}, callback,
);
}
/*
* "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled
@ -184,13 +204,19 @@ export default class MessageComposerInput extends React.Component {
createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
const decorators = richText ? RichText.getScopedRTDecorators(this.props) :
RichText.getScopedMDDecorators(this.props);
const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false);
decorators.push({
strategy: this.findLinkEntities.bind(this),
component: (entityProps) => {
const Pill = sdk.getComponent('elements.Pill');
const {url} = Entity.get(entityProps.entityKey).getData();
const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData();
if (Pill.isPillUrl(url)) {
return <Pill url={url} room={this.props.room} offsetKey={entityProps.offsetKey}/>;
return <Pill
url={url}
room={this.props.room}
offsetKey={entityProps.offsetKey}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
}
return (
@ -243,7 +269,8 @@ export default class MessageComposerInput extends React.Component {
// paths for inserting a user pill is not fun
const selection = this.state.editorState.getSelection();
const member = this.props.room.getMember(payload.user_id);
const completion = member ? member.name.replace(' (IRC)', '') : payload.user_id;
const completion = member ?
member.rawDisplayName.replace(' (IRC)', '') : payload.user_id;
this.setDisplayedCompletion({
completion,
selection,
@ -253,10 +280,13 @@ export default class MessageComposerInput extends React.Component {
}
break;
case 'quote': {
let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.htmlToContentState(`<blockquote>${formatted_body}</blockquote>`);
let {body} = payload.event.getContent();
/// XXX: Not doing rich-text quoting from formatted-body because draft-js
/// has regressed such that when links are quoted, errors are thrown. See
/// https://github.com/vector-im/riot-web/issues/4756.
body = escape(body);
if (body) {
let content = RichText.htmlToContentState(`<blockquote>${body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(RichText.stateToMarkdown(content));
}
@ -393,7 +423,7 @@ export default class MessageComposerInput extends React.Component {
const newContentState = Modifier.replaceText(
editorState.getCurrentContent(),
currentSelection.merge({
anchorOffset: currentStartOffset - emojiMatch[0].length,
anchorOffset: currentStartOffset - emojiMatch[1].length - 1,
focusOffset: currentStartOffset,
}),
unicodeEmoji,
@ -451,14 +481,20 @@ export default class MessageComposerInput extends React.Component {
callback();
}
const textContent = this.state.editorState.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(
this.state.editorState.getSelection(),
this.state.editorState.getCurrentContent().getBlocksAsArray());
if (this.props.onContentChanged) {
const textContent = this.state.editorState
.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(
this.state.editorState.getSelection(),
this.state.editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection);
}
// Scroll to the bottom of the editor if the cursor is on the last line of the
// composer. For some reason the editor won't scroll automatically if we paste
// blocks of text in or insert newlines.
if (textContent.slice(selection.start).indexOf("\n") === -1) {
this.refs.editor.refs.editor.scrollTop = this.refs.editor.refs.editor.scrollHeight;
}
});
}
@ -496,14 +532,21 @@ export default class MessageComposerInput extends React.Component {
// These are block types, not handled by RichUtils by default.
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState);
const shouldToggleBlockFormat = (
command === 'backspace' ||
command === 'split-block'
) && currentBlockType !== 'unstyled';
if (blockCommands.includes(command)) {
newState = RichUtils.toggleBlockType(this.state.editorState, command);
} else if (command === 'strike') {
// this is the only inline style not handled by Draft by default
newState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH');
} else if (command === 'backspace' && currentBlockType !== 'unstyled') {
} else if (shouldToggleBlockFormat) {
const currentStartOffset = this.state.editorState.getSelection().getStartOffset();
if (currentStartOffset === 0) {
const currentEndOffset = this.state.editorState.getSelection().getEndOffset();
if (currentStartOffset === 0 && currentEndOffset === 0) {
// Toggle current block type (setting it to 'unstyled')
newState = RichUtils.toggleBlockType(this.state.editorState, currentBlockType);
}
@ -638,7 +681,17 @@ export default class MessageComposerInput extends React.Component {
let contentText = contentState.getPlainText(), contentHTML;
const cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
// Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour.
// We have to do this now as opposed to after calculating the contentText for MD
// mode because entity positions may not be maintained when using
// md.toPlaintext().
// Unfortunately this means we lose mentions in history when in MD mode. This
// would be fixed if history was stored as contentState.
contentText = this.removeMDLinks(contentState, ['@']);
// Some commands (/join) require pills to be replaced with their text content
const commandText = this.removeMDLinks(contentState, ['#']);
const cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
if (cmd) {
if (!cmd.error) {
this.setState({
@ -691,7 +744,7 @@ export default class MessageComposerInput extends React.Component {
const hasLink = blocks.some((block) => {
return block.getCharacterList().filter((c) => {
const entityKey = c.getEntity();
return entityKey && Entity.get(entityKey).getType() === 'LINK';
return entityKey && contentState.getEntity(entityKey).getType() === 'LINK';
}).size > 0;
});
shouldSendHTML = hasLink;
@ -702,7 +755,31 @@ export default class MessageComposerInput extends React.Component {
);
}
} else {
const md = new Markdown(contentText);
// Use the original contentState because `contentText` has had mentions
// stripped and these need to end up in contentHTML.
// Replace all Entities of type `LINK` with markdown link equivalents.
// TODO: move this into `Markdown` and do the same conversion in the other
// two places (toggling from MD->RT mode and loading MD history into RT mode)
// but this can only be done when history includes Entities.
const pt = contentState.getBlocksAsArray().map((block) => {
let blockText = block.getText();
let offset = 0;
this.findLinkEntities(contentState, block, (start, end) => {
const entity = contentState.getEntity(block.getEntityAt(start));
if (entity.getType() !== 'LINK') {
return;
}
const text = blockText.slice(offset + start, offset + end);
const url = entity.getData().url;
const mdLink = `[${text}](${url})`;
blockText = blockText.slice(0, offset + start) + mdLink + blockText.slice(offset + end);
offset += mdLink.length - text.length;
});
return blockText;
}).join('\n');
const md = new Markdown(pt);
if (md.isPlainText()) {
contentText = md.toPlaintext();
} else {
@ -731,35 +808,6 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage;
}
// Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour
contentText = contentText.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL,
(markdownLink, text, resource, prefix, offset) => {
// Calculate the offset relative to the current block that the offset is in
let sum = 0;
const blocks = contentState.getBlocksAsArray();
let block;
for (let i = 0; i < blocks.length; i++) {
block = blocks[i];
sum += block.getLength();
if (sum > offset) {
sum -= block.getLength();
break;
}
}
offset -= sum;
const entityKey = block.getEntityAt(offset);
const entity = entityKey ? Entity.get(entityKey) : null;
if (entity && entity.getData().isCompletion && prefix === '@') {
// This is a completed mention, so do not insert MD link, just text
return text;
} else {
// This is either a MD link that was typed into the composer or another
// type of pill (e.g. room pill)
return markdownLink;
}
});
let sendMessagePromise;
if (contentHTML) {
sendMessagePromise = sendHtmlFn.call(
@ -919,35 +967,27 @@ export default class MessageComposerInput extends React.Component {
}
const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion;
let contentState = activeEditorState.getCurrentContent();
let entityKey;
let mdCompletion;
if (href) {
entityKey = Entity.create('LINK', 'IMMUTABLE', {
contentState = contentState.createEntity('LINK', 'IMMUTABLE', {
url: href,
isCompletion: true,
});
if (!this.state.isRichtextEnabled) {
mdCompletion = `[${completion}](${href})`;
}
entityKey = contentState.getLastCreatedEntityKey();
}
let selection;
if (range) {
selection = RichText.textOffsetsToSelectionState(
range, activeEditorState.getCurrentContent().getBlocksAsArray(),
range, contentState.getBlocksAsArray(),
);
} else {
selection = activeEditorState.getSelection();
}
let contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(),
selection,
mdCompletion || completion,
null,
entityKey,
);
contentState = Modifier.replaceText(contentState, selection, completion, null, entityKey);
// Move the selection to the end of the block
const afterSelection = contentState.getSelectionAfter();
@ -1007,6 +1047,44 @@ export default class MessageComposerInput extends React.Component {
};
}
getAutocompleteQuery(contentState: ContentState) {
// Don't send markdown links to the autocompleter
return this.removeMDLinks(contentState, ['@', '#']);
}
removeMDLinks(contentState: ContentState, prefixes: string[]) {
const plaintext = contentState.getPlainText();
if (!plaintext) return '';
return plaintext.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL,
(markdownLink, text, resource, prefix, offset) => {
if (!prefixes.includes(prefix)) return markdownLink;
// Calculate the offset relative to the current block that the offset is in
let sum = 0;
const blocks = contentState.getBlocksAsArray();
let block;
for (let i = 0; i < blocks.length; i++) {
block = blocks[i];
sum += block.getLength();
if (sum > offset) {
sum -= block.getLength();
break;
}
}
offset -= sum;
const entityKey = block.getEntityAt(offset);
const entity = entityKey ? contentState.getEntity(entityKey) : null;
if (entity && entity.getData().isCompletion) {
// This is a completed mention, so do not insert MD link, just text
return text;
} else {
// This is either a MD link that was typed into the composer or another
// type of pill (e.g. room pill)
return markdownLink;
}
});
}
onMarkdownToggleClicked = (e) => {
e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode');
@ -1032,7 +1110,6 @@ export default class MessageComposerInput extends React.Component {
});
const content = activeEditorState.getCurrentContent();
const contentText = content.getPlainText();
const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(),
activeEditorState.getCurrentContent().getBlocksAsArray());
@ -1042,7 +1119,7 @@ export default class MessageComposerInput extends React.Component {
<Autocomplete
ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion}
query={contentText}
query={this.getAutocompleteQuery(content)}
selection={selection}/>
</div>
<div className={className}>

View file

@ -134,6 +134,7 @@
"Add phone number": "Add phone number",
"Admin": "Admin",
"Admin tools": "Admin tools",
"Allow": "Allow",
"And %(count)s more...": "And %(count)s more...",
"VoIP": "VoIP",
"Missing Media Permissions, click here to request.": "Missing Media Permissions, click here to request.",
@ -241,6 +242,7 @@
"Decrypt %(text)s": "Decrypt %(text)s",
"Decryption error": "Decryption error",
"Delete": "Delete",
"Delete widget": "Delete widget",
"demote": "demote",
"Deops user with given id": "Deops user with given id",
"Default": "Default",
@ -267,6 +269,7 @@
"Drop here %(toAction)s": "Drop here %(toAction)s",
"Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"Edit": "Edit",
"Email": "Email",
"Email address": "Email address",
"Email address (optional)": "Email address (optional)",
@ -342,6 +345,7 @@
"had": "had",
"Hangup": "Hangup",
"Hide Apps": "Hide Apps",
"Hide join/leave messages (invites/kicks/bans unaffected)": "Hide join/leave messages (invites/kicks/bans unaffected)",
"Hide read receipts": "Hide read receipts",
"Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar",
"Historical": "Historical",
@ -459,6 +463,7 @@
"Reason": "Reason",
"Reason: %(reasonText)s": "Reason: %(reasonText)s",
"Revoke Moderator": "Revoke Moderator",
"Revoke widget access": "Revoke widget access",
"Refer a friend to Riot:": "Refer a friend to Riot:",
"Register": "Register",
"rejected": "rejected",
@ -962,6 +967,7 @@
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
"Failed to upload image": "Failed to upload image",
"Failed to update group": "Failed to update group",
"Hide avatars in user and room mentions": "Hide avatars in user and room mentions",
"Description": "Description",
"Filter group members": "Filter group members",
"Remove from group": "Remove from group"

View file

@ -233,6 +233,7 @@
"demote": "demote",
"Deops user with given id": "Deops user with given id",
"Default": "Default",
"Delete widget": "Delete widget",
"Device already verified!": "Device already verified!",
"Device ID": "Device ID",
"Device ID:": "Device ID:",
@ -252,6 +253,7 @@
"Drop here %(toAction)s": "Drop here %(toAction)s",
"Drop here to tag %(section)s": "Drop here to tag %(section)s",
"Ed25519 fingerprint": "Ed25519 fingerprint",
"Edit": "Edit",
"Email": "Email",
"Email address": "Email address",
"Email address (optional)": "Email address (optional)",
@ -421,6 +423,7 @@
"Profile": "Profile",
"Reason": "Reason",
"Revoke Moderator": "Revoke Moderator",
"Revoke widget access": "Revoke widget access",
"Refer a friend to Riot:": "Refer a friend to Riot:",
"Register": "Register",
"rejected": "rejected",

51
src/shouldHideEvent.js Normal file
View file

@ -0,0 +1,51 @@
/*
Copyright 2017 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.
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.
*/
function _isLeaveOrJoin(ev) {
const isMemberEvent = ev.getType() === 'm.room.member' && ev.getStateKey() !== undefined;
if (!isMemberEvent) {
return false; // bail early: all the checks below concern member events only
}
// TODO: These checks are done to make sure we're dealing with membership transitions not avatar changes / dupe joins
// These checks are also being done in TextForEvent and should really reside in the JS SDK as a helper function
const membership = ev.getContent().membership;
const prevMembership = ev.getPrevContent().membership;
if (membership === prevMembership && membership === 'join') {
// join -> join : This happens when display names change / avatars are set / genuine dupe joins with no changes.
// Find out which we're dealing with.
if (ev.getPrevContent().displayname !== ev.getContent().displayname) {
return false; // display name changed
}
if (ev.getPrevContent().avatar_url !== ev.getContent().avatar_url) {
return false; // avatar url changed
}
// dupe join event, fall through to hide rules
}
// this only applies to joins/invited joins/leaves not invites/kicks/bans
const isJoin = membership === 'join' && prevMembership !== 'ban';
const isLeave = membership === 'leave' && ev.getStateKey() === ev.getSender();
return isJoin || isLeave;
}
export default function(ev, syncedSettings) {
// Hide redacted events
if (syncedSettings['hideRedactions'] && ev.isRedacted()) return true;
if (syncedSettings['hideJoinLeaves'] && _isLeaveOrJoin(ev)) return true;
return false;
}

View file

@ -18,10 +18,12 @@ var React = require('react');
var ReactDOM = require("react-dom");
var TestUtils = require('react-addons-test-utils');
var expect = require('expect');
import sinon from 'sinon';
var sdk = require('matrix-react-sdk');
var MessagePanel = sdk.getComponent('structures.MessagePanel');
import UserSettingsStore from '../../../src/UserSettingsStore';
var test_utils = require('test-utils');
var mockclock = require('mock-clock');
@ -54,9 +56,10 @@ describe('MessagePanel', function () {
test_utils.beforeEach(this);
client = test_utils.createTestClient();
client.credentials = {userId: '@me:here'};
UserSettingsStore.getSyncedSettings = sinon.stub().returns({});
});
afterEach(function () {
afterEach(function() {
clock.uninstall();
});