+ );
+ } else if (this.state.screen == 'register') {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
}
-};
+});
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
new file mode 100644
index 0000000000..a7944f91f5
--- /dev/null
+++ b/src/components/structures/RoomView.js
@@ -0,0 +1,1343 @@
+/*
+Copyright 2015 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.
+*/
+
+// TODO: This component is enormous! There's several things which could stand-alone:
+// - Aux component
+// - Search results component
+// - Drag and drop
+// - File uploading - uploadFile()
+// - Timeline component (alllll the logic in getEventTiles())
+
+var React = require("react");
+var ReactDOM = require("react-dom");
+var GeminiScrollbar = require('react-gemini-scrollbar');
+var q = require("q");
+var classNames = require("classnames");
+var Matrix = require("matrix-js-sdk");
+
+var MatrixClientPeg = require("../../MatrixClientPeg");
+var ContentMessages = require("../../ContentMessages");
+var WhoIsTyping = require("../../WhoIsTyping");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
+var CallHandler = require('../../CallHandler');
+var Resend = require("../../Resend");
+var dis = require("../../dispatcher");
+
+var PAGINATE_SIZE = 20;
+var INITIAL_SIZE = 20;
+
+var DEBUG_SCROLL = false;
+
+module.exports = React.createClass({
+ displayName: 'RoomView',
+ propTypes: {
+ ConferenceHandler: React.PropTypes.any
+ },
+
+ getInitialState: function() {
+ var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null;
+ return {
+ room: room,
+ messageCap: INITIAL_SIZE,
+ editingRoomSettings: false,
+ uploadingRoomSettings: false,
+ numUnreadMessages: 0,
+ draggingFile: false,
+ searching: false,
+ searchResults: null,
+ syncState: MatrixClientPeg.get().getSyncState(),
+ hasUnsentMessages: this._hasUnsentMessages(room),
+ callState: null,
+ }
+ },
+
+ componentWillMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
+ MatrixClientPeg.get().on("Room.name", this.onRoomName);
+ MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
+ MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
+ MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
+ MatrixClientPeg.get().on("sync", this.onSyncStateChange);
+ this.savedScrollState = {atBottom: true};
+ },
+
+ componentWillUnmount: function() {
+ if (this.refs.messagePanel) {
+ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
+ messagePanel.removeEventListener('drop', this.onDrop);
+ messagePanel.removeEventListener('dragover', this.onDragOver);
+ messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd);
+ messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd);
+ }
+ dis.unregister(this.dispatcherRef);
+ if (MatrixClientPeg.get()) {
+ MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
+ MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
+ MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
+ MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
+ MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
+ MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
+ }
+
+ window.removeEventListener('resize', this.onResize);
+ },
+
+ onAction: function(payload) {
+ switch (payload.action) {
+ case 'message_send_failed':
+ case 'message_sent':
+ this.setState({
+ hasUnsentMessages: this._hasUnsentMessages(this.state.room)
+ });
+ case 'message_resend_started':
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId)
+ });
+ this.forceUpdate();
+ break;
+ case 'notifier_enabled':
+ case 'upload_failed':
+ case 'upload_started':
+ case 'upload_finished':
+ this.forceUpdate();
+ break;
+ case 'call_state':
+ // don't filter out payloads for room IDs other than props.room because
+ // we may be interested in the conf 1:1 room
+
+ if (!payload.room_id) {
+ return;
+ }
+
+ var call = CallHandler.getCallForRoom(payload.room_id);
+ var callState;
+
+ if (call) {
+ // Call state has changed so we may be loading video elements
+ // which will obscure the message log.
+ // scroll to bottom
+ this.scrollToBottom();
+ callState = call.call_state;
+ }
+ else {
+ callState = "ended";
+ }
+
+ // possibly remove the conf call notification if we're now in
+ // the conf
+ this._updateConfCallNotification();
+
+ this.setState({
+ callState: callState
+ });
+
+ break;
+ case 'user_activity':
+ this.sendReadReceipt();
+ break;
+ }
+ },
+
+ // get the DOM node which has the scrollTop property we care about for our
+ // message panel.
+ //
+ // If the gemini scrollbar is doing its thing, this will be a div within
+ // the message panel (ie, the gemini container); otherwise it will be the
+ // message panel itself.
+ _getScrollNode: function() {
+ var panel = ReactDOM.findDOMNode(this.refs.messagePanel);
+ if (!panel) return null;
+
+ if (panel.classList.contains('gm-prevented')) {
+ return panel;
+ } else {
+ return panel.children[2]; // XXX: Fragile!
+ }
+ },
+
+ onSyncStateChange: function(state, prevState) {
+ if (state === "SYNCING" && prevState === "SYNCING") {
+ return;
+ }
+ this.setState({
+ syncState: state
+ });
+ },
+
+ // MatrixRoom still showing the messages from the old room?
+ // Set the key to the room_id. Sadly you can no longer get at
+ // the key from inside the component, or we'd check this in code.
+ /*componentWillReceiveProps: function(props) {
+ },*/
+
+ onRoomTimeline: function(ev, room, toStartOfTimeline) {
+ if (!this.isMounted()) return;
+
+ // ignore anything that comes in whilst paginating: we get one
+ // event for each new matrix event so this would cause a huge
+ // number of UI updates. Just update the UI when the paginate
+ // call returns.
+ if (this.state.paginating) return;
+
+ // no point handling anything while we're waiting for the join to finish:
+ // we'll only be showing a spinner.
+ if (this.state.joining) return;
+ if (room.roomId != this.props.roomId) return;
+
+ var currentUnread = this.state.numUnreadMessages;
+ if (!toStartOfTimeline &&
+ (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
+ // update unread count when scrolled up
+ if (this.savedScrollState.atBottom) {
+ currentUnread = 0;
+ }
+ else {
+ currentUnread += 1;
+ }
+ }
+
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId),
+ numUnreadMessages: currentUnread
+ });
+ },
+
+ onRoomName: function(room) {
+ if (room.roomId == this.props.roomId) {
+ this.setState({
+ room: room
+ });
+ }
+ },
+
+ onRoomReceipt: function(receiptEvent, room) {
+ if (room.roomId == this.props.roomId) {
+ this.forceUpdate();
+ }
+ },
+
+ onRoomMemberTyping: function(ev, member) {
+ this.forceUpdate();
+ },
+
+ onRoomStateMember: function(ev, state, member) {
+ if (!this.props.ConferenceHandler) {
+ return;
+ }
+ if (member.roomId !== this.props.roomId ||
+ member.userId !== this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) {
+ return;
+ }
+ this._updateConfCallNotification();
+ },
+
+ _hasUnsentMessages: function(room) {
+ return this._getUnsentMessages(room).length > 0;
+ },
+
+ _getUnsentMessages: function(room) {
+ if (!room) { return []; }
+ // TODO: It would be nice if the JS SDK provided nicer constant-time
+ // constructs rather than O(N) (N=num msgs) on this.
+ return room.timeline.filter(function(ev) {
+ return ev.status === Matrix.EventStatus.NOT_SENT;
+ });
+ },
+
+ _updateConfCallNotification: function() {
+ var room = MatrixClientPeg.get().getRoom(this.props.roomId);
+ if (!room || !this.props.ConferenceHandler) {
+ return;
+ }
+ var confMember = room.getMember(
+ this.props.ConferenceHandler.getConferenceUserIdForRoom(this.props.roomId)
+ );
+
+ if (!confMember) {
+ return;
+ }
+ var confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId);
+
+ // A conf call notification should be displayed if there is an ongoing
+ // conf call but this cilent isn't a part of it.
+ this.setState({
+ displayConfCallNotification: (
+ (!confCall || confCall.call_state === "ended") &&
+ confMember.membership === "join"
+ )
+ });
+ },
+
+ componentDidMount: function() {
+ if (this.refs.messagePanel) {
+ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
+
+ messagePanel.addEventListener('drop', this.onDrop);
+ messagePanel.addEventListener('dragover', this.onDragOver);
+ messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd);
+ messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd);
+
+ this.scrollToBottom();
+ this.sendReadReceipt();
+ this.fillSpace();
+ }
+
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ var callState = call ? call.call_state : "ended";
+ this.setState({
+ callState: callState
+ });
+
+ this._updateConfCallNotification();
+
+ window.addEventListener('resize', this.onResize);
+ this.onResize();
+ },
+
+ componentDidUpdate: function() {
+ // after adding event tiles, we may need to tweak the scroll (either to
+ // keep at the bottom of the timeline, or to maintain the view after
+ // adding events to the top).
+
+ if (!this.refs.messagePanel) return;
+
+ if (this.state.searchResults) return;
+
+ if (this.needsScrollReset) {
+ if (DEBUG_SCROLL) console.log("Resetting scroll position after tile count change");
+ this._restoreSavedScrollState();
+ this.needsScrollReset = false;
+ }
+
+ // have to fill space in case we're accepting an invite
+ if (!this.state.paginating) this.fillSpace();
+ },
+
+ _paginateCompleted: function() {
+ if (DEBUG_SCROLL) console.log("paginate complete");
+
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId)
+ });
+
+ // we might not have got enough results from the pagination
+ // request, so give fillSpace() a chance to set off another.
+ if (!this.fillSpace()) {
+ this.setState({paginating: false});
+ }
+ },
+
+ // check the scroll position, and if we need to, set off a pagination
+ // request.
+ //
+ // returns true if a pagination request was started (or is still in progress)
+ fillSpace: function() {
+ if (!this.refs.messagePanel) return;
+ if (this.state.searchResults) return; // TODO: paginate search results
+ var messageWrapperScroll = this._getScrollNode();
+ if (messageWrapperScroll.scrollTop < messageWrapperScroll.clientHeight && this.state.room.oldState.paginationToken) {
+ // there's less than a screenful of messages left. Either wind back
+ // the message cap (if there are enough events in the timeline to
+ // do so), or fire off a pagination request.
+
+ this.oldScrollHeight = messageWrapperScroll.scrollHeight;
+
+ if (this.state.messageCap < this.state.room.timeline.length) {
+ var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length);
+ if (DEBUG_SCROLL) console.log("winding back message cap to", cap);
+ this.setState({messageCap: cap});
+ } else {
+ var cap = this.state.messageCap + PAGINATE_SIZE;
+ if (DEBUG_SCROLL) console.log("starting paginate to cap", cap);
+ this.setState({messageCap: cap, paginating: true});
+ MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done();
+ return true;
+ }
+ }
+ return false;
+ },
+
+ onResendAllClick: function() {
+ var eventsToResend = this._getUnsentMessages(this.state.room);
+ eventsToResend.forEach(function(event) {
+ Resend.resend(event);
+ });
+ },
+
+ onJoinButtonClicked: function(ev) {
+ var self = this;
+ MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() {
+ self.setState({
+ joining: false,
+ room: MatrixClientPeg.get().getRoom(self.props.roomId)
+ });
+ }, function(error) {
+ self.setState({
+ joining: false,
+ joinError: error
+ });
+ });
+ this.setState({
+ joining: true
+ });
+ },
+
+ onMessageListScroll: function(ev) {
+ var sn = this._getScrollNode();
+ if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
+
+ // Sometimes we see attempts to write to scrollTop essentially being
+ // ignored. (Or rather, it is successfully written, but on the next
+ // scroll event, it's been reset again).
+ //
+ // This was observed on Chrome 47, when scrolling using the trackpad in OS
+ // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
+ // due to Chrome not being able to cope with the scroll offset being reset
+ // while a two-finger drag is in progress.
+ //
+ // By way of a workaround, we detect this situation and just keep
+ // resetting scrollTop until we see the scroll node have the right
+ // value.
+ if (this.recentEventScroll !== undefined) {
+ if(sn.scrollTop < this.recentEventScroll-200) {
+ console.log("Working around vector-im/vector-web#528");
+ this._restoreSavedScrollState();
+ return;
+ }
+ this.recentEventScroll = undefined;
+ }
+
+ if (this.refs.messagePanel && !this.state.searchResults) {
+ this.savedScrollState = this._calculateScrollState();
+ if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState);
+ if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) {
+ this.setState({numUnreadMessages: 0});
+ }
+ }
+ if (!this.state.paginating) this.fillSpace();
+ },
+
+ onDragOver: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ ev.dataTransfer.dropEffect = 'none';
+
+ var items = ev.dataTransfer.items;
+ if (items.length == 1) {
+ if (items[0].kind == 'file') {
+ this.setState({ draggingFile : true });
+ ev.dataTransfer.dropEffect = 'copy';
+ }
+ }
+ },
+
+ onDrop: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.setState({ draggingFile : false });
+ var files = ev.dataTransfer.files;
+ if (files.length == 1) {
+ this.uploadFile(files[0]);
+ }
+ },
+
+ onDragLeaveOrEnd: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.setState({ draggingFile : false });
+ },
+
+ uploadFile: function(file) {
+ var self = this;
+ ContentMessages.sendContentToRoom(
+ file, this.props.roomId, MatrixClientPeg.get()
+ ).done(undefined, function(error) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to upload file",
+ description: error.toString()
+ });
+ });
+ },
+
+ getWhoIsTypingString: function() {
+ return WhoIsTyping.whoIsTypingString(this.state.room);
+ },
+
+ onSearch: function(term, scope) {
+ var filter;
+ if (scope === "Room") {
+ filter = {
+ // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
+ rooms: [
+ this.props.roomId
+ ]
+ };
+ }
+
+ var self = this;
+ self.setState({
+ searchInProgress: true
+ });
+
+ MatrixClientPeg.get().search({
+ body: {
+ search_categories: {
+ room_events: {
+ search_term: term,
+ filter: filter,
+ order_by: "recent",
+ include_state: true,
+ groupings: {
+ group_by: [
+ {
+ key: "room_id"
+ }
+ ]
+ },
+ event_context: {
+ before_limit: 1,
+ after_limit: 1,
+ include_profile: true,
+ }
+ }
+ }
+ }
+ }).then(function(data) {
+
+ if (!self.state.searching || term !== self.refs.search_bar.refs.search_term.value) {
+ console.error("Discarding stale search results");
+ return;
+ }
+
+ // for debugging:
+ // data.search_categories.room_events.highlights = ["hello", "everybody"];
+
+ var highlights;
+ if (data.search_categories.room_events.highlights &&
+ data.search_categories.room_events.highlights.length > 0)
+ {
+ // postgres on synapse returns us precise details of the
+ // strings which actually got matched for highlighting.
+ // for overlapping highlights, favour longer (more specific) terms first
+ highlights = data.search_categories.room_events.highlights
+ .sort(function(a, b) { b.length - a.length });
+ }
+ else {
+ // sqlite doesn't, so just try to highlight the literal search term
+ highlights = [ term ];
+ }
+
+ self.setState({
+ highlights: highlights,
+ searchTerm: term,
+ searchResults: data,
+ searchScope: scope,
+ searchCount: data.search_categories.room_events.count,
+ });
+ }, function(error) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Search failed",
+ description: error.toString()
+ });
+ }).finally(function() {
+ self.setState({
+ searchInProgress: false
+ });
+ });
+ },
+
+ getEventTiles: function() {
+ var DateSeparator = sdk.getComponent('messages.DateSeparator');
+ var cli = MatrixClientPeg.get();
+
+ var ret = [];
+ var count = 0;
+
+ var EventTile = sdk.getComponent('rooms.EventTile');
+ var self = this;
+
+ if (this.state.searchResults)
+ {
+ if (!this.state.searchResults.search_categories.room_events.results ||
+ !this.state.searchResults.search_categories.room_events.groups)
+ {
+ return ret;
+ }
+
+ // XXX: this dance is foul, due to the results API not directly returning sorted results
+ var results = this.state.searchResults.search_categories.room_events.results;
+ var roomIdGroups = this.state.searchResults.search_categories.room_events.groups.room_id;
+
+ if (Array.isArray(results)) {
+ // Old search API used to return results as a event_id -> result dict, but now
+ // returns a straightforward list.
+ results = results.reduce(function(prev, curr) {
+ prev[curr.result.event_id] = curr;
+ return prev;
+ }, {});
+ }
+
+ Object.keys(roomIdGroups)
+ .sort(function(a, b) { roomIdGroups[a].order - roomIdGroups[b].order }) // WHY NOT RETURN AN ORDERED ARRAY?!?!?!
+ .forEach(function(roomId)
+ {
+ // XXX: todo: merge overlapping results somehow?
+ // XXX: why doesn't searching on name work?
+ if (self.state.searchScope === 'All') {
+ ret.push(
Room: { cli.getRoom(roomId).name }
);
+ }
+
+ var resultList = roomIdGroups[roomId].results.map(function(eventId) { return results[eventId]; });
+ for (var i = resultList.length - 1; i >= 0; i--) {
+ var ts1 = resultList[i].result.origin_server_ts;
+ ret.push(
); // Rank: {resultList[i].rank}
+ var mxEv = new Matrix.MatrixEvent(resultList[i].result);
+ if (resultList[i].context.events_before[0]) {
+ var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_before[0]);
+ if (EventTile.haveTileForEvent(mxEv2)) {
+ ret.push(
);
+ }
+ if (resultList[i].context.events_after[0]) {
+ var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_after[0]);
+ if (EventTile.haveTileForEvent(mxEv2)) {
+ ret.push(
);
+ }
+ }
+ }
+ });
+ return ret;
+ }
+
+ for (var i = this.state.room.timeline.length-1; i >= 0 && count < this.state.messageCap; --i) {
+ var mxEv = this.state.room.timeline[i];
+
+ if (!EventTile.haveTileForEvent(mxEv)) {
+ continue;
+ }
+ if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") {
+ if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) ||
+ this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) {
+ continue; // suppress conf user join/parts
+ }
+ }
+
+ var continuation = false;
+ var last = false;
+ var dateSeparator = null;
+ if (i == this.state.room.timeline.length - 1) {
+ last = true;
+ }
+ if (i > 0 && count < this.state.messageCap - 1) {
+ if (this.state.room.timeline[i].sender &&
+ this.state.room.timeline[i - 1].sender &&
+ (this.state.room.timeline[i].sender.userId ===
+ this.state.room.timeline[i - 1].sender.userId) &&
+ (this.state.room.timeline[i].getType() ==
+ this.state.room.timeline[i - 1].getType())
+ )
+ {
+ continuation = true;
+ }
+
+ var ts0 = this.state.room.timeline[i - 1].getTs();
+ var ts1 = this.state.room.timeline[i].getTs();
+ if (new Date(ts0).toDateString() !== new Date(ts1).toDateString()) {
+ dateSeparator =
;
+ continuation = false;
+ }
+ }
+
+ if (i === 1) { // n.b. 1, not 0, as the 0th event is an m.room.create and so doesn't show on the timeline
+ var ts1 = this.state.room.timeline[i].getTs();
+ dateSeparator =
;
+ continuation = false;
+ }
+
+ ret.unshift(
+
+ );
+ if (dateSeparator) {
+ ret.unshift(dateSeparator);
+ }
+ ++count;
+ }
+ if (count != this.lastEventTileCount) {
+ if (DEBUG_SCROLL) console.log("Queuing scroll reset (event count changed; now "+count+"; was "+this.lastEventTileCount+")");
+ this.needsScrollReset = true;
+ }
+ this.lastEventTileCount = count;
+ return ret;
+ },
+
+ uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) {
+ var old_name = this.state.room.name;
+
+ var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', '');
+ if (old_topic) {
+ old_topic = old_topic.getContent().topic;
+ } else {
+ old_topic = "";
+ }
+
+ var old_join_rule = this.state.room.currentState.getStateEvents('m.room.join_rules', '');
+ if (old_join_rule) {
+ old_join_rule = old_join_rule.getContent().join_rule;
+ } else {
+ old_join_rule = "invite";
+ }
+
+ var old_history_visibility = this.state.room.currentState.getStateEvents('m.room.history_visibility', '');
+ if (old_history_visibility) {
+ old_history_visibility = old_history_visibility.getContent().history_visibility;
+ } else {
+ old_history_visibility = "shared";
+ }
+
+ var deferreds = [];
+
+ if (old_name != new_name && new_name != undefined && new_name) {
+ deferreds.push(
+ MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name)
+ );
+ }
+
+ if (old_topic != new_topic && new_topic != undefined) {
+ deferreds.push(
+ MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic)
+ );
+ }
+
+ if (old_join_rule != new_join_rule && new_join_rule != undefined) {
+ deferreds.push(
+ MatrixClientPeg.get().sendStateEvent(
+ this.state.room.roomId, "m.room.join_rules", {
+ join_rule: new_join_rule,
+ }, ""
+ )
+ );
+ }
+
+ if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) {
+ deferreds.push(
+ MatrixClientPeg.get().sendStateEvent(
+ this.state.room.roomId, "m.room.history_visibility", {
+ history_visibility: new_history_visibility,
+ }, ""
+ )
+ );
+ }
+
+ if (new_power_levels) {
+ deferreds.push(
+ MatrixClientPeg.get().sendStateEvent(
+ this.state.room.roomId, "m.room.power_levels", new_power_levels, ""
+ )
+ );
+ }
+
+ if (deferreds.length) {
+ var self = this;
+ q.all(deferreds).fail(function(err) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to set state",
+ description: err.toString()
+ });
+ }).finally(function() {
+ self.setState({
+ uploadingRoomSettings: false,
+ });
+ });
+ } else {
+ this.setState({
+ editingRoomSettings: false,
+ uploadingRoomSettings: false,
+ });
+ }
+ },
+
+ _collectEventNode: function(eventId, node) {
+ if (this.eventNodes == undefined) this.eventNodes = {};
+ this.eventNodes[eventId] = node;
+ },
+
+ _indexForEventId(evId) {
+ for (var i = 0; i < this.state.room.timeline.length; ++i) {
+ if (evId == this.state.room.timeline[i].getId()) {
+ return i;
+ }
+ }
+ return null;
+ },
+
+ sendReadReceipt: function() {
+ if (!this.state.room) return;
+ var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
+ var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
+
+ var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
+ if (lastReadEventIndex === null) return;
+
+ if (lastReadEventIndex > currentReadUpToEventIndex) {
+ MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]);
+ }
+ },
+
+ _getLastDisplayedEventIndexIgnoringOwn: function() {
+ if (this.eventNodes === undefined) return null;
+
+ var messageWrapper = this.refs.messagePanel;
+ if (messageWrapper === undefined) return null;
+ var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
+
+ for (var i = this.state.room.timeline.length-1; i >= 0; --i) {
+ var ev = this.state.room.timeline[i];
+
+ if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
+ continue;
+ }
+
+ var node = this.eventNodes[ev.getId()];
+ if (!node) continue;
+
+ var boundingRect = node.getBoundingClientRect();
+
+ if (boundingRect.bottom < wrapperRect.bottom) {
+ return i;
+ }
+ }
+ return null;
+ },
+
+ onSettingsClick: function() {
+ this.setState({editingRoomSettings: true});
+ },
+
+ onSaveClick: function() {
+ this.setState({
+ editingRoomSettings: false,
+ uploadingRoomSettings: true,
+ });
+
+ var new_name = this.refs.header.getRoomName();
+ var new_topic = this.refs.room_settings.getTopic();
+ var new_join_rule = this.refs.room_settings.getJoinRules();
+ var new_history_visibility = this.refs.room_settings.getHistoryVisibility();
+ var new_power_levels = this.refs.room_settings.getPowerLevels();
+
+ this.uploadNewState(
+ new_name,
+ new_topic,
+ new_join_rule,
+ new_history_visibility,
+ new_power_levels
+ );
+ },
+
+ onCancelClick: function() {
+ this.setState(this.getInitialState());
+ },
+
+ onLeaveClick: function() {
+ dis.dispatch({
+ action: 'leave_room',
+ room_id: this.props.roomId,
+ });
+ this.props.onFinished();
+ },
+
+ onRejectButtonClicked: function(ev) {
+ var self = this;
+ this.setState({
+ rejecting: true
+ });
+ MatrixClientPeg.get().leave(this.props.roomId).done(function() {
+ dis.dispatch({ action: 'view_next_room' });
+ self.setState({
+ rejecting: false
+ });
+ }, function(err) {
+ console.error("Failed to reject invite: %s", err);
+ self.setState({
+ rejecting: false,
+ rejectError: err
+ });
+ });
+ },
+
+ onSearchClick: function() {
+ this.setState({ searching: true });
+ },
+
+ onConferenceNotificationClick: function() {
+ dis.dispatch({
+ action: 'place_call',
+ type: "video",
+ room_id: this.props.roomId
+ });
+ },
+
+ getUnreadMessagesString: function() {
+ if (!this.state.numUnreadMessages) {
+ return "";
+ }
+ return this.state.numUnreadMessages + " new message" + (this.state.numUnreadMessages > 1 ? "s" : "");
+ },
+
+ scrollToBottom: function() {
+ var scrollNode = this._getScrollNode();
+ if (!scrollNode) return;
+ scrollNode.scrollTop = scrollNode.scrollHeight;
+ if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
+ },
+
+ // scroll the event view to put the given event at the bottom.
+ //
+ // pixel_offset gives the number of pixels between the bottom of the event
+ // and the bottom of the container.
+ scrollToEvent: function(eventId, pixelOffset) {
+ var scrollNode = this._getScrollNode();
+ if (!scrollNode) return;
+
+ var messageWrapper = this.refs.messagePanel;
+ if (messageWrapper === undefined) return;
+
+ var idx = this._indexForEventId(eventId);
+ if (idx === null) {
+ // we don't seem to have this event in our timeline. Presumably
+ // it's fallen out of scrollback. We ought to backfill until we
+ // find it, but we'd have to be careful we didn't backfill forever
+ // looking for a non-existent event.
+ //
+ // for now, just scroll to the top of the buffer.
+ console.log("Refusing to scroll to unknown event "+eventId);
+ scrollNode.scrollTop = 0;
+ return;
+ }
+
+ // we might need to roll back the messagecap (to generate tiles for
+ // older messages). This just means telling getEventTiles to create
+ // tiles for events we already have in our timeline (we already know
+ // the event in question is in our timeline, so we shouldn't need to
+ // backfill).
+ //
+ // we actually wind back slightly further than the event in question,
+ // because we want the event to be at the *bottom* of the container.
+ // Don't roll it back past the timeline we have, though.
+ var minCap = this.state.room.timeline.length - Math.min(idx - INITIAL_SIZE, 0);
+ if (minCap > this.state.messageCap) {
+ this.setState({messageCap: minCap});
+ }
+
+ var node = this.eventNodes[eventId];
+ if (node === null) {
+ // getEventTiles should have sorted this out when we set the
+ // messageCap, so this is weird.
+ console.error("No node for event, even after rolling back messageCap");
+ return;
+ }
+
+ var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
+ var boundingRect = node.getBoundingClientRect();
+ var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
+ if(scrollDelta != 0) {
+ scrollNode.scrollTop += scrollDelta;
+
+ // see the comments in onMessageListScroll regarding recentEventScroll
+ this.recentEventScroll = scrollNode.scrollTop;
+ }
+
+ if (DEBUG_SCROLL) {
+ console.log("Scrolled to event", eventId, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")");
+ console.log("recentEventScroll now "+this.recentEventScroll);
+ }
+ },
+
+ _restoreSavedScrollState: function() {
+ var scrollState = this.savedScrollState;
+ if (scrollState.atBottom) {
+ this.scrollToBottom();
+ } else if (scrollState.lastDisplayedEvent) {
+ this.scrollToEvent(scrollState.lastDisplayedEvent,
+ scrollState.pixelOffset);
+ }
+ },
+
+ _calculateScrollState: function() {
+ // we don't save the absolute scroll offset, because that
+ // would be affected by window width, zoom level, amount of scrollback,
+ // etc.
+ //
+ // instead we save the id of the last fully-visible event, and the
+ // number of pixels the window was scrolled below it - which will
+ // hopefully be near enough.
+ //
+ if (this.eventNodes === undefined) return null;
+
+ var messageWrapper = this.refs.messagePanel;
+ if (messageWrapper === undefined) return null;
+ var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
+
+ var messageWrapperScroll = this._getScrollNode();
+ // + 1 here to avoid fractional pixel rounding errors
+ var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1;
+
+ for (var i = this.state.room.timeline.length-1; i >= 0; --i) {
+ var ev = this.state.room.timeline[i];
+ var node = this.eventNodes[ev.getId()];
+ if (!node) continue;
+
+ var boundingRect = node.getBoundingClientRect();
+ if (boundingRect.bottom < wrapperRect.bottom) {
+ return {
+ atBottom: atBottom,
+ lastDisplayedEvent: ev.getId(),
+ pixelOffset: wrapperRect.bottom - boundingRect.bottom,
+ }
+ }
+ }
+
+ // apparently the entire timeline is below the viewport. Give up.
+ return { atBottom: true };
+ },
+
+ // get the current scroll position of the room, so that it can be
+ // restored when we switch back to it
+ getScrollState: function() {
+ return this.savedScrollState;
+ },
+
+ restoreScrollState: function(scrollState) {
+ if(scrollState.atBottom) {
+ // we were at the bottom before. Ideally we'd scroll to the
+ // 'read-up-to' mark here.
+ } else if (scrollState.lastDisplayedEvent) {
+ this.scrollToEvent(scrollState.lastDisplayedEvent,
+ scrollState.pixelOffset);
+ }
+ },
+
+ onResize: function(e) {
+ // It seems flexbox doesn't give us a way to constrain the auxPanel height to have
+ // a minimum of the height of the video element, whilst also capping it from pushing out the page
+ // so we have to do it via JS instead. In this implementation we cap the height by putting
+ // a maxHeight on the underlying remote video tag.
+ var auxPanelMaxHeight;
+ if (this.refs.callView) {
+ // XXX: don't understand why we have to call findDOMNode here in react 0.14 - it should already be a DOM node.
+ var video = ReactDOM.findDOMNode(this.refs.callView.refs.video.refs.remote);
+
+ // header + footer + status + give us at least 100px of scrollback at all times.
+ auxPanelMaxHeight = window.innerHeight - (83 + 72 + 36 + 100);
+
+ // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
+ // but it's better than the video going missing entirely
+ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
+
+ video.style.maxHeight = auxPanelMaxHeight + "px";
+ }
+ },
+
+ onFullscreenClick: function() {
+ dis.dispatch({
+ action: 'video_fullscreen',
+ fullscreen: true
+ }, true);
+ },
+
+ onMuteAudioClick: function() {
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ if (!call) {
+ return;
+ }
+ var newState = !call.isMicrophoneMuted();
+ call.setMicrophoneMuted(newState);
+ this.setState({
+ audioMuted: newState
+ });
+ },
+
+ onMuteVideoClick: function() {
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ if (!call) {
+ return;
+ }
+ var newState = !call.isLocalVideoMuted();
+ call.setLocalVideoMuted(newState);
+ this.setState({
+ videoMuted: newState
+ });
+ },
+
+ render: function() {
+ var RoomHeader = sdk.getComponent('rooms.RoomHeader');
+ var MessageComposer = sdk.getComponent('rooms.MessageComposer');
+ var CallView = sdk.getComponent("voip.CallView");
+ var RoomSettings = sdk.getComponent("rooms.RoomSettings");
+ var SearchBar = sdk.getComponent("rooms.SearchBar");
+
+ if (!this.state.room) {
+ if (this.props.roomId) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ var myUserId = MatrixClientPeg.get().credentials.userId;
+ var myMember = this.state.room.getMember(myUserId);
+ if (myMember && myMember.membership == 'invite') {
+ if (this.state.joining || this.state.rejecting) {
+ var Loader = sdk.getComponent("elements.Spinner");
+ return (
+
+
+
+ );
+ } else {
+ var inviteEvent = myMember.events.member;
+ var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
+ // XXX: Leaving this intentionally basic for now because invites are about to change totally
+ var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
+ var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : "";
+ return (
+
+
+
+
{inviterName} has invited you to a room
+
+
+
+
{joinErrorText}
+
{rejectErrorText}
+
+
+ );
+ }
+ } else {
+ var scrollheader_classes = classNames({
+ mx_RoomView_scrollheader: true,
+ loading: this.state.paginating
+ });
+
+ var statusBar;
+
+ // for testing UI...
+ // this.state.upload = {
+ // uploadedBytes: 123493,
+ // totalBytes: 347534,
+ // fileName: "testing_fooble.jpg",
+ // }
+
+ if (ContentMessages.getCurrentUploads().length > 0) {
+ var UploadBar = sdk.getComponent('structures.UploadBar');
+ statusBar =
+ } else if (!this.state.searchResults) {
+ var typingString = this.getWhoIsTypingString();
+ // typingString = "S͚͍̭̪̤͙̱͙̖̥͙̥̤̻̙͕͓͂̌ͬ͐̂k̜̝͎̰̥̻̼̂̌͛͗͊̅̒͂̊̍̍͌̈̈́͌̋̊ͬa͉̯͚̺̗̳̩ͪ̋̑͌̓̆̍̂̉̏̅̆ͧ̌̑v̲̲̪̝ͥ̌ͨͮͭ̊͆̾ͮ̍ͮ͑̚e̮̙͈̱̘͕̼̮͒ͩͨͫ̃͗̇ͩ͒ͣͦ͒̄̍͐ͣ̿ͥṘ̗̺͇̺̺͔̄́̊̓͊̍̃ͨ̚ā̼͎̘̟̼͎̜̪̪͚̋ͨͨͧ̓ͦͯͤ̄͆̋͂ͩ͌ͧͅt̙̙̹̗̦͖̞ͫͪ͑̑̅ͪ̃̚ͅ is typing...";
+ var unreadMsgs = this.getUnreadMessagesString();
+ // no conn bar trumps unread count since you can't get unread messages
+ // without a connection! (technically may already have some but meh)
+ // It also trumps the "some not sent" msg since you can't resend without
+ // a connection!
+ if (this.state.syncState === "ERROR") {
+ statusBar = (
+
+
+
+
+ Connectivity to the server has been lost.
+
+
+ Sent messages will be stored until your connection has returned.
+
+ );
+ }
+ // unread count trumps who is typing since the unread count is only
+ // set when you've scrolled up
+ else if (unreadMsgs) {
+ statusBar = (
+
+ );
+ }
+});
diff --git a/src/components/structures/login/PostRegistration.js b/src/components/structures/login/PostRegistration.js
new file mode 100644
index 0000000000..51625a5971
--- /dev/null
+++ b/src/components/structures/login/PostRegistration.js
@@ -0,0 +1,79 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var sdk = require('../../../index');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+
+module.exports = React.createClass({
+ displayName: 'PostRegistration',
+
+ propTypes: {
+ onComplete: React.PropTypes.func.isRequired
+ },
+
+ getInitialState: function() {
+ return {
+ avatarUrl: null,
+ errorString: null,
+ busy: false
+ };
+ },
+
+ componentWillMount: function() {
+ // There is some assymetry between ChangeDisplayName and ChangeAvatar,
+ // as ChangeDisplayName will auto-get the name but ChangeAvatar expects
+ // the URL to be passed to you (because it's also used for room avatars).
+ var cli = MatrixClientPeg.get();
+ this.setState({busy: true});
+ var self = this;
+ cli.getProfileInfo(cli.credentials.userId).done(function(result) {
+ self.setState({
+ avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
+ busy: false
+ });
+ }, function(error) {
+ self.setState({
+ errorString: "Failed to fetch avatar URL",
+ busy: false
+ });
+ });
+ },
+
+ render: function() {
+ var ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
+ var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
+ var LoginHeader = sdk.getComponent('login.LoginHeader');
+ return (
+
+
+
+
+ Set a display name:
+
+ Upload an avatar:
+
+
+ {this.state.errorString}
+
+
+
+ );
+ }
+});
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js
new file mode 100644
index 0000000000..1570641556
--- /dev/null
+++ b/src/components/structures/login/Registration.js
@@ -0,0 +1,247 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var sdk = require('../../../index');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var dis = require('../../../dispatcher');
+var Signup = require("../../../Signup");
+var ServerConfig = require("../../views/login/ServerConfig");
+var RegistrationForm = require("../../views/login/RegistrationForm");
+var CaptchaForm = require("../../views/login/CaptchaForm");
+
+var MIN_PASSWORD_LENGTH = 6;
+
+module.exports = React.createClass({
+ displayName: 'Registration',
+
+ propTypes: {
+ onLoggedIn: React.PropTypes.func.isRequired,
+ clientSecret: React.PropTypes.string,
+ sessionId: React.PropTypes.string,
+ registrationUrl: React.PropTypes.string,
+ idSid: React.PropTypes.string,
+ hsUrl: React.PropTypes.string,
+ isUrl: React.PropTypes.string,
+ // registration shouldn't know or care how login is done.
+ onLoginClick: React.PropTypes.func.isRequired
+ },
+
+ getInitialState: function() {
+ return {
+ busy: false,
+ errorText: null,
+ enteredHomeserverUrl: this.props.hsUrl,
+ enteredIdentityServerUrl: this.props.isUrl
+ };
+ },
+
+ componentWillMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ // attach this to the instance rather than this.state since it isn't UI
+ this.registerLogic = new Signup.Register(
+ this.props.hsUrl, this.props.isUrl
+ );
+ this.registerLogic.setClientSecret(this.props.clientSecret);
+ this.registerLogic.setSessionId(this.props.sessionId);
+ this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
+ this.registerLogic.setIdSid(this.props.idSid);
+ this.registerLogic.recheckState();
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ },
+
+ componentDidMount: function() {
+ // may have already done an HTTP hit (e.g. redirect from an email) so
+ // check for any pending response
+ var promise = this.registerLogic.getPromise();
+ if (promise) {
+ this.onProcessingRegistration(promise);
+ }
+ },
+
+ onHsUrlChanged: function(newHsUrl) {
+ this.registerLogic.setHomeserverUrl(newHsUrl);
+ },
+
+ onIsUrlChanged: function(newIsUrl) {
+ this.registerLogic.setIdentityServerUrl(newIsUrl);
+ },
+
+ onAction: function(payload) {
+ if (payload.action !== "registration_step_update") {
+ return;
+ }
+ this.forceUpdate(); // registration state has changed.
+ },
+
+ onFormSubmit: function(formVals) {
+ var self = this;
+ this.setState({
+ errorText: "",
+ busy: true
+ });
+ this.onProcessingRegistration(this.registerLogic.register(formVals));
+ },
+
+ // Promise is resolved when the registration process is FULLY COMPLETE
+ onProcessingRegistration: function(promise) {
+ var self = this;
+ promise.done(function(response) {
+ if (!response || !response.access_token) {
+ console.warn(
+ "FIXME: Register fulfilled without a final response, " +
+ "did you break the promise chain?"
+ );
+ // no matter, we'll grab it direct
+ response = self.registerLogic.getCredentials();
+ }
+ if (!response || !response.user_id || !response.access_token) {
+ console.error("Final response is missing keys.");
+ self.setState({
+ errorText: "There was a problem processing the response."
+ });
+ return;
+ }
+ self.props.onLoggedIn({
+ userId: response.user_id,
+ homeserverUrl: self.registerLogic.getHomeserverUrl(),
+ identityServerUrl: self.registerLogic.getIdentityServerUrl(),
+ accessToken: response.access_token
+ });
+ self.setState({
+ busy: false
+ });
+ }, function(err) {
+ if (err.message) {
+ self.setState({
+ errorText: err.message
+ });
+ }
+ self.setState({
+ busy: false
+ });
+ console.log(err);
+ });
+ },
+
+ onFormValidationFailed: function(errCode) {
+ var errMsg;
+ switch (errCode) {
+ case "RegistrationForm.ERR_PASSWORD_MISSING":
+ errMsg = "Missing password.";
+ break;
+ case "RegistrationForm.ERR_PASSWORD_MISMATCH":
+ errMsg = "Passwords don't match.";
+ break;
+ case "RegistrationForm.ERR_PASSWORD_LENGTH":
+ errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
+ break;
+ default:
+ console.error("Unknown error code: %s", errCode);
+ errMsg = "An unknown error occurred.";
+ break;
+ }
+ this.setState({
+ errorText: errMsg
+ });
+ },
+
+ onCaptchaLoaded: function(divIdName) {
+ this.registerLogic.tellStage("m.login.recaptcha", {
+ divId: divIdName
+ });
+ this.setState({
+ busy: false // requires user input
+ });
+ },
+
+ _getRegisterContentJsx: function() {
+ var currStep = this.registerLogic.getStep();
+ var registerStep;
+ switch (currStep) {
+ case "Register.COMPLETE":
+ break; // NOP
+ case "Register.START":
+ case "Register.STEP_m.login.dummy":
+ registerStep = (
+
+ );
+ break;
+ case "Register.STEP_m.login.email.identity":
+ registerStep = (
+
+ Please check your email to continue registration.
+
+ );
+ }
+});
diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js
new file mode 100644
index 0000000000..42c2f6ca2d
--- /dev/null
+++ b/src/components/views/avatars/MemberAvatar.js
@@ -0,0 +1,112 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var Avatar = require('../../../Avatar');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+
+module.exports = React.createClass({
+ displayName: 'MemberAvatar',
+
+ propTypes: {
+ member: React.PropTypes.object.isRequired,
+ width: React.PropTypes.number,
+ height: React.PropTypes.number,
+ resizeMethod: React.PropTypes.string,
+ },
+
+ getDefaultProps: function() {
+ return {
+ width: 40,
+ height: 40,
+ resizeMethod: 'crop'
+ }
+ },
+
+ componentWillReceiveProps: function(nextProps) {
+ this.refreshUrl();
+ },
+
+ defaultAvatarUrl: function(member, width, height, resizeMethod) {
+ return Avatar.defaultAvatarUrlForString(member.userId);
+ },
+
+ onError: function(ev) {
+ // don't tightloop if the browser can't load a data url
+ if (ev.target.src == this.defaultAvatarUrl(this.props.member)) {
+ return;
+ }
+ this.setState({
+ imageUrl: this.defaultAvatarUrl(this.props.member)
+ });
+ },
+
+ _computeUrl: function() {
+ return Avatar.avatarUrlForMember(this.props.member,
+ this.props.width,
+ this.props.height,
+ this.props.resizeMethod);
+ },
+
+ refreshUrl: function() {
+ var newUrl = this._computeUrl();
+ if (newUrl != this.currentUrl) {
+ this.currentUrl = newUrl;
+ this.setState({imageUrl: newUrl});
+ }
+ },
+
+ getInitialState: function() {
+ return {
+ imageUrl: this._computeUrl()
+ };
+ },
+
+
+ ///////////////
+
+ render: function() {
+ // XXX: recalculates default avatar url constantly
+ if (this.state.imageUrl === this.defaultAvatarUrl(this.props.member)) {
+ var initial;
+ if (this.props.member.name[0])
+ initial = this.props.member.name[0].toUpperCase();
+ if (initial === '@' && this.props.member.name[1])
+ initial = this.props.member.name[1].toUpperCase();
+
+ return (
+
+ { initial }
+
+
+ );
+ }
+ return (
+
+ );
+ }
+});
diff --git a/src/controllers/atoms/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js
similarity index 54%
rename from src/controllers/atoms/RoomAvatar.js
rename to src/components/views/avatars/RoomAvatar.js
index 6c55345ead..2c1de65bcf 100644
--- a/src/controllers/atoms/RoomAvatar.js
+++ b/src/components/views/avatars/RoomAvatar.js
@@ -13,18 +13,13 @@ 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.
*/
+var React = require('react');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var Avatar = require('../../../Avatar');
-'use strict';
+module.exports = React.createClass({
+ displayName: 'RoomAvatar',
-var MatrixClientPeg = require('../../MatrixClientPeg');
-
-/*
- * View class should provide:
- * - getUrlList() returning an array of URLs to try for the room avatar
- in order of preference from the most preferred at index 0. null entries
- in the array will be skipped over.
- */
-module.exports = {
getDefaultProps: function() {
return {
width: 36,
@@ -41,10 +36,26 @@ module.exports = {
},
componentWillReceiveProps: function(nextProps) {
- this._update();
- this.setState({
- imageUrl: this._nextUrl()
- });
+ this.refreshImageUrl();
+ },
+
+ refreshImageUrl: function(nextProps) {
+ // If the list has changed, we start from scratch and re-check, but
+ // don't do so unless the list has changed or we'd re-try fetching
+ // images each time we re-rendered
+ var newList = this.getUrlList();
+ var differs = false;
+ for (var i = 0; i < newList.length && i < this.urlList.length; ++i) {
+ if (this.urlList[i] != newList[i]) differs = true;
+ }
+ if (this.urlList.length != newList.length) differs = true;
+
+ if (differs) {
+ this._update();
+ this.setState({
+ imageUrl: this._nextUrl()
+ });
+ }
},
_update: function() {
@@ -108,5 +119,53 @@ module.exports = {
this.setState({
imageUrl: this._nextUrl()
});
+ },
+
+
+
+ ////////////
+
+
+ getUrlList: function() {
+ return [
+ this.roomAvatarUrl(),
+ this.getOneToOneAvatar(),
+ this.getFallbackAvatar()
+ ];
+ },
+
+ getFallbackAvatar: function() {
+ return Avatar.defaultAvatarUrlForString(this.props.room.roomId);
+ },
+
+ render: function() {
+ var style = {
+ width: this.props.width,
+ height: this.props.height,
+ };
+
+ // XXX: recalculates fallback avatar constantly
+ if (this.state.imageUrl === this.getFallbackAvatar()) {
+ var initial;
+ if (this.props.room.name[0])
+ initial = this.props.room.name[0].toUpperCase();
+ if ((initial === '@' || initial === '#') && this.props.room.name[1])
+ initial = this.props.room.name[1].toUpperCase();
+
+ return (
+
+ { initial }
+
+
+ );
+ }
+ else {
+ return
+ }
}
-};
+});
diff --git a/src/controllers/atoms/create_room/CreateRoomButton.js b/src/components/views/create_room/CreateRoomButton.js
similarity index 78%
rename from src/controllers/atoms/create_room/CreateRoomButton.js
rename to src/components/views/create_room/CreateRoomButton.js
index f03dd56c97..95ba4ac366 100644
--- a/src/controllers/atoms/create_room/CreateRoomButton.js
+++ b/src/components/views/create_room/CreateRoomButton.js
@@ -18,7 +18,8 @@ limitations under the License.
var React = require('react');
-module.exports = {
+module.exports = React.createClass({
+ displayName: 'CreateRoomButton',
propTypes: {
onCreateRoom: React.PropTypes.func,
},
@@ -32,4 +33,10 @@ module.exports = {
onClick: function() {
this.props.onCreateRoom();
},
-};
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/controllers/atoms/create_room/Presets.js b/src/components/views/create_room/Presets.js
similarity index 62%
rename from src/controllers/atoms/create_room/Presets.js
rename to src/components/views/create_room/Presets.js
index bcc2f51481..ee0d19c357 100644
--- a/src/controllers/atoms/create_room/Presets.js
+++ b/src/components/views/create_room/Presets.js
@@ -24,7 +24,8 @@ var Presets = {
Custom: "custom",
};
-module.exports = {
+module.exports = React.createClass({
+ displayName: 'CreateRoomPresets',
propTypes: {
onChange: React.PropTypes.func,
preset: React.PropTypes.string
@@ -37,4 +38,18 @@ module.exports = {
onChange: function() {},
};
},
-};
+
+ onValueChanged: function(ev) {
+ this.props.onChange(ev.target.value)
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/create_room/RoomAlias.js b/src/components/views/create_room/RoomAlias.js
new file mode 100644
index 0000000000..9a30d3fbff
--- /dev/null
+++ b/src/components/views/create_room/RoomAlias.js
@@ -0,0 +1,101 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'RoomAlias',
+ propTypes: {
+ // Specifying a homeserver will make magical things happen when you,
+ // e.g. start typing in the room alias box.
+ homeserver: React.PropTypes.string,
+ alias: React.PropTypes.string,
+ onChange: React.PropTypes.func,
+ },
+
+ getDefaultProps: function() {
+ return {
+ onChange: function() {},
+ alias: '',
+ };
+ },
+
+ getAliasLocalpart: function() {
+ var room_alias = this.props.alias;
+
+ if (room_alias && this.props.homeserver) {
+ var suffix = ":" + this.props.homeserver;
+ if (room_alias.startsWith("#") && room_alias.endsWith(suffix)) {
+ room_alias = room_alias.slice(1, -suffix.length);
+ }
+ }
+
+ return room_alias;
+ },
+
+ onValueChanged: function(ev) {
+ this.props.onChange(ev.target.value);
+ },
+
+ onFocus: function(ev) {
+ var target = ev.target;
+ var curr_val = ev.target.value;
+
+ if (this.props.homeserver) {
+ if (curr_val == "") {
+ setTimeout(function() {
+ target.value = "#:" + this.props.homeserver;
+ target.setSelectionRange(1, 1);
+ }, 0);
+ } else {
+ var suffix = ":" + this.props.homeserver;
+ setTimeout(function() {
+ target.setSelectionRange(
+ curr_val.startsWith("#") ? 1 : 0,
+ curr_val.endsWith(suffix) ? (target.value.length - suffix.length) : target.value.length
+ );
+ }, 0);
+ }
+ }
+ },
+
+ onBlur: function(ev) {
+ var curr_val = ev.target.value;
+
+ if (this.props.homeserver) {
+ if (curr_val == "#:" + this.props.homeserver) {
+ ev.target.value = "";
+ return;
+ }
+
+ if (curr_val != "") {
+ var new_val = ev.target.value;
+ var suffix = ":" + this.props.homeserver;
+ if (!curr_val.startsWith("#")) new_val = "#" + new_val;
+ if (!curr_val.endsWith(suffix)) new_val = new_val + suffix;
+ ev.target.value = new_val;
+ }
+ }
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/controllers/organisms/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js
similarity index 52%
rename from src/controllers/organisms/ErrorDialog.js
rename to src/components/views/dialogs/ErrorDialog.js
index d3431e4a80..af827340d0 100644
--- a/src/controllers/organisms/ErrorDialog.js
+++ b/src/components/views/dialogs/ErrorDialog.js
@@ -14,9 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+/*
+ * Usage:
+ * Modal.createDialog(ErrorDialog, {
+ * title: "some text", (default: "Error")
+ * description: "some more text",
+ * button: "Button Text",
+ * onClose: someFunction,
+ * focus: true|false (default: true)
+ * });
+ */
+
var React = require("react");
-module.exports = {
+module.exports = React.createClass({
+ displayName: 'ErrorDialog',
propTypes: {
title: React.PropTypes.string,
button: React.PropTypes.string,
@@ -32,4 +44,22 @@ module.exports = {
focus: true,
};
},
-};
+
+ render: function() {
+ return (
+
+
+ {this.props.title}
+
+
+ {this.props.description}
+
+
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js
new file mode 100644
index 0000000000..ed58542a66
--- /dev/null
+++ b/src/components/views/dialogs/LogoutPrompt.js
@@ -0,0 +1,48 @@
+/*
+Copyright 2015 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.
+*/
+var React = require('react');
+var dis = require("../../../dispatcher");
+
+module.exports = React.createClass({
+ displayName: 'LogoutPrompt',
+ logOut: function() {
+ dis.dispatch({action: 'logout'});
+ if (this.props.onFinished) {
+ this.props.onFinished();
+ }
+ },
+
+ cancelPrompt: function() {
+ if (this.props.onFinished) {
+ this.props.onFinished();
+ }
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/elements/ProgressBar.js b/src/components/views/elements/ProgressBar.js
new file mode 100644
index 0000000000..bab6a701dd
--- /dev/null
+++ b/src/components/views/elements/ProgressBar.js
@@ -0,0 +1,38 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'ProgressBar',
+ propTypes: {
+ value: React.PropTypes.number,
+ max: React.PropTypes.number
+ },
+
+ render: function() {
+ // Would use an HTML5 progress tag but if that doesn't animate if you
+ // use the HTML attributes rather than styles
+ var progressStyle = {
+ width: ((this.props.value / this.props.max) * 100)+"%"
+ };
+ return (
+
+ );
+ }
+});
\ No newline at end of file
diff --git a/src/controllers/molecules/UserSelector.js b/src/components/views/elements/UserSelector.js
similarity index 56%
rename from src/controllers/molecules/UserSelector.js
rename to src/components/views/elements/UserSelector.js
index 67a56163fa..ea04de59a9 100644
--- a/src/controllers/molecules/UserSelector.js
+++ b/src/components/views/elements/UserSelector.js
@@ -18,7 +18,9 @@ limitations under the License.
var React = require('react');
-module.exports = {
+module.exports = React.createClass({
+ displayName: 'UserSelector',
+
propTypes: {
onChange: React.PropTypes.func,
selected_users: React.PropTypes.arrayOf(React.PropTypes.string),
@@ -42,4 +44,26 @@ module.exports = {
return e != user_id;
}));
},
-};
+
+ onAddUserId: function() {
+ this.addUser(this.refs.user_id_input.value);
+ this.refs.user_id_input.value = "";
+ },
+
+ render: function() {
+ var self = this;
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js
new file mode 100644
index 0000000000..9b722f463b
--- /dev/null
+++ b/src/components/views/login/CaptchaForm.js
@@ -0,0 +1,67 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var DIV_ID = 'mx_recaptcha';
+
+/**
+ * A pure UI component which displays a captcha form.
+ */
+module.exports = React.createClass({
+ displayName: 'CaptchaForm',
+
+ propTypes: {
+ onCaptchaLoaded: React.PropTypes.func.isRequired // called with div id name
+ },
+
+ getDefaultProps: function() {
+ return {
+ onCaptchaLoaded: function() {
+ console.error("Unhandled onCaptchaLoaded");
+ }
+ };
+ },
+
+ componentDidMount: function() {
+ // Just putting a script tag into the returned jsx doesn't work, annoyingly,
+ // so we do this instead.
+ var self = this;
+ if (this.refs.recaptchaContainer) {
+ console.log("Loading recaptcha script...");
+ var scriptTag = document.createElement('script');
+ window.mx_on_recaptcha_loaded = function() {
+ console.log("Loaded recaptcha script.");
+ self.props.onCaptchaLoaded(DIV_ID);
+ };
+ scriptTag.setAttribute(
+ 'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"
+ );
+ this.refs.recaptchaContainer.appendChild(scriptTag);
+ }
+ },
+
+ render: function() {
+ // FIXME: Tight coupling with the div id and SignupStages.js
+ return (
+
+ This Home Server would like to make sure you are not a robot
+
+
+ );
+ }
+});
\ No newline at end of file
diff --git a/src/controllers/organisms/CasLogin.js b/src/components/views/login/CasLogin.js
similarity index 75%
rename from src/controllers/organisms/CasLogin.js
rename to src/components/views/login/CasLogin.js
index d84306e587..9380db9788 100644
--- a/src/controllers/organisms/CasLogin.js
+++ b/src/components/views/login/CasLogin.js
@@ -16,10 +16,12 @@ limitations under the License.
'use strict';
-var MatrixClientPeg = require("../../MatrixClientPeg");
+var MatrixClientPeg = require("../../../MatrixClientPeg");
+var React = require('react');
var url = require("url");
-module.exports = {
+module.exports = React.createClass({
+ displayName: 'CasLogin',
onCasClicked: function(ev) {
var cli = MatrixClientPeg.get();
@@ -30,4 +32,12 @@ module.exports = {
window.location.href = casUrl;
},
-};
+ render: function() {
+ return (
+
+
+
+ );
+ }
+
+});
diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js
new file mode 100644
index 0000000000..3f86bc199c
--- /dev/null
+++ b/src/components/views/login/CustomServerDialog.js
@@ -0,0 +1,50 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require("react");
+
+module.exports = React.createClass({
+ displayName: 'CustomServerDialog',
+
+ render: function() {
+ return (
+
+
+ Custom Server Options
+
+
+
+ You can use the custom server options to log into other Matrix
+ servers by specifying a different Home server URL.
+
+ This allows you to use this app with an existing Matrix account on
+ a different Home server.
+
+
+ You can also set a custom Identity server but this will affect
+ people's ability to find you if you use a server in a group other
+ than the main Matrix.org group.
+
+
+ );
}
-};
-
+});
diff --git a/src/controllers/molecules/RoomSettings.js b/src/components/views/login/LoginHeader.js
similarity index 72%
rename from src/controllers/molecules/RoomSettings.js
rename to src/components/views/login/LoginHeader.js
index 3c0682d09a..c64016413b 100644
--- a/src/controllers/molecules/RoomSettings.js
+++ b/src/components/views/login/LoginHeader.js
@@ -14,16 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+'use strict';
+
var React = require('react');
-module.exports = {
- propTypes: {
- room: React.PropTypes.object.isRequired,
- },
+module.exports = React.createClass({
+ displayName: 'LoginHeader',
- getInitialState: function() {
- return {
- power_levels_changed: false
- };
+ render: function() {
+ return (
+
+ Matrix
+
+ );
}
-};
+});
diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js
new file mode 100644
index 0000000000..baaf3236d6
--- /dev/null
+++ b/src/components/views/login/PasswordLogin.js
@@ -0,0 +1,65 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require('react');
+var ReactDOM = require('react-dom');
+
+/**
+ * A pure UI component which displays a username/password form.
+ */
+module.exports = React.createClass({displayName: 'PasswordLogin',
+ propTypes: {
+ onSubmit: React.PropTypes.func.isRequired // fn(username, password)
+ },
+
+ getInitialState: function() {
+ return {
+ username: "",
+ password: ""
+ };
+ },
+
+ onSubmitForm: function(ev) {
+ ev.preventDefault();
+ this.props.onSubmit(this.state.username, this.state.password);
+ },
+
+ onUsernameChanged: function(ev) {
+ this.setState({username: ev.target.value});
+ },
+
+ onPasswordChanged: function(ev) {
+ this.setState({password: ev.target.value});
+ },
+
+ render: function() {
+ return (
+
+
+
+ );
+ }
+});
\ No newline at end of file
diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js
new file mode 100644
index 0000000000..5c4887955b
--- /dev/null
+++ b/src/components/views/login/RegistrationForm.js
@@ -0,0 +1,126 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var sdk = require('../../../index');
+
+/**
+ * A pure UI component which displays a registration form.
+ */
+module.exports = React.createClass({
+ displayName: 'RegistrationForm',
+
+ propTypes: {
+ defaultEmail: React.PropTypes.string,
+ defaultUsername: React.PropTypes.string,
+ showEmail: React.PropTypes.bool,
+ minPasswordLength: React.PropTypes.number,
+ onError: React.PropTypes.func,
+ onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise
+ },
+
+ getDefaultProps: function() {
+ return {
+ showEmail: false,
+ minPasswordLength: 6,
+ onError: function(e) {
+ console.error(e);
+ }
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ email: this.props.defaultEmail,
+ username: this.props.defaultUsername,
+ password: null,
+ passwordConfirm: null
+ };
+ },
+
+ onSubmit: function(ev) {
+ ev.preventDefault();
+
+ var pwd1 = this.refs.password.value.trim();
+ var pwd2 = this.refs.passwordConfirm.value.trim()
+
+ var errCode;
+ if (!pwd1 || !pwd2) {
+ errCode = "RegistrationForm.ERR_PASSWORD_MISSING";
+ }
+ else if (pwd1 !== pwd2) {
+ errCode = "RegistrationForm.ERR_PASSWORD_MISMATCH";
+ }
+ else if (pwd1.length < this.props.minPasswordLength) {
+ errCode = "RegistrationForm.ERR_PASSWORD_LENGTH";
+ }
+ if (errCode) {
+ this.props.onError(errCode);
+ return;
+ }
+
+ var promise = this.props.onRegisterClick({
+ username: this.refs.username.value.trim(),
+ password: pwd1,
+ email: this.refs.email.value.trim()
+ });
+
+ if (promise) {
+ ev.target.disabled = true;
+ promise.finally(function() {
+ ev.target.disabled = false;
+ });
+ }
+ },
+
+ render: function() {
+ var emailSection, registerButton;
+ if (this.props.showEmail) {
+ emailSection = (
+
+ );
+ }
+ if (this.props.onRegisterClick) {
+ registerButton = (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js
new file mode 100644
index 0000000000..54430c7520
--- /dev/null
+++ b/src/components/views/login/ServerConfig.js
@@ -0,0 +1,145 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var Modal = require('../../../Modal');
+var sdk = require('../../../index');
+
+/**
+ * A pure UI component which displays the HS and IS to use.
+ */
+module.exports = React.createClass({
+ displayName: 'ServerConfig',
+
+ propTypes: {
+ onHsUrlChanged: React.PropTypes.func,
+ onIsUrlChanged: React.PropTypes.func,
+ defaultHsUrl: React.PropTypes.string,
+ defaultIsUrl: React.PropTypes.string,
+ withToggleButton: React.PropTypes.bool,
+ delayTimeMs: React.PropTypes.number // time to wait before invoking onChanged
+ },
+
+ getDefaultProps: function() {
+ return {
+ onHsUrlChanged: function() {},
+ onIsUrlChanged: function() {},
+ withToggleButton: false,
+ delayTimeMs: 0
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ hs_url: this.props.defaultHsUrl,
+ is_url: this.props.defaultIsUrl,
+ original_hs_url: this.props.defaultHsUrl,
+ original_is_url: this.props.defaultIsUrl,
+ // no toggle button = show, toggle button = hide
+ configVisible: !this.props.withToggleButton
+ }
+ },
+
+ onHomeserverChanged: function(ev) {
+ this.setState({hs_url: ev.target.value}, function() {
+ this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() {
+ this.props.onHsUrlChanged(this.state.hs_url);
+ });
+ });
+ },
+
+ onIdentityServerChanged: function(ev) {
+ this.setState({is_url: ev.target.value}, function() {
+ this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() {
+ this.props.onIsUrlChanged(this.state.is_url);
+ });
+ });
+ },
+
+ _waitThenInvoke: function(existingTimeoutId, fn) {
+ if (existingTimeoutId) {
+ clearTimeout(existingTimeoutId);
+ }
+ return setTimeout(fn.bind(this), this.props.delayTimeMs);
+ },
+
+ getHsUrl: function() {
+ return this.state.hs_url;
+ },
+
+ getIsUrl: function() {
+ return this.state.is_url;
+ },
+
+ onServerConfigVisibleChange: function(ev) {
+ this.setState({
+ configVisible: ev.target.checked
+ });
+ },
+
+ showHelpPopup: function() {
+ var CustomServerDialog = sdk.getComponent('login.CustomServerDialog');
+ Modal.createDialog(CustomServerDialog);
+ },
+
+ render: function() {
+ var serverConfigStyle = {};
+ serverConfigStyle.display = this.state.configVisible ? 'block' : 'none';
+
+ var toggleButton;
+ if (this.props.withToggleButton) {
+ toggleButton = (
+
+ );
+ }
+});
diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js
new file mode 100644
index 0000000000..0a03ebe89d
--- /dev/null
+++ b/src/components/views/rooms/RoomTile.js
@@ -0,0 +1,126 @@
+/*
+Copyright 2015 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.
+*/
+
+'use strict';
+
+var React = require('react');
+var classNames = require('classnames');
+var dis = require("../../../dispatcher");
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var sdk = require('../../../index');
+
+module.exports = React.createClass({
+ displayName: 'RoomTile',
+
+ propTypes: {
+ // TODO: We should *optionally* support DND stuff and ideally be impl agnostic about it
+ connectDragSource: React.PropTypes.func.isRequired,
+ connectDropTarget: React.PropTypes.func.isRequired,
+ isDragging: React.PropTypes.bool.isRequired,
+
+ room: React.PropTypes.object.isRequired,
+ collapsed: React.PropTypes.bool.isRequired,
+ selected: React.PropTypes.bool.isRequired,
+ unread: React.PropTypes.bool.isRequired,
+ highlight: React.PropTypes.bool.isRequired,
+ isInvite: React.PropTypes.bool.isRequired,
+ roomSubList: React.PropTypes.object.isRequired,
+ },
+
+ getInitialState: function() {
+ return( { hover : false });
+ },
+
+ onClick: function() {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: this.props.room.roomId
+ });
+ },
+
+ onMouseEnter: function() {
+ this.setState( { hover : true });
+ },
+
+ onMouseLeave: function() {
+ this.setState( { hover : false });
+ },
+
+ render: function() {
+ var myUserId = MatrixClientPeg.get().credentials.userId;
+ var me = this.props.room.currentState.members[myUserId];
+ var classes = classNames({
+ 'mx_RoomTile': true,
+ 'mx_RoomTile_selected': this.props.selected,
+ 'mx_RoomTile_unread': this.props.unread,
+ 'mx_RoomTile_highlight': this.props.highlight,
+ 'mx_RoomTile_invited': (me && me.membership == 'invite'),
+ });
+
+ // XXX: We should never display raw room IDs, but sometimes the
+ // room name js sdk gives is undefined (cannot repro this -- k)
+ var name = this.props.room.name || this.props.room.roomId;
+
+ name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
+ var badge;
+ if (this.props.highlight) {
+ badge = ;
+ }
+ /*
+ if (this.props.highlight) {
+ badge =
!
;
+ }
+ else if (this.props.unread) {
+ badge =
1
;
+ }
+ var nameCell;
+ if (badge) {
+ nameCell =
{name}
{badge}
;
+ }
+ else {
+ nameCell =
{name}
;
+ }
+ */
+
+ var label;
+ if (!this.props.collapsed) {
+ var className = 'mx_RoomTile_name' + (this.props.isInvite ? ' mx_RoomTile_invite' : '');
+ label =
{name}
;
+ }
+ else if (this.state.hover) {
+ var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
+ label = ;
+ }
+
+ var RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
+
+ // These props are injected by React DnD,
+ // as defined by your `collect` function above:
+ var isDragging = this.props.isDragging;
+ var connectDragSource = this.props.connectDragSource;
+ var connectDropTarget = this.props.connectDropTarget;
+
+ return connectDragSource(connectDropTarget(
+
+
+
+ { badge }
+
+ { label }
+
+ ));
+ }
+});
diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js
new file mode 100644
index 0000000000..2ae50a0cae
--- /dev/null
+++ b/src/components/views/settings/ChangeAvatar.js
@@ -0,0 +1,131 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require('react');
+var MatrixClientPeg = require("../../../MatrixClientPeg");
+var sdk = require('../../../index');
+
+module.exports = React.createClass({
+ displayName: 'ChangeAvatar',
+ propTypes: {
+ initialAvatarUrl: React.PropTypes.string,
+ room: React.PropTypes.object,
+ },
+
+ Phases: {
+ Display: "display",
+ Uploading: "uploading",
+ Error: "error",
+ },
+
+ getInitialState: function() {
+ return {
+ avatarUrl: this.props.initialAvatarUrl,
+ phase: this.Phases.Display,
+ }
+ },
+
+ componentWillReceiveProps: function(newProps) {
+ if (this.avatarSet) {
+ // don't clobber what the user has just set
+ return;
+ }
+ this.setState({
+ avatarUrl: newProps.initialAvatarUrl
+ });
+ },
+
+ setAvatarFromFile: function(file) {
+ var newUrl = null;
+
+ this.setState({
+ phase: this.Phases.Uploading
+ });
+ var self = this;
+ MatrixClientPeg.get().uploadContent(file).then(function(url) {
+ newUrl = url;
+ if (self.props.room) {
+ return MatrixClientPeg.get().sendStateEvent(
+ self.props.room.roomId,
+ 'm.room.avatar',
+ {url: url},
+ ''
+ );
+ } else {
+ return MatrixClientPeg.get().setAvatarUrl(url);
+ }
+ }).done(function() {
+ self.setState({
+ phase: self.Phases.Display,
+ avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl)
+ });
+ }, function(error) {
+ self.setState({
+ phase: self.Phases.Error
+ });
+ self.onError(error);
+ });
+ },
+
+ onFileSelected: function(ev) {
+ this.avatarSet = true;
+ this.setAvatarFromFile(ev.target.files[0]);
+ },
+
+ onError: function(error) {
+ this.setState({
+ errorText: "Failed to upload profile picture!"
+ });
+ },
+
+ render: function() {
+ var RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
+ var avatarImg;
+ // Having just set an avatar we just display that since it will take a little
+ // time to propagate through to the RoomAvatar.
+ if (this.props.room && !this.avatarSet) {
+ avatarImg = ;
+ } else {
+ var style = {
+ maxWidth: 320,
+ maxHeight: 240,
+ };
+ avatarImg = ;
+ }
+
+ switch (this.state.phase) {
+ case this.Phases.Display:
+ case this.Phases.Error:
+ return (
+