diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..9d6a114391 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - node # Latest stable version of nodejs. diff --git a/CHANGELOG.md b/CHANGELOG.md index b99944185e..c18ffa24d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,57 @@ +Changes in [0.6.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.5) (2016-08-28) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.4-r1...v0.6.5) + + * re-add leave button in RoomSettings + * add /user URLs + * recognise matrix.to links and other vector links + * fix linkify dependency + * fix avatar clicking in MemberInfo + * fix emojione sizing + [\#431](https://github.com/matrix-org/matrix-react-sdk/pull/431) + * Fix NPE when we don't know the sender of an event + [\#430](https://github.com/matrix-org/matrix-react-sdk/pull/430) + * Update annoying TimelinePanel test + [\#429](https://github.com/matrix-org/matrix-react-sdk/pull/429) + * add fancy changelog dialog + [\#416](https://github.com/matrix-org/matrix-react-sdk/pull/416) + * Send bot options with leading underscore on the state key + [\#428](https://github.com/matrix-org/matrix-react-sdk/pull/428) + * Update autocomplete design and scroll it correctly + [\#419](https://github.com/matrix-org/matrix-react-sdk/pull/419) + * Add ability to query and set bot options + [\#427](https://github.com/matrix-org/matrix-react-sdk/pull/427) + * Add .travis.yml + [\#425](https://github.com/matrix-org/matrix-react-sdk/pull/425) + * Added event/info message avatars back in + [\#426](https://github.com/matrix-org/matrix-react-sdk/pull/426) + * Add postMessage API required for integration provisioning + [\#423](https://github.com/matrix-org/matrix-react-sdk/pull/423) + * Fix TimelinePanel test + [\#424](https://github.com/matrix-org/matrix-react-sdk/pull/424) + * Wmwragg/chat message presentation + [\#422](https://github.com/matrix-org/matrix-react-sdk/pull/422) + * Only try to delete room rule if it exists + [\#421](https://github.com/matrix-org/matrix-react-sdk/pull/421) + * Make the notification slider work + [\#420](https://github.com/matrix-org/matrix-react-sdk/pull/420) + * Don't download E2E devices if feature disabled + [\#418](https://github.com/matrix-org/matrix-react-sdk/pull/418) + * strip (IRC) suffix from tabcomplete entries + [\#417](https://github.com/matrix-org/matrix-react-sdk/pull/417) + * ignore local busy + [\#415](https://github.com/matrix-org/matrix-react-sdk/pull/415) + * defaultDeviceDisplayName should be a prop + [\#414](https://github.com/matrix-org/matrix-react-sdk/pull/414) + * Use server-generated deviceId + [\#410](https://github.com/matrix-org/matrix-react-sdk/pull/410) + * Set initial_device_display_name on login and register + [\#413](https://github.com/matrix-org/matrix-react-sdk/pull/413) + * Add device_id to devices display + [\#409](https://github.com/matrix-org/matrix-react-sdk/pull/409) + * Don't use MatrixClientPeg for temporary clients + [\#408](https://github.com/matrix-org/matrix-react-sdk/pull/408) + Changes in [0.6.4-r1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.4-r1) (2016-08-12) ========================================================================================================= [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.4...v0.6.4-r1) diff --git a/package.json b/package.json index c6ece7e20d..569ce798f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.6.4-r1", + "version": "0.6.5", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -37,10 +37,10 @@ "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", - "linkifyjs": "^2.0.0-beta.4", + "linkifyjs": "2.0.0-beta.4", "lodash": "^4.13.1", "marked": "^0.3.5", - "matrix-js-sdk": "0.5.5", + "matrix-js-sdk": "0.5.6", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.2.1", diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 6a8d903df8..2bb0ef4c04 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -49,7 +49,7 @@ export function unicodeToImage(str) { alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode]; const title = mappedUnicode[unicode]; - replaceWith = `${alt}`; + replaceWith = `${alt}`; return replaceWith; } }); @@ -85,12 +85,28 @@ var sanitizeHtmlParams = { transformTags: { // custom to matrix // add blank targets to all hyperlinks except vector URLs 'a': function(tagName, attribs) { - var m = attribs.href ? attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN) : null; - if (m) { - delete attribs.target; - } - else { - attribs.target = '_blank'; + if (attribs.href) { + attribs.target = '_blank'; // by default + + var m; + // FIXME: horrible duplication with linkify-matrix + m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); + if (m) { + attribs.href = m[1]; + delete attribs.target; + } + + m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); + if (m) { + var entity = m[1]; + if (entity[0] === '@') { + attribs.href = '#/user/' + entity; + } + else if (entity[0] === '#' || entity[0] === '!') { + attribs.href = '#/room/' + entity; + } + delete attribs.target; + } } attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName: tagName, attribs : attribs }; @@ -271,7 +287,7 @@ module.exports = { emojifyText: function(text) { return { - __html: emojione.unicodeToImage(escape(text)), + __html: unicodeToImage(escape(text)), }; }, }; diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js new file mode 100644 index 0000000000..cc552ba898 --- /dev/null +++ b/src/ScalarMessaging.js @@ -0,0 +1,273 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed: +{ + action: "invite" | "membership_state" | "bot_options" | "set_bot_options", + room_id: $ROOM_ID, + user_id: $USER_ID + // additional request fields +} + +The complete request object is returned to the caller with an additional "response" key like so: +{ + action: "invite" | "membership_state" | "bot_options" | "set_bot_options", + room_id: $ROOM_ID, + user_id: $USER_ID, + // additional request fields + response: { ... } +} + +The "action" determines the format of the request and response. All actions can return an error response. +An error response is a "response" object which consists of a sole "error" key to indicate an error. +They look like: +{ + error: { + message: "Unable to invite user into room.", + _error: + } +} +The "message" key should be a human-friendly string. + +ACTIONS +======= +All actions can return an error response instead of the response outlined below. + +invite +------ +Invites a user into a room. + +Request: + - room_id is the room to invite the user into. + - user_id is the user ID to invite. + - No additional fields. +Response: +{ + success: true +} +Example: +{ + action: "invite", + room_id: "!foo:bar", + user_id: "@invitee:bar", + response: { + success: true + } +} + +set_bot_options +--------------- +Set the m.room.bot.options state event for a bot user. + +Request: + - room_id is the room to send the state event into. + - user_id is the user ID of the bot who you're setting options for. + - "content" is an object consisting of the content you wish to set. +Response: +{ + success: true +} +Example: +{ + action: "set_bot_options", + room_id: "!foo:bar", + user_id: "@bot:bar", + content: { + default_option: "alpha" + }, + response: { + success: true + } +} + +membership_state AND bot_options +-------------------------------- +Get the content of the "m.room.member" or "m.room.bot.options" state event respectively. + +NB: Whilst this API is basically equivalent to getStateEvent, we specifically do not + want external entities to be able to query any state event for any room, hence the + restrictive API outlined here. + +Request: + - room_id is the room which has the state event. + - user_id is the state_key parameter which in both cases is a user ID (the member or the bot). + - No additional fields. +Response: + - The event content. If there is no state event, the "response" key should be null. +Example: +{ + action: "membership_state", + room_id: "!foo:bar", + user_id: "@somemember:bar", + response: { + membership: "join", + displayname: "Bob", + avatar_url: null + } +} +*/ + +const SdkConfig = require('./SdkConfig'); +const MatrixClientPeg = require("./MatrixClientPeg"); + +function sendResponse(event, res) { + const data = JSON.parse(JSON.stringify(event.data)); + data.response = res; + event.source.postMessage(data, event.origin); +} + +function sendError(event, msg, nestedError) { + console.error("Action:" + event.data.action + " failed with message: " + msg); + const data = JSON.parse(JSON.stringify(event.data)); + data.response = { + error: { + message: msg, + }, + }; + if (nestedError) { + data.response.error._error = nestedError; + } + event.source.postMessage(data, event.origin); +} + +function inviteUser(event, roomId, userId) { + console.log(`Received request to invite ${userId} into room ${roomId}`); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, "You need to be logged in."); + return; + } + const room = client.getRoom(roomId); + if (room) { + // if they are already invited we can resolve immediately. + const member = room.getMember(userId); + if (member && member.membership === "invite") { + sendResponse(event, { + success: true, + }); + return; + } + } + + client.invite(roomId, userId).done(function() { + sendResponse(event, { + success: true, + }); + }, function(err) { + sendError(event, "You need to be able to invite users to do that.", err); + }); +} + +function setBotOptions(event, roomId, userId) { + console.log(`Received request to set options for bot ${userId} in room ${roomId}`); + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, "You need to be logged in."); + return; + } + client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, err.message ? err.message : "Failed to send request.", err); + }); +} + +function getMembershipState(event, roomId, userId) { + console.log(`membership_state of ${userId} in room ${roomId} requested.`); + returnStateEvent(event, roomId, "m.room.member", userId); +} + +function botOptions(event, roomId, userId) { + console.log(`bot_options of ${userId} in room ${roomId} requested.`); + returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); +} + + +function returnStateEvent(event, roomId, eventType, stateKey) { + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, "You need to be logged in."); + return; + } + const room = client.getRoom(roomId); + if (!room) { + sendError(event, "This room is not recognised."); + return; + } + const stateEvent = room.currentState.getStateEvents(eventType, stateKey); + if (!stateEvent) { + sendResponse(event, null); + return; + } + sendResponse(event, stateEvent.getContent()); +} + +const onMessage = function(event) { + if (!event.origin) { // stupid chrome + event.origin = event.originalEvent.origin; + } + + // check it is from the integrations UI URL (remove trailing spaces) + let url = SdkConfig.get().integrations_ui_url; + if (url.endsWith("/")) { + url = url.substr(0, url.length - 1); + } + if (url !== event.origin) { + console.warn("Unauthorised postMessage received. Source URL: " + event.origin); + return; + } + + const roomId = event.data.room_id; + const userId = event.data.user_id; + if (!userId) { + sendError(event, "Missing user_id in request"); + return; + } + if (!roomId) { + sendError(event, "Missing room_id in request"); + return; + } + switch (event.data.action) { + case "membership_state": + getMembershipState(event, roomId, userId); + break; + case "invite": + inviteUser(event, roomId, userId); + break; + case "bot_options": + botOptions(event, roomId, userId); + break; + case "set_bot_options": + setBotOptions(event, roomId, userId); + break; + default: + console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); + break; + } +}; + +module.exports = { + startListening: function() { + window.addEventListener("message", onMessage, false); + }, + + stopListening: function() { + window.removeEventListener("message", onMessage); + }, +}; diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 41d5d035d1..87d7987856 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,4 +1,5 @@ import Q from 'q'; +import React from 'react'; export default class AutocompleteProvider { constructor(commandRegex?: RegExp, fuseOpts?: any) { @@ -51,4 +52,9 @@ export default class AutocompleteProvider { getName(): string { return 'Default Provider'; } + + renderCompletions(completions: [React.Component]): ?React.Component { + console.error('stub; should be implemented in subclasses'); + return null; + } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 19a366ac63..4652e69ddf 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -74,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider { } getName() { - return 'Commands'; + return '*️⃣ Commands'; } static getInstance(): CommandProvider { @@ -83,4 +83,10 @@ export default class CommandProvider extends AutocompleteProvider { return instance; } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ {completions} +
; + } } diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index 168da00c1c..4595f7456d 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -1,19 +1,62 @@ import React from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; -export function TextualCompletion({ - title, - subtitle, - description, -}: { - title: ?string, - subtitle: ?string, - description: ?string -}) { - return ( -
- {title} - {subtitle} - {description} -
- ); +/* These were earlier stateless functional components but had to be converted +since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion, +something that is not entirely possible with stateless functional components. One could +presumably wrap them in a
before rendering but I think this is the better way to do it. + */ + +export class TextualCompletion extends React.Component { + render() { + const { + title, + subtitle, + description, + className, + ...restProps, + } = this.props; + return ( +
+ {title} + {subtitle} + {description} +
+ ); + } } +TextualCompletion.propTypes = { + title: React.PropTypes.string, + subtitle: React.PropTypes.string, + description: React.PropTypes.string, + className: React.PropTypes.string, +}; + +export class PillCompletion extends React.Component { + render() { + const { + title, + subtitle, + description, + initialComponent, + className, + ...restProps, + } = this.props; + return ( +
+ {initialComponent} + {title} + {subtitle} + {description} +
+ ); + } +} +PillCompletion.propTypes = { + title: React.PropTypes.string, + subtitle: React.PropTypes.string, + description: React.PropTypes.string, + initialComponent: React.PropTypes.element, + className: React.PropTypes.string, +}; diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 1746ce0aaa..c85eb8a10b 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -78,7 +78,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { } getName() { - return 'Results from DuckDuckGo'; + return '🔍 Results from DuckDuckGo'; } static getInstance(): DuckDuckGoProvider { @@ -87,4 +87,10 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { } return instance; } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ {completions} +
; + } } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 37a50ee8d8..e292808787 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -3,6 +3,8 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import Fuse from 'fuse.js'; +import sdk from '../index'; +import {PillCompletion} from './Components'; const EMOJI_REGEX = /:\w*:?/g; const EMOJI_SHORTNAMES = Object.keys(emojioneList); @@ -16,28 +18,28 @@ export default class EmojiProvider extends AutocompleteProvider { } getCompletions(query: string, selection: {start: number, end: number}) { + const EmojiText = sdk.getComponent('views.elements.EmojiText'); + let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { completions = this.fuse.search(command[0]).map(result => { - let shortname = EMOJI_SHORTNAMES[result]; - let imageHTML = shortnameToImage(shortname); + const shortname = EMOJI_SHORTNAMES[result]; + const unicode = shortnameToUnicode(shortname); return { - completion: shortnameToUnicode(shortname), + completion: unicode, component: ( -
-   {shortname} -
+ {unicode}} /> ), range, }; - }).slice(0, 4); + }).slice(0, 8); } return Q.when(completions); } getName() { - return 'Emoji'; + return '😃 Emoji'; } static getInstance() { @@ -45,4 +47,10 @@ export default class EmojiProvider extends AutocompleteProvider { instance = new EmojiProvider(); return instance; } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ {completions} +
; + } } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index b34fdeb59a..39cf1179d7 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -3,8 +3,9 @@ import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; import Fuse from 'fuse.js'; -import {TextualCompletion} from './Components'; +import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../MatrixTools'; +import sdk from '../index'; const ROOM_REGEX = /(?=#)([^\s]*)/g; @@ -21,6 +22,8 @@ export default class RoomProvider extends AutocompleteProvider { } getCompletions(query: string, selection: {start: number, end: number}) { + const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + let client = MatrixClientPeg.get(); let completions = []; const {command, range} = this.getCurrentCommand(query, selection); @@ -39,7 +42,7 @@ export default class RoomProvider extends AutocompleteProvider { return { completion: displayAlias, component: ( - + } title={room.name} description={displayAlias} /> ), range, }; @@ -49,7 +52,7 @@ export default class RoomProvider extends AutocompleteProvider { } getName() { - return 'Rooms'; + return '💬 Rooms'; } static getInstance() { @@ -59,4 +62,10 @@ export default class RoomProvider extends AutocompleteProvider { return instance; } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ {completions} +
; + } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 8828f8cb70..7485d76484 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,7 +2,8 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; import Q from 'q'; import Fuse from 'fuse.js'; -import {TextualCompletion} from './Components'; +import {PillCompletion} from './Components'; +import sdk from '../index'; const USER_REGEX = /@[^\s]*/g; @@ -20,6 +21,8 @@ export default class UserProvider extends AutocompleteProvider { } getCompletions(query: string, selection: {start: number, end: number}) { + const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); + let completions = []; let {command, range} = this.getCurrentCommand(query, selection); if (command) { @@ -29,7 +32,8 @@ export default class UserProvider extends AutocompleteProvider { return { completion: user.userId, component: ( - } title={displayName} description={user.userId} /> ), @@ -41,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider { } getName() { - return 'Users'; + return '👥 Users'; } setUserList(users) { @@ -54,4 +58,10 @@ export default class UserProvider extends AutocompleteProvider { } return instance; } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ {completions} +
; + } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 8d84cbf7d0..84575b9e4f 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -69,6 +69,7 @@ module.exports = React.createClass({ UserSettings: "user_settings", CreateRoom: "create_room", RoomDirectory: "room_directory", + UserView: "user_view", }, AuxPanel: { @@ -87,6 +88,10 @@ module.exports = React.createClass({ // in the case where we view a room by ID or by RoomView when it resolves // what ID an alias points at. currentRoomId: null, + + // If we're trying to just view a user ID (i.e. /user URL), this is it + viewUserId: null, + logged_in: false, collapse_lhs: false, collapse_rhs: false, @@ -94,6 +99,9 @@ module.exports = React.createClass({ width: 10000, sideOpacity: 1.0, middleOpacity: 1.0, + + version: null, + newVersion: null, }; return s; }, @@ -736,6 +744,18 @@ module.exports = React.createClass({ } else { dis.dispatch(payload); } + } else if (screen.indexOf('user/') == 0) { + var userId = screen.substring(5); + this.setState({ viewUserId: userId }); + this._setPage(this.PageTypes.UserView); + this.notifyNewScreen('user/' + userId); + var member = new Matrix.RoomMember(null, userId); + if (member) { + dis.dispatch({ + action: 'view_user', + member: member, + }); + } } else { console.info("Ignoring showScreen for '%s'", screen); @@ -756,15 +776,13 @@ module.exports = React.createClass({ onUserClick: function(event, userId) { event.preventDefault(); - /* - var MemberInfo = sdk.getComponent('rooms.MemberInfo'); - var member = new Matrix.RoomMember(null, userId); - ContextualMenu.createMenu(MemberInfo, { - member: member, - right: window.innerWidth - event.pageX, - top: event.pageY - }); - */ + // var MemberInfo = sdk.getComponent('rooms.MemberInfo'); + // var member = new Matrix.RoomMember(null, userId); + // ContextualMenu.createMenu(MemberInfo, { + // member: member, + // right: window.innerWidth - event.pageX, + // top: event.pageY + // }); var member = new Matrix.RoomMember(null, userId); if (!member) { return; } @@ -856,6 +874,7 @@ module.exports = React.createClass({ onVersion: function(current, latest) { this.setState({ version: current, + newVersion: latest, hasNewVersion: current !== latest }); }, @@ -988,11 +1007,15 @@ module.exports = React.createClass({ page_element = right_panel = break; + case this.PageTypes.UserView: + page_element = null; // deliberately null for now + right_panel = + break; } var topBar; if (this.state.hasNewVersion) { - topBar = ; + topBar = ; } else if (MatrixClientPeg.get().isGuest()) { topBar = ; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 798ee03e73..c80a8c89d7 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -31,7 +31,6 @@ var KeyCode = require('../../KeyCode'); var PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; -var TIMELINE_CAP = 250; // the most events to show in a timeline var DEBUG = false; @@ -82,6 +81,9 @@ var TimelinePanel = React.createClass({ // opacity for dynamic UI fading effects opacity: React.PropTypes.number, + + // maximum number of events to show in a timeline + timelineCap: React.PropTypes.number, }, statics: { @@ -92,6 +94,12 @@ var TimelinePanel = React.createClass({ roomReadMarkerTsMap: {}, }, + getDefaultProps: function() { + return { + timelineCap: 250, + }; + }, + getInitialState: function() { var initialReadMarker = TimelinePanel.roomReadMarkerMap[this.props.room.roomId] @@ -684,7 +692,7 @@ var TimelinePanel = React.createClass({ _loadTimeline: function(eventId, pixelOffset, offsetBase) { this._timelineWindow = new Matrix.TimelineWindow( MatrixClientPeg.get(), this.props.room, - {windowLimit: TIMELINE_CAP}); + {windowLimit: this.props.timelineCap}); var onLoaded = () => { this._reloadEvents(); diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 654f801afc..6e1670604e 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -47,6 +47,9 @@ module.exports = React.createClass({ }, _getState: function(props) { + if (!props.member) { + console.error("MemberAvatar called somehow with null member"); + } return { name: props.member.name, title: props.member.userId, diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 0863fe0842..26658c3005 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -22,12 +22,6 @@ var sdk = require('../../../index'); module.exports = React.createClass({ displayName: 'MessageEvent', - statics: { - needsSenderProfile: function() { - return true; - } - }, - propTypes: { /* the MatrixEvent to show */ mxEvent: React.PropTypes.object.isRequired, diff --git a/src/components/views/messages/TextualEvent.js b/src/components/views/messages/TextualEvent.js index 7cab98ea84..8319dbd434 100644 --- a/src/components/views/messages/TextualEvent.js +++ b/src/components/views/messages/TextualEvent.js @@ -24,12 +24,6 @@ import sdk from '../../../index'; module.exports = React.createClass({ displayName: 'TextualEvent', - statics: { - needsSenderProfile: function() { - return false; - } - }, - render: function() { const EmojiText = sdk.getComponent('elements.EmojiText'); var text = TextForEvent.textForEvent(this.props.mxEvent); @@ -39,4 +33,3 @@ module.exports = React.createClass({ ); }, }); - diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 32e568e2ba..4b2e23a8b8 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,7 +1,8 @@ import React from 'react'; -import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; +import ReactDOM from 'react-dom'; import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; +import sdk from '../../../index'; import {getCompletions} from '../../../autocomplete/Autocompleter'; @@ -100,11 +101,27 @@ export default class Autocomplete extends React.Component { this.setState({selectionOffset}); } + componentDidUpdate() { + // this is the selected completion, so scroll it into view if needed + const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`]; + if (selectedCompletion && this.container) { + const domNode = ReactDOM.findDOMNode(selectedCompletion); + const offsetTop = domNode && domNode.offsetTop; + if (offsetTop > this.container.scrollTop + this.container.offsetHeight || + offsetTop < this.container.scrollTop) { + this.container.scrollTop = offsetTop - this.container.offsetTop; + } + } + } + render() { + const EmojiText = sdk.getComponent('views.elements.EmojiText'); + let position = 0; let renderedCompletions = this.state.completions.map((completionResult, i) => { let completions = completionResult.completions.map((completion, i) => { - let className = classNames('mx_Autocomplete_Completion', { + + const className = classNames('mx_Autocomplete_Completion', { 'selected': position === this.state.selectionOffset, }); let componentPosition = position; @@ -116,40 +133,27 @@ export default class Autocomplete extends React.Component { this.onConfirm(); }; - return ( -
- {completion.component} -
- ); + return React.cloneElement(completion.component, { + key: i, + ref: `completion${i}`, + className, + onMouseOver, + onClick, + }); }); return completions.length > 0 ? (
- {completionResult.provider.getName()} - - {completions} - + {completionResult.provider.getName()} + {completionResult.provider.renderCompletions(completions)}
) : null; }); return ( -
- - {renderedCompletions} - +
this.container = e}> + {renderedCompletions}
); } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index bc0317addd..b1df3f3267 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -62,7 +62,7 @@ var MAX_READ_AVATARS = 5; // '----------------------------------------------------------' module.exports = React.createClass({ - displayName: 'Event', + displayName: 'EventTile', statics: { haveTileForEvent: function(e) { @@ -368,7 +368,7 @@ module.exports = React.createClass({ // room, or emote messages var isInfoMessage = (msgtype === 'm.emote' || eventType !== 'm.room.message'); - var EventTileType = sdk.getComponent(eventTileTypes[this.props.mxEvent.getType()]); + var EventTileType = sdk.getComponent(eventTileTypes[eventType]); // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!EventTileType) { @@ -395,25 +395,44 @@ module.exports = React.createClass({ - var aux = null; - if (msgtype === 'm.image') aux = "sent an image"; - else if (msgtype === 'm.video') aux = "sent a video"; - else if (msgtype === 'm.file') aux = "uploaded a file"; - var readAvatars = this.getReadAvatars(); var avatar, sender; - if (!this.props.continuation && !isInfoMessage) { - if (this.props.mxEvent.sender) { - avatar = ( + let avatarSize; + let needsSenderProfile; + + if (isInfoMessage) { + // a small avatar, with no sender profile, for emotes and + // joins/parts/etc + avatarSize = 14; + needsSenderProfile = false; + } else if (this.props.continuation) { + // no avatar or sender profile for continuation messages + avatarSize = 0; + needsSenderProfile = false; + } else { + avatarSize = 30; + needsSenderProfile = true; + } + + if (this.props.mxEvent.sender && avatarSize) { + avatar = (
- +
- ); - } - if (EventTileType.needsSenderProfile()) { - sender = ; - } + ); + } + + if (needsSenderProfile) { + let aux = null; + if (msgtype === 'm.image') aux = "sent an image"; + else if (msgtype === 'm.video') aux = "sent a video"; + else if (msgtype === 'm.file') aux = "uploaded a file"; + + sender = ; } var editButton = ( diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 59e186da06..c02b009c39 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -531,7 +531,7 @@ module.exports = React.createClass({ }, onMemberAvatarClick: function () { - var avatarUrl = this.props.member.user.avatarUrl; + var avatarUrl = this.props.member.user ? this.props.member.user.avatarUrl : this.props.member.events.member.getContent().avatar_url; if(!avatarUrl) return; var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(avatarUrl); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 1896207c09..145b8559b7 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -23,6 +23,7 @@ var Modal = require('../../../Modal'); var ObjectUtils = require("../../../ObjectUtils"); var dis = require("../../../dispatcher"); var ScalarAuthClient = require("../../../ScalarAuthClient"); +var ScalarMessaging = require('../../../ScalarMessaging'); var UserSettingsStore = require('../../../UserSettingsStore'); // parse a string as an integer; if the input is undefined, or cannot be parsed @@ -70,6 +71,7 @@ module.exports = React.createClass({ }, componentWillMount: function() { + ScalarMessaging.startListening(); MatrixClientPeg.get().getRoomDirectoryVisibility( this.props.room.roomId ).done((result) => { @@ -93,6 +95,8 @@ module.exports = React.createClass({ }, componentWillUnmount: function() { + ScalarMessaging.stopListening(); + dis.dispatch({ action: 'ui_opacity', sideOpacity: 1.0, @@ -422,6 +426,27 @@ module.exports = React.createClass({ }, ""); }, + onLeaveClick() { + dis.dispatch({ + action: 'leave_room', + room_id: this.props.room.roomId, + }); + }, + + onForgetClick() { + // FIXME: duplicated with RoomTagContextualMenu (and dead code in RoomView) + MatrixClientPeg.get().forget(this.props.room.roomId).done(function() { + dis.dispatch({ action: 'view_next_room' }); + }, function(err) { + var errCode = err.errcode || "unknown error code"; + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Error", + description: `Failed to forget room (${errCode})` + }); + }); + }, + _renderEncryptionSection: function() { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { return null; @@ -540,6 +565,25 @@ module.exports = React.createClass({ ); } + var leaveButton = null; + var myMember = this.props.room.getMember(user_id); + if (myMember) { + if (myMember.membership === "join") { + leaveButton = ( +
+ Leave room +
+ ); + } + else if (myMember.membership === "leave") { + leaveButton = ( +
+ Forget room +
+ ); + } + } + // TODO: support editing custom events_levels // TODO: support editing custom user_levels @@ -627,6 +671,8 @@ module.exports = React.createClass({ return (
+ { leaveButton } + { tagsSection }
diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index cc416ace2f..8e8351de43 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -100,7 +100,7 @@ export default class DevicesPanelEntry extends React.Component { deleteButton =
{this.state.deleteError}
} else { deleteButton = ( -
Delete
diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 99b7ee5c33..99e4898182 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -95,6 +95,7 @@ function matrixLinkify(linkify) { S_AT_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_USERID); } +// stubs, overwritten in MatrixChat's componentDidMount matrixLinkify.onUserClick = function(e, userId) { e.preventDefault(); }; matrixLinkify.onAliasClick = function(e, roomAlias) { e.preventDefault(); }; @@ -102,11 +103,14 @@ var escapeRegExp = function(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; -// we only recognise URLs which match our current URL as being the same app -// as if someone explicitly links to vector.im/develop and we're on vector.im/beta -// they may well be trying to get us to explicitly go to develop. -// FIXME: intercept matrix.to URLs as well. -matrixLinkify.VECTOR_URL_PATTERN = "^(https?:\/\/)?" + escapeRegExp(window.location.host + window.location.pathname); +// Recognise URLs from both our local vector and official vector as vector. +// anyone else really should be using matrix.to. +matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:" + + escapeRegExp(window.location.host + window.location.pathname) + "|" + + "(?:www\\.)?vector\\.im/(?:beta|staging|develop)/" + + ")(#.*)"; + +matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)"; matrixLinkify.options = { events: function (href, type) { @@ -131,8 +135,25 @@ matrixLinkify.options = { case 'roomalias': return '#/room/' + href; case 'userid': - return '#'; + return '#/user/' + href; default: + var m; + // FIXME: horrible duplication with HtmlUtils' transform tags + m = href.match(matrixLinkify.VECTOR_URL_PATTERN); + if (m) { + return m[1]; + } + m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN); + if (m) { + var entity = m[1]; + if (entity[0] === '@') { + return '#/user/' + entity; + } + else if (entity[0] === '#' || entity[0] === '!') { + return '#/room/' + entity; + } + } + return href; } }, @@ -143,7 +164,9 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - if (href.match(matrixLinkify.VECTOR_URL_PATTERN)) { + if (href.match(matrixLinkify.VECTOR_URL_PATTERN) || + href.match(matrixLinkify.MATRIXTO_URL_PATTERN)) + { return null; } else { diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 7a603d138f..027d888d2d 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -40,11 +40,12 @@ describe('TimelinePanel', function() { var timeline; var parentDiv; - function mkMessage() { + function mkMessage(opts) { return test_utils.mkMessage( { event: true, room: ROOM_ID, user: USER_ID, ts: Date.now(), + ... opts, }); } @@ -87,7 +88,7 @@ describe('TimelinePanel', function() { // this is https://github.com/vector-im/vector-web/issues/1367 // enough events to allow us to scroll back - var N_EVENTS = 20; + var N_EVENTS = 30; for (var i = 0; i < N_EVENTS; i++) { timeline.addEvent(mkMessage()); } @@ -207,10 +208,11 @@ describe('TimelinePanel', function() { }); it("should let you scroll down again after you've scrolled up", function(done) { - var N_EVENTS = 600; + var TIMELINE_CAP = 100; // needs to be more than we can fit in the div + var N_EVENTS = 120; // needs to be more than TIMELINE_CAP // sadly, loading all those events takes a while - this.timeout(N_EVENTS * 30); + this.timeout(N_EVENTS * 50); // client.getRoom is called a /lot/ in this test, so replace // sinon's spy with a fast noop. @@ -218,13 +220,15 @@ describe('TimelinePanel', function() { // fill the timeline with lots of events for (var i = 0; i < N_EVENTS; i++) { - timeline.addEvent(mkMessage()); + timeline.addEvent(mkMessage({msg: "Event "+i})); } console.log("added events to timeline"); var scrollDefer; var panel = ReactDOM.render( - {scrollDefer.resolve()}} />, + {scrollDefer.resolve()}} + timelineCap={TIMELINE_CAP} + />, parentDiv ); console.log("TimelinePanel rendered"); @@ -256,14 +260,18 @@ describe('TimelinePanel', function() { console.log("back paginating..."); setScrollTop(0); return awaitScroll().then(() => { + let eventTiles = scryEventTiles(panel); + let firstEvent = eventTiles[0].props.mxEvent; + + console.log("TimelinePanel contains " + eventTiles.length + + " events; first is " + + firstEvent.getContent().body); + if(scrollingDiv.scrollTop > 0) { // need to go further return backPaginate(); } console.log("paginated to start."); - - // hopefully, we got to the start of the timeline - expect(messagePanel.props.backPaginating).toBe(false); }); } @@ -276,16 +284,38 @@ describe('TimelinePanel', function() { // back-paginate until we hit the start return backPaginate(); }).then(() => { + // hopefully, we got to the start of the timeline + expect(messagePanel.props.backPaginating).toBe(false); + expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); var events = scryEventTiles(panel); - expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]) + expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]); + expect(events.length).toEqual(TIMELINE_CAP); // we should now be able to scroll down, and paginate in the other // direction. setScrollTop(scrollingDiv.scrollHeight); scrollingDiv.scrollTop = scrollingDiv.scrollHeight; - return awaitScroll(); + + // the delay() below is a heinous hack to deal with the fact that, + // without it, we may or may not get control back before the + // forward pagination completes. The delay means that it should + // have completed. + return awaitScroll().delay(0); }).then(() => { + expect(messagePanel.props.backPaginating).toBe(false); + expect(messagePanel.props.forwardPaginating).toBe(false); + expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); + + var events = scryEventTiles(panel); + expect(events.length).toEqual(TIMELINE_CAP); + + // we don't really know what the first event tile will be, since that + // depends on how much the timelinepanel decides to paginate. + // + // just check that the first tile isn't event 0. + expect(events[0].props.mxEvent).toNotBe(timeline.getEvents()[0]); + console.log("done"); }).done(done, done); });