mirror of
https://github.com/element-hq/element-web.git
synced 2024-12-13 14:38:43 +03:00
Merge remote-tracking branch 'origin/develop' into dbkr/group_userlist
This commit is contained in:
commit
c1546ceb59
28 changed files with 858 additions and 258 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
11
package.json
11
package.json
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
58
src/WidgetUtils.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: (
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 (
|
||||
|
|
75
src/components/views/elements/AppPermission.js
Normal file
75
src/components/views/elements/AppPermission.js
Normal 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() {},
|
||||
};
|
|
@ -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>
|
||||
|
|
25
src/components/views/elements/AppWarning.js
Normal file
25
src/components/views/elements/AppWarning.js
Normal 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;
|
34
src/components/views/elements/MessageSpinner.js
Normal file
34
src/components/views/elements/MessageSpinner.js
Normal 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>
|
||||
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
51
src/shouldHideEvent.js
Normal 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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue