);
} else if (this.state.screen == 'register') {
@@ -749,19 +889,30 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingQueryParams.email}
+ username={this.state.upgradeUsername}
+ disableUsernameChanges={Boolean(this.state.upgradeUsername)}
+ guestAccessToken={this.state.guestAccessToken}
hsUrl={this.props.config.default_hs_url}
isUrl={this.props.config.default_is_url}
registrationUrl={this.props.registrationUrl}
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} />
);
+ } else if (this.state.screen == 'forgot_password') {
+ return (
+
+ );
} else {
return (
+ identityServerUrl={this.props.config.default_is_url}
+ onForgotPasswordClick={this.onForgotPasswordClick} />
);
}
}
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 177b4ab1cc..1fc30a8eec 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -35,11 +35,15 @@ var sdk = require('../../index');
var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
+var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend");
+var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher");
+var Tinter = require("../../Tinter");
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
+var SEND_READ_RECEIPT_DELAY = 2000;
var DEBUG_SCROLL = false;
@@ -53,7 +57,9 @@ if (DEBUG_SCROLL) {
module.exports = React.createClass({
displayName: 'RoomView',
propTypes: {
- ConferenceHandler: React.PropTypes.any
+ ConferenceHandler: React.PropTypes.any,
+ roomId: React.PropTypes.string,
+ autoPeek: React.PropTypes.bool, // should we try to peek the room on mount, or has whoever invoked us already initiated a peek?
},
/* properties in RoomView objects include:
@@ -74,13 +80,21 @@ module.exports = React.createClass({
syncState: MatrixClientPeg.get().getSyncState(),
hasUnsentMessages: this._hasUnsentMessages(room),
callState: null,
+ autoPeekDone: false, // track whether our autoPeek (if any) has completed)
+ guestsCanJoin: false,
+ canPeek: false,
+ readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null,
+ readMarkerGhostEventId: undefined
}
},
componentWillMount: function() {
+ this.last_rr_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction);
+ MatrixClientPeg.get().on("Room", this.onNewRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
+ MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
@@ -88,8 +102,6 @@ module.exports = React.createClass({
// xchat-style tab complete, add a colon if tab
// completing at the start of the text
this.tabComplete = new TabComplete({
- startingWordSuffix: ": ",
- wordSuffix: " ",
allowLooping: false,
autoEnterTabComplete: true,
onClickCompletes: true,
@@ -97,24 +109,56 @@ module.exports = React.createClass({
this.forceUpdate();
}
});
+ // if this is an unknown room then we're in one of three states:
+ // - This is a room we can peek into (search engine) (we can /peek)
+ // - This is a room we can publicly join or were invited to. (we can /join)
+ // - This is a room we cannot join at all. (no action can help us)
+ // We can't try to /join because this may implicitly accept invites (!)
+ // We can /peek though. If it fails then we present the join UI. If it
+ // succeeds then great, show the preview (but we still may be able to /join!).
+ if (!this.state.room) {
+ if (this.props.autoPeek) {
+ console.log("Attempting to peek into room %s", this.props.roomId);
+ MatrixClientPeg.get().peekInRoom(this.props.roomId).catch((err) => {
+ console.error("Failed to peek into room: %s", err);
+ }).finally(() => {
+ // we don't need to do anything - JS SDK will emit Room events
+ // which will update the UI.
+ this.setState({
+ autoPeekDone: true
+ });
+ });
+ }
+ }
+ else {
+ this._calculatePeekRules(this.state.room);
+ }
},
componentWillUnmount: function() {
- if (this.refs.messagePanel) {
- // disconnect the D&D event listeners from the message panel. This
- // is really just for hygiene - the messagePanel is going to be
+ // set a boolean to say we've been unmounted, which any pending
+ // promises can use to throw away their results.
+ //
+ // (We could use isMounted, but facebook have deprecated that.)
+ this.unmounted = true;
+
+ if (this.refs.roomView) {
+ // disconnect the D&D event listeners from the room view. This
+ // is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up.
- 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);
+ var roomView = ReactDOM.findDOMNode(this.refs.roomView);
+ roomView.removeEventListener('drop', this.onDrop);
+ roomView.removeEventListener('dragover', this.onDragOver);
+ roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
+ roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
}
dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) {
+ MatrixClientPeg.get().removeListener("Room", this.onNewRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
+ MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
@@ -122,6 +166,8 @@ module.exports = React.createClass({
}
window.removeEventListener('resize', this.onResize);
+
+ Tinter.tint(); // reset colourscheme
},
onAction: function(payload) {
@@ -175,6 +221,12 @@ module.exports = React.createClass({
break;
case 'user_activity':
+ case 'user_activity_end':
+ // we could treat user_activity_end differently and not
+ // send receipts for messages that have arrived between
+ // the actual user activity and the time they stopped
+ // being active, but let's see if this is actually
+ // necessary.
this.sendReadReceipt();
break;
}
@@ -196,7 +248,7 @@ module.exports = React.createClass({
},*/
onRoomTimeline: function(ev, room, toStartOfTimeline) {
- if (!this.isMounted()) return;
+ if (this.unmounted) return;
// ignore anything that comes in whilst paginating: we get one
// event for each new matrix event so this would cause a huge
@@ -227,6 +279,32 @@ module.exports = React.createClass({
});
},
+ onNewRoom: function(room) {
+ if (room.roomId == this.props.roomId) {
+ this.setState({
+ room: room
+ });
+ }
+
+ this._calculatePeekRules(room);
+ },
+
+ _calculatePeekRules: function(room) {
+ var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", "");
+ if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") {
+ this.setState({
+ guestsCanJoin: true
+ });
+ }
+
+ var historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", "");
+ if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") {
+ this.setState({
+ canPeek: true
+ });
+ }
+ },
+
onRoomName: function(room) {
if (room.roomId == this.props.roomId) {
this.setState({
@@ -235,9 +313,58 @@ module.exports = React.createClass({
}
},
+ updateTint: function() {
+ var room = MatrixClientPeg.get().getRoom(this.props.roomId);
+ if (!room) return;
+
+ var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
+ var color_scheme = {};
+ if (color_scheme_event) {
+ color_scheme = color_scheme_event.getContent();
+ // XXX: we should validate the event
+ }
+ Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
+ },
+
+ onRoomAccountData: function(room, event) {
+ if (room.roomId == this.props.roomId) {
+ if (event.getType === "org.matrix.room.color_scheme") {
+ var color_scheme = event.getContent();
+ // XXX: we should validate the event
+ Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
+ }
+ }
+ },
+
onRoomReceipt: function(receiptEvent, room) {
if (room.roomId == this.props.roomId) {
- this.forceUpdate();
+ var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
+ var readMarkerGhostEventId = this.state.readMarkerGhostEventId;
+ if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) {
+ readMarkerGhostEventId = this.state.readMarkerEventId;
+ }
+
+
+ // if the event after the one referenced in the read receipt if sent by us, do nothing since
+ // this is a temporary period before the synthesized receipt for our own message arrives
+ var readMarkerGhostEventIndex;
+ for (var i = 0; i < room.timeline.length; ++i) {
+ if (room.timeline[i].getId() == readMarkerGhostEventId) {
+ readMarkerGhostEventIndex = i;
+ break;
+ }
+ }
+ if (readMarkerGhostEventIndex + 1 < room.timeline.length) {
+ var nextEvent = room.timeline[readMarkerGhostEventIndex + 1];
+ if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) {
+ readMarkerGhostEventId = undefined;
+ }
+ }
+
+ this.setState({
+ readMarkerEventId: readMarkerEventId,
+ readMarkerGhostEventId: readMarkerGhostEventId,
+ });
}
},
@@ -249,6 +376,14 @@ module.exports = React.createClass({
if (member.roomId === this.props.roomId) {
// a member state changed in this room, refresh the tab complete list
this._updateTabCompleteList(this.state.room);
+
+ var room = MatrixClientPeg.get().getRoom(this.props.roomId);
+ var me = MatrixClientPeg.get().credentials.userId;
+ if (this.state.joining && room.hasMembershipState(me, "join")) {
+ this.setState({
+ joining: false
+ });
+ }
}
if (!this.props.ConferenceHandler) {
@@ -314,7 +449,24 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize);
this.onResize();
+ if (this.refs.roomView) {
+ var roomView = ReactDOM.findDOMNode(this.refs.roomView);
+ roomView.addEventListener('drop', this.onDrop);
+ roomView.addEventListener('dragover', this.onDragOver);
+ roomView.addEventListener('dragleave', this.onDragLeaveOrEnd);
+ roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
+ }
+
this._updateTabCompleteList(this.state.room);
+
+ // XXX: EVIL HACK to autofocus inviting on empty rooms.
+ // We use the setTimeout to avoid racing with focus_composer.
+ if (this.state.room && this.state.room.getJoinedMembers().length == 1) {
+ var inviteBox = document.getElementById("mx_SearchableEntityList_query");
+ setTimeout(function() {
+ inviteBox.focus();
+ }, 50);
+ }
},
_updateTabCompleteList: function(room) {
@@ -322,7 +474,9 @@ module.exports = React.createClass({
return;
}
this.tabComplete.setCompletionList(
- MemberEntry.fromMemberList(room.getJoinedMembers())
+ MemberEntry.fromMemberList(room.getJoinedMembers()).concat(
+ CommandEntry.fromCommands(SlashCommands.getCommandList())
+ )
);
},
@@ -330,13 +484,10 @@ module.exports = React.createClass({
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
this.refs.messagePanel.initialised = true;
- 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.updateTint();
},
componentDidUpdate: function() {
@@ -353,11 +504,14 @@ module.exports = React.createClass({
_paginateCompleted: function() {
debuglog("paginate complete");
- this.setState({
- room: MatrixClientPeg.get().getRoom(this.props.roomId)
- });
+ // we might have switched rooms since the paginate started - just bin
+ // the results if so.
+ if (this.unmounted) return;
- this.setState({paginating: false});
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId),
+ paginating: false,
+ });
},
onSearchResultsFillRequest: function(backwards) {
@@ -412,16 +566,29 @@ module.exports = React.createClass({
onJoinButtonClicked: function(ev) {
var self = this;
- MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() {
+ MatrixClientPeg.get().joinRoom(this.props.roomId).done(function() {
+ // It is possible that there is no Room yet if state hasn't come down
+ // from /sync - joinRoom will resolve when the HTTP request to join succeeds,
+ // NOT when it comes down /sync. If there is no room, we'll keep the
+ // joining flag set until we see it. Likewise, if our state is not
+ // "join" we'll keep this flag set until it comes down /sync.
+ var room = MatrixClientPeg.get().getRoom(self.props.roomId);
+ var me = MatrixClientPeg.get().credentials.userId;
self.setState({
- joining: false,
- room: MatrixClientPeg.get().getRoom(self.props.roomId)
+ joining: room ? !room.hasMembershipState(me, "join") : true,
+ room: room
});
}, function(error) {
self.setState({
joining: false,
joinError: error
});
+ var msg = error.message ? error.message : JSON.stringify(error);
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to join room",
+ description: msg
+ });
});
this.setState({
joining: true
@@ -535,7 +702,7 @@ module.exports = React.createClass({
return searchPromise.then(function(results) {
debuglog("search complete");
- if (!self.state.searching || self.searchId != localSearchId) {
+ if (self.unmounted || !self.state.searching || self.searchId != localSearchId) {
console.error("Discarding stale search results");
return;
}
@@ -553,7 +720,8 @@ module.exports = React.createClass({
// For overlapping highlights,
// favour longer (more specific) terms first
- highlights = highlights.sort(function(a, b) { b.length - a.length });
+ highlights = highlights.sort(function(a, b) {
+ return b.length - a.length });
self.setState({
searchHighlights: highlights,
@@ -648,9 +816,10 @@ module.exports = React.createClass({
var EventTile = sdk.getComponent('rooms.EventTile');
-
var prevEvent = null; // the last event we showed
var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap);
+ var readMarkerIndex;
+ var ghostIndex;
for (var i = startIdx; i < this.state.room.timeline.length; i++) {
var mxEv = this.state.room.timeline[i];
@@ -664,6 +833,25 @@ module.exports = React.createClass({
}
}
+ // now we've decided whether or not to show this message,
+ // add the read up to marker if appropriate
+ // doing this here means we implicitly do not show the marker
+ // if it's at the bottom
+ // NB. it would be better to decide where the read marker was going
+ // when the state changed rather than here in the render method, but
+ // this is where we decide what messages we show so it's the only
+ // place we know whether we're at the bottom or not.
+ var self = this;
+ var mxEvSender = mxEv.sender ? mxEv.sender.userId : null;
+ if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) {
+ var hr;
+ hr = ();
+ readMarkerIndex = ret.length;
+ ret.push(
{hr}
);
+ }
+
// is this a continuation of the previous message?
var continuation = false;
if (prevEvent !== null) {
@@ -700,13 +888,33 @@ module.exports = React.createClass({
);
+ // A read up to marker has died and returned as a ghost!
+ // Lives in the dom as the ghost of the previous one while it fades away
+ if (eventId == this.state.readMarkerGhostEventId) {
+ ghostIndex = ret.length;
+ }
+
prevEvent = mxEv;
}
+ // splice the read marker ghost in now that we know whether the read receipt
+ // is the last element or not, because we only decide as we're going along.
+ if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) {
+ var hr;
+ hr = ();
+ ret.splice(ghostIndex, 0, (
+
{hr}
+ ));
+ }
+
return ret;
},
- uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) {
+ uploadNewState: function(newVals) {
var old_name = this.state.room.name;
var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', '');
@@ -730,61 +938,206 @@ module.exports = React.createClass({
old_history_visibility = "shared";
}
+ var old_guest_read = (old_history_visibility === "world_readable");
+
+ var old_guest_join = this.state.room.currentState.getStateEvents('m.room.guest_access', '');
+ if (old_guest_join) {
+ old_guest_join = (old_guest_join.getContent().guest_access === "can_join");
+ }
+ else {
+ old_guest_join = false;
+ }
+
+ var old_canonical_alias = this.state.room.currentState.getStateEvents('m.room.canonical_alias', '');
+ if (old_canonical_alias) {
+ old_canonical_alias = old_canonical_alias.getContent().alias;
+ }
+ else {
+ old_canonical_alias = "";
+ }
+
var deferreds = [];
- if (old_name != new_name && new_name != undefined && new_name) {
+ if (old_name != newVals.name && newVals.name != undefined) {
deferreds.push(
- MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name)
+ MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name)
);
}
- if (old_topic != new_topic && new_topic != undefined) {
+ if (old_topic != newVals.topic && newVals.topic != undefined) {
deferreds.push(
- MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic)
+ MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, newVals.topic)
);
}
- if (old_join_rule != new_join_rule && new_join_rule != undefined) {
+ if (old_join_rule != newVals.join_rule && newVals.join_rule != undefined) {
deferreds.push(
MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.join_rules", {
- join_rule: new_join_rule,
+ join_rule: newVals.join_rule,
}, ""
)
);
}
- if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) {
- deferreds.push(
+ var visibilityDeferred;
+ if (old_history_visibility != newVals.history_visibility &&
+ newVals.history_visibility != undefined) {
+ visibilityDeferred =
MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.history_visibility", {
- history_visibility: new_history_visibility,
+ history_visibility: newVals.history_visibility,
}, ""
+ );
+ }
+
+ if (old_guest_read != newVals.guest_read ||
+ old_guest_join != newVals.guest_join)
+ {
+ var guestDeferred =
+ MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, {
+ allowRead: newVals.guest_read,
+ allowJoin: newVals.guest_join
+ });
+
+ if (visibilityDeferred) {
+ visibilityDeferred = visibilityDeferred.then(guestDeferred);
+ }
+ else {
+ visibilityDeferred = guestDeferred;
+ }
+ }
+
+ if (visibilityDeferred) {
+ deferreds.push(visibilityDeferred);
+ }
+
+ // setRoomMutePushRule will do nothing if there is no change
+ deferreds.push(
+ MatrixClientPeg.get().setRoomMutePushRule(
+ "global", this.state.room.roomId, newVals.are_notifications_muted
+ )
+ );
+
+ if (newVals.power_levels) {
+ deferreds.push(
+ MatrixClientPeg.get().sendStateEvent(
+ this.state.room.roomId, "m.room.power_levels", newVals.power_levels, ""
)
);
}
- if (new_power_levels) {
+ if (newVals.alias_operations) {
+ var oplist = [];
+ for (var i = 0; i < newVals.alias_operations.length; i++) {
+ var alias_operation = newVals.alias_operations[i];
+ switch (alias_operation.type) {
+ case 'put':
+ oplist.push(
+ MatrixClientPeg.get().createAlias(
+ alias_operation.alias, this.state.room.roomId
+ )
+ );
+ break;
+ case 'delete':
+ oplist.push(
+ MatrixClientPeg.get().deleteAlias(
+ alias_operation.alias
+ )
+ );
+ break;
+ default:
+ console.log("Unknown alias operation, ignoring: " + alias_operation.type);
+ }
+ }
+
+ if (oplist.length) {
+ var deferred = oplist[0];
+ oplist.splice(1).forEach(function (f) {
+ deferred = deferred.then(f);
+ });
+ deferreds.push(deferred);
+ }
+ }
+
+ if (newVals.tag_operations) {
+ // FIXME: should probably be factored out with alias_operations above
+ var oplist = [];
+ for (var i = 0; i < newVals.tag_operations.length; i++) {
+ var tag_operation = newVals.tag_operations[i];
+ switch (tag_operation.type) {
+ case 'put':
+ oplist.push(
+ MatrixClientPeg.get().setRoomTag(
+ this.props.roomId, tag_operation.tag, {}
+ )
+ );
+ break;
+ case 'delete':
+ oplist.push(
+ MatrixClientPeg.get().deleteRoomTag(
+ this.props.roomId, tag_operation.tag
+ )
+ );
+ break;
+ default:
+ console.log("Unknown tag operation, ignoring: " + tag_operation.type);
+ }
+ }
+
+ if (oplist.length) {
+ var deferred = oplist[0];
+ oplist.splice(1).forEach(function (f) {
+ deferred = deferred.then(f);
+ });
+ deferreds.push(deferred);
+ }
+ }
+
+ if (old_canonical_alias !== newVals.canonical_alias) {
deferreds.push(
MatrixClientPeg.get().sendStateEvent(
- this.state.room.roomId, "m.room.power_levels", new_power_levels, ""
+ this.state.room.roomId, "m.room.canonical_alias", {
+ alias: newVals.canonical_alias
+ }, ""
+ )
+ );
+ }
+
+ if (newVals.color_scheme) {
+ deferreds.push(
+ MatrixClientPeg.get().setRoomAccountData(
+ this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme
)
);
}
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()
+ q.allSettled(deferreds).then(
+ function(results) {
+ var fails = results.filter(function(result) { return result.state !== "fulfilled" });
+ if (fails.length) {
+ fails.forEach(function(result) {
+ console.error(result.reason);
+ });
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to set state",
+ description: fails.map(function(result) { return result.reason }).join("\n"),
+ });
+ self.refs.room_settings.resetState();
+ }
+ else {
+ self.setState({
+ editingRoomSettings: false
+ });
+ }
+ }).finally(function() {
+ self.setState({
+ uploadingRoomSettings: false,
+ });
});
- }).finally(function() {
- self.setState({
- uploadingRoomSettings: false,
- });
- });
} else {
this.setState({
editingRoomSettings: false,
@@ -815,8 +1168,15 @@ module.exports = React.createClass({
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
if (lastReadEventIndex === null) return;
- if (lastReadEventIndex > currentReadUpToEventIndex) {
- MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]);
+ var lastReadEvent = this.state.room.timeline[lastReadEventIndex];
+
+ // we also remember the last read receipt we sent to avoid spamming the same one at the server repeatedly
+ if (lastReadEventIndex > currentReadUpToEventIndex && this.last_rr_sent_event_id != lastReadEvent.getId()) {
+ this.last_rr_sent_event_id = lastReadEvent.getId();
+ MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
+ // it failed, so allow retries next time the user is active
+ this.last_rr_sent_event_id = undefined;
+ });
}
},
@@ -847,31 +1207,32 @@ module.exports = React.createClass({
},
onSettingsClick: function() {
- this.setState({editingRoomSettings: true});
+ this.showSettings(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
- );
+ this.uploadNewState({
+ name: this.refs.header.getRoomName(),
+ topic: this.refs.header.getTopic(),
+ join_rule: this.refs.room_settings.getJoinRules(),
+ history_visibility: this.refs.room_settings.getHistoryVisibility(),
+ are_notifications_muted: this.refs.room_settings.areNotificationsMuted(),
+ power_levels: this.refs.room_settings.getPowerLevels(),
+ alias_operations: this.refs.room_settings.getAliasOperations(),
+ tag_operations: this.refs.room_settings.getTagOperations(),
+ canonical_alias: this.refs.room_settings.getCanonicalAlias(),
+ guest_join: this.refs.room_settings.canGuestsJoin(),
+ guest_read: this.refs.room_settings.canGuestsRead(),
+ color_scheme: this.refs.room_settings.getColorScheme(),
+ });
},
onCancelClick: function() {
+ this.updateTint();
this.setState({editingRoomSettings: false});
},
@@ -1019,20 +1380,32 @@ module.exports = React.createClass({
// 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;
+
+ // header + footer + status + give us at least 120px of scrollback at all times.
+ var auxPanelMaxHeight = window.innerHeight -
+ (83 + // height of RoomHeader
+ 36 + // height of the status area
+ 72 + // minimum height of the message compmoser
+ (this.state.editingRoomSettings ? (window.innerHeight * 0.3) : 120)); // amount of desired scrollback
+
+ // 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;
+
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;
+ var video = this.refs.callView.getVideoView().getRemoteVideoElement();
video.style.maxHeight = auxPanelMaxHeight + "px";
}
+
+ // we need to do this for general auxPanels too
+ if (this.refs.auxPanel) {
+ this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px";
+ }
+
+ // the above might have made the aux panel resize itself, so now
+ // we need to tell the gemini panel to adapt.
+ this.onChildResize();
},
onFullscreenClick: function() {
@@ -1066,6 +1439,24 @@ module.exports = React.createClass({
});
},
+ onChildResize: function() {
+ // When the video or the message composer resizes, the scroll panel
+ // also changes size. Work around GeminiScrollBar fail by telling it
+ // about it. This also ensures that the scroll offset is updated.
+ if (this.refs.messagePanel) {
+ this.refs.messagePanel.forceUpdate();
+ }
+ },
+
+ showSettings: function(show) {
+ // XXX: this is a bit naughty; we should be doing this via props
+ if (show) {
+ this.setState({editingRoomSettings: true});
+ var self = this;
+ setTimeout(function() { self.onResize() }, 0);
+ }
+ },
+
render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer');
@@ -1073,15 +1464,35 @@ module.exports = React.createClass({
var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
+ var TintableSvg = sdk.getComponent("elements.TintableSvg");
+ var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
if (!this.state.room) {
if (this.props.roomId) {
- return (
-
+ );
+ }
+ }
+ else {
return (
);
@@ -1102,19 +1513,26 @@ module.exports = React.createClass({
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
+ // FIXME: This comment is now outdated - what do we need to fix? ^
var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : "";
+
+ // We deliberately don't try to peek into invites, even if we have permission to peek
+ // as they could be a spam vector.
+ // XXX: in future we could give the option of a 'Preview' button which lets them view anyway.
+
return (
}
-
// if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it.
var searchResultsPanel;
@@ -1339,7 +1772,7 @@ module.exports = React.createClass({
);
return (
-
- );
+ if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) {
+ // show the label
+ editable_el =
{ this.props.label || this.props.initialValue }
;
+ } else {
+ // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
+ editable_el = ;
}
- return (
-
- {editable_el}
-
- );
+ return editable_el;
}
});
diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js
new file mode 100644
index 0000000000..c47c9f3809
--- /dev/null
+++ b/src/components/views/elements/PowerSelector.js
@@ -0,0 +1,108 @@
+/*
+Copyright 2015, 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var roles = {
+ 0: 'User',
+ 50: 'Moderator',
+ 100: 'Admin',
+};
+
+var reverseRoles = {};
+Object.keys(roles).forEach(function(key) {
+ reverseRoles[roles[key]] = key;
+});
+
+module.exports = React.createClass({
+ displayName: 'PowerSelector',
+
+ propTypes: {
+ value: React.PropTypes.number.isRequired,
+ disabled: React.PropTypes.bool,
+ onChange: React.PropTypes.func,
+ },
+
+ getInitialState: function() {
+ return {
+ custom: (roles[this.props.value] === undefined),
+ };
+ },
+
+ onSelectChange: function(event) {
+ this.state.custom = (event.target.value === "Custom");
+ this.props.onChange(this.getValue());
+ },
+
+ onCustomBlur: function(event) {
+ this.props.onChange(this.getValue());
+ },
+
+ onCustomKeyDown: function(event) {
+ if (event.key == "Enter") {
+ this.props.onChange(this.getValue());
+ }
+ },
+
+ getValue: function() {
+ var value;
+ if (this.refs.select) {
+ value = reverseRoles[ this.refs.select.value ];
+ if (this.refs.custom) {
+ if (value === undefined) value = parseInt( this.refs.custom.value );
+ }
+ }
+ return value;
+ },
+
+ render: function() {
+ var customPicker;
+ if (this.state.custom) {
+ var input;
+ if (this.props.disabled) {
+ input = { this.props.value }
+ }
+ else {
+ input =
+ }
+ customPicker = of { input };
+ }
+
+ var selectValue = roles[this.props.value] || "Custom";
+ var select;
+ if (this.props.disabled) {
+ select = { selectValue };
+ }
+ else {
+ select =
+
+ }
+
+ return (
+
+ { select }
+ { customPicker }
+
+ );
+ }
+});
diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js
new file mode 100644
index 0000000000..81fbed1921
--- /dev/null
+++ b/src/components/views/elements/TintableSvg.js
@@ -0,0 +1,69 @@
+/*
+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 ReactDOM = require("react-dom");
+var dis = require("../../../dispatcher");
+var Tinter = require("../../../Tinter");
+
+module.exports = React.createClass({
+ displayName: 'TintableSvg',
+
+ propTypes: {
+ src: React.PropTypes.string.isRequired,
+ width: React.PropTypes.string.isRequired,
+ height: React.PropTypes.string.isRequired,
+ className: React.PropTypes.string,
+ },
+
+ componentWillMount: function() {
+ this.fixups = [];
+ this.dispatcherRef = dis.register(this.onAction);
+ },
+
+ componentDidMount: function() {
+ // we can't use onLoad on object due to https://github.com/facebook/react/pull/5781
+ // so handle it with pure DOM instead
+ ReactDOM.findDOMNode(this).addEventListener('load', this.onLoad);
+ },
+
+ componentWillUnmount: function() {
+ ReactDOM.findDOMNode(this).removeEventListener('load', this.onLoad);
+ dis.unregister(this.dispatcherRef);
+ },
+
+ onAction: function(payload) {
+ if (payload.action !== 'tint_update') return;
+ Tinter.applySvgFixups(this.fixups);
+ },
+
+ onLoad: function(event) {
+ this.fixups = Tinter.calcSvgFixups([event.target]);
+ Tinter.applySvgFixups(this.fixups);
+ },
+
+ render: function() {
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js
index 8a67dfd7e6..dc6a49abd6 100644
--- a/src/components/views/login/CustomServerDialog.js
+++ b/src/components/views/login/CustomServerDialog.js
@@ -22,7 +22,7 @@ module.exports = React.createClass({
render: function() {
return (
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index fe763d06bf..e3613ef9a3 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -36,6 +36,9 @@ module.exports = React.createClass({
},
componentDidUpdate: function() {
+ // XXX: why don't we linkify here?
+ // XXX: why do we bother doing this on update at all, given events are immutable?
+
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
},
diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js
new file mode 100644
index 0000000000..ed0e5cbc41
--- /dev/null
+++ b/src/components/views/rooms/EntityTile.js
@@ -0,0 +1,139 @@
+/*
+Copyright 2015, 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var sdk = require('../../../index');
+
+
+var PRESENCE_CLASS = {
+ "offline": "mx_EntityTile_offline",
+ "online": "mx_EntityTile_online",
+ "unavailable": "mx_EntityTile_unavailable"
+};
+
+module.exports = React.createClass({
+ displayName: 'EntityTile',
+
+ propTypes: {
+ name: React.PropTypes.string,
+ title: React.PropTypes.string,
+ avatarJsx: React.PropTypes.any, //
+ presenceState: React.PropTypes.string,
+ presenceActiveAgo: React.PropTypes.number,
+ showInviteButton: React.PropTypes.bool,
+ shouldComponentUpdate: React.PropTypes.func,
+ onClick: React.PropTypes.func
+ },
+
+ getDefaultProps: function() {
+ return {
+ shouldComponentUpdate: function(nextProps, nextState) { return false; },
+ onClick: function() {},
+ presenceState: "offline",
+ presenceActiveAgo: -1,
+ showInviteButton: false,
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ hover: false
+ };
+ },
+
+ shouldComponentUpdate: function(nextProps, nextState) {
+ if (this.state.hover !== nextState.hover) return true;
+ return this.props.shouldComponentUpdate(nextProps, nextState);
+ },
+
+ mouseEnter: function(e) {
+ this.setState({ 'hover': true });
+ },
+
+ mouseLeave: function(e) {
+ this.setState({ 'hover': false });
+ },
+
+ render: function() {
+ var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline";
+ var mainClassName = "mx_EntityTile ";
+ mainClassName += presenceClass;
+ if (this.state.hover) {
+ mainClassName += " mx_EntityTile_hover";
+ }
+
+ var nameEl;
+ if (this.state.hover) {
+ var PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
+ nameEl = (
+
+
+
{ this.props.name }
+
+
+ );
+ }
+ else {
+ nameEl = (
+
+ { this.props.name }
+
+ );
+ }
+
+ var inviteButton;
+ if (this.props.showInviteButton) {
+ inviteButton = (
+
+
+
+ );
+ }
+
+ var power;
+ var powerLevel = this.props.powerLevel;
+ if (powerLevel >= 50 && powerLevel < 99) {
+ power = ;
+ }
+ if (powerLevel >= 99) {
+ power = ;
+ }
+
+
+ var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
+ var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
+
+ var av = this.props.avatarJsx || ;
+
+ return (
+
+
+ { av }
+ { power }
+
+ { nameEl }
+ { inviteButton }
+
+ );
+ }
+});
diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js
index b5f0b88b40..a8a601c2d6 100644
--- a/src/components/views/rooms/MemberInfo.js
+++ b/src/components/views/rooms/MemberInfo.js
@@ -58,15 +58,16 @@ module.exports = React.createClass({
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
MatrixClientPeg.get().kick(roomId, target).done(function() {
- // NO-OP; rely on the m.room.member event coming down else we could
- // get out of sync if we force setState here!
- console.log("Kick success");
- }, function(err) {
- Modal.createDialog(ErrorDialog, {
- title: "Kick error",
- description: err.message
- });
- });
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ console.log("Kick success");
+ }, function(err) {
+ Modal.createDialog(ErrorDialog, {
+ title: "Kick error",
+ description: err.message
+ });
+ }
+ );
this.props.onFinished();
},
@@ -74,16 +75,18 @@ module.exports = React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
- MatrixClientPeg.get().ban(roomId, target).done(function() {
- // NO-OP; rely on the m.room.member event coming down else we could
- // get out of sync if we force setState here!
- console.log("Ban success");
- }, function(err) {
- Modal.createDialog(ErrorDialog, {
- title: "Ban error",
- description: err.message
- });
- });
+ MatrixClientPeg.get().ban(roomId, target).done(
+ function() {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ console.log("Ban success");
+ }, function(err) {
+ Modal.createDialog(ErrorDialog, {
+ title: "Ban error",
+ description: err.message
+ });
+ }
+ );
this.props.onFinished();
},
@@ -118,16 +121,17 @@ module.exports = React.createClass({
}
MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
- function() {
- // NO-OP; rely on the m.room.member event coming down else we could
- // get out of sync if we force setState here!
- console.log("Mute toggle success");
- }, function(err) {
- Modal.createDialog(ErrorDialog, {
- title: "Mute error",
- description: err.message
- });
- });
+ function() {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ console.log("Mute toggle success");
+ }, function(err) {
+ Modal.createDialog(ErrorDialog, {
+ title: "Mute error",
+ description: err.message
+ });
+ }
+ );
this.props.onFinished();
},
@@ -154,22 +158,55 @@ module.exports = React.createClass({
}
var defaultLevel = powerLevelEvent.getContent().users_default;
var modLevel = me.powerLevel - 1;
+ if (modLevel > 50 && defaultLevel < 50) modLevel = 50; // try to stick with the vector level defaults
// toggle the level
var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
- function() {
- // NO-OP; rely on the m.room.member event coming down else we could
- // get out of sync if we force setState here!
- console.log("Mod toggle success");
- }, function(err) {
- Modal.createDialog(ErrorDialog, {
- title: "Mod error",
- description: err.message
- });
- });
+ function() {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ console.log("Mod toggle success");
+ }, function(err) {
+ Modal.createDialog(ErrorDialog, {
+ title: "Mod error",
+ description: err.message
+ });
+ }
+ );
this.props.onFinished();
},
+ onPowerChange: function(powerLevel) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ var roomId = this.props.member.roomId;
+ var target = this.props.member.userId;
+ var room = MatrixClientPeg.get().getRoom(roomId);
+ if (!room) {
+ this.props.onFinished();
+ return;
+ }
+ var powerLevelEvent = room.currentState.getStateEvents(
+ "m.room.power_levels", ""
+ );
+ if (!powerLevelEvent) {
+ this.props.onFinished();
+ return;
+ }
+ MatrixClientPeg.get().setPowerLevel(roomId, target, powerLevel, powerLevelEvent).done(
+ function() {
+ // NO-OP; rely on the m.room.member event coming down else we could
+ // get out of sync if we force setState here!
+ console.log("Power change success");
+ }, function(err) {
+ Modal.createDialog(ErrorDialog, {
+ title: "Failure to change power level",
+ description: err.message
+ });
+ }
+ );
+ this.props.onFinished();
+ },
+
onChatClick: function() {
// check if there are any existing rooms with just us and them (1:1)
// If so, just view that room. If not, create a private room with them.
@@ -209,20 +246,22 @@ module.exports = React.createClass({
MatrixClientPeg.get().createRoom({
invite: [this.props.member.userId],
preset: "private_chat"
- }).done(function(res) {
- self.setState({ creatingRoom: false });
- dis.dispatch({
- action: 'view_room',
- room_id: res.room_id
- });
- self.props.onFinished();
- }, function(err) {
- self.setState({ creatingRoom: false });
- console.error(
- "Failed to create room: %s", JSON.stringify(err)
- );
- self.props.onFinished();
- });
+ }).done(
+ function(res) {
+ self.setState({ creatingRoom: false });
+ dis.dispatch({
+ action: 'view_room',
+ room_id: res.room_id
+ });
+ self.props.onFinished();
+ }, function(err) {
+ self.setState({ creatingRoom: false });
+ console.error(
+ "Failed to create room: %s", JSON.stringify(err)
+ );
+ self.props.onFinished();
+ }
+ );
}
},
@@ -291,9 +330,15 @@ module.exports = React.createClass({
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
powerLevels.state_default
);
+ var levelToSend = (
+ (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
+ powerLevels.events_default
+ );
+
can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban;
can.mute = me.powerLevel >= editPowerLevel;
+ can.toggleMod = me.powerLevel > them.powerLevel && them.powerLevel >= levelToSend;
can.modifyLevel = me.powerLevel > them.powerLevel;
return can;
},
@@ -317,12 +362,11 @@ module.exports = React.createClass({
},
render: function() {
- var interactButton, kickButton, banButton, muteButton, giveModButton, spinner;
- if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) {
- interactButton =
Leave room
;
- }
- else {
- interactButton =
Start chat
;
+ var startChat, kickButton, banButton, muteButton, giveModButton, spinner;
+ if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
+ // FIXME: we're referring to a vector component from react-sdk
+ var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile');
+ startChat =
}
if (this.state.creatingRoom) {
@@ -346,35 +390,56 @@ module.exports = React.createClass({
{muteLabel}
;
}
- if (this.state.can.modifyLevel) {
- var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod";
+ if (this.state.can.toggleMod) {
+ var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator";
giveModButton =
{giveOpLabel}
}
+ // TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
+ // e.g. clicking on a linkified userid in a room
+
+ var adminTools;
+ if (kickButton || banButton || muteButton || giveModButton) {
+ adminTools =
+
{ hangupButton }
diff --git a/src/components/views/rooms/PresenceLabel.js b/src/components/views/rooms/PresenceLabel.js
new file mode 100644
index 0000000000..4ecad5b3df
--- /dev/null
+++ b/src/components/views/rooms/PresenceLabel.js
@@ -0,0 +1,84 @@
+/*
+Copyright 2015, 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var sdk = require('../../../index');
+
+module.exports = React.createClass({
+ displayName: 'PresenceLabel',
+
+ propTypes: {
+ activeAgo: React.PropTypes.number,
+ presenceState: React.PropTypes.string
+ },
+
+ getDefaultProps: function() {
+ return {
+ ago: -1,
+ presenceState: null
+ };
+ },
+
+ getDuration: function(time) {
+ if (!time) return;
+ var t = parseInt(time / 1000);
+ var s = t % 60;
+ var m = parseInt(t / 60) % 60;
+ var h = parseInt(t / (60 * 60)) % 24;
+ var d = parseInt(t / (60 * 60 * 24));
+ if (t < 60) {
+ if (t < 0) {
+ return "0s";
+ }
+ return s + "s";
+ }
+ if (t < 60 * 60) {
+ return m + "m";
+ }
+ if (t < 24 * 60 * 60) {
+ return h + "h";
+ }
+ return d + "d ";
+ },
+
+ getPrettyPresence: function(presence) {
+ if (presence === "online") return "Online";
+ if (presence === "unavailable") return "Idle"; // XXX: is this actually right?
+ if (presence === "offline") return "Offline";
+ return "Unknown";
+ },
+
+ render: function() {
+ if (this.props.activeAgo >= 0) {
+ return (
+
+ { this.getPrettyPresence(this.props.presenceState) } { this.getDuration(this.props.activeAgo) } ago
+
}
else {
- var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
-
var name = null;
var searchStatus = null;
var topic_el = null;
var cancel_button = null;
var save_button = null;
var settings_button = null;
- var actual_name = this.props.room.currentState.getStateEvents('m.room.name', '');
- if (actual_name) actual_name = actual_name.getContent().name;
if (this.props.editing) {
- name =
-
-
-
- // if (topic) topic_el =
- cancel_button =
Cancel
- save_button =
Save Changes
- } else {
- //
+ // calculate permissions. XXX: this should be done on mount or something, and factored out with RoomSettings
+ var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
+ var events_levels = (power_levels ? power_levels.events : {}) || {};
+ var user_id = MatrixClientPeg.get().credentials.userId;
+
+ if (power_levels) {
+ power_levels = power_levels.getContent();
+ var default_user_level = parseInt(power_levels.users_default || 0);
+ var user_levels = power_levels.users || {};
+ var current_user_level = user_levels[user_id];
+ if (current_user_level == undefined) current_user_level = default_user_level;
+ } else {
+ var default_user_level = 0;
+ var user_levels = [];
+ var current_user_level = 0;
+ }
+ var state_default = parseInt((power_levels ? power_levels.state_default : 0) || 0);
+
+ var room_avatar_level = state_default;
+ if (events_levels['m.room.avatar'] !== undefined) {
+ room_avatar_level = events_levels['m.room.avatar'];
+ }
+ var can_set_room_avatar = current_user_level >= room_avatar_level;
+
+ var room_name_level = state_default;
+ if (events_levels['m.room.name'] !== undefined) {
+ room_name_level = events_levels['m.room.name'];
+ }
+ var can_set_room_name = current_user_level >= room_name_level;
+
+ var room_topic_level = state_default;
+ if (events_levels['m.room.topic'] !== undefined) {
+ room_topic_level = events_levels['m.room.topic'];
+ }
+ var can_set_room_topic = current_user_level >= room_topic_level;
+
+ var placeholderName = "Unnamed Room";
+ if (this.state.defaultName && this.state.defaultName !== 'Empty room') {
+ placeholderName += " (" + this.state.defaultName + ")";
+ }
+
+ save_button =
Save
+ cancel_button =
+ }
+
+ if (can_set_room_name) {
+ name =
+
+
+
+ }
+ else {
var searchStatus;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
@@ -114,39 +218,93 @@ module.exports = React.createClass({
searchStatus =
(~{ this.props.searchInfo.searchCount } results)
;
}
+ // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
+ var members = this.props.room.getJoinedMembers();
+ var settingsHint = false;
+ if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
+ var name = this.props.room.currentState.getStateEvents('m.room.name', '');
+ if (!name || !name.getContent().name) {
+ settingsHint = true;
+ }
+ }
+
name =
-
{ this.props.room.name }
+
{ this.props.room.name }
{ searchStatus }
-
-
+
+
- if (topic) topic_el =
{ topic.getContent().topic }
;
+ }
+
+ if (can_set_room_topic) {
+ topic_el =
+
+ } else {
+ var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
+ if (topic) topic_el =
{ topic.getContent().topic }
;
}
var roomAvatar = null;
if (this.props.room) {
- roomAvatar = (
-
- );
+ if (can_set_room_avatar) {
+ roomAvatar = (
+
+
+
+
+
+
+
+
+
+ );
+ }
+ else {
+ roomAvatar = (
+
+
+
+ );
+ }
}
var leave_button;
if (this.props.onLeaveClick) {
leave_button =
-
-
+
+
;
}
var forget_button;
if (this.props.onForgetClick) {
forget_button =
-
-
+
+
+
;
+ }
+
+ var right_row;
+ if (!this.props.editing) {
+ right_row =
+
);
diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js
new file mode 100644
index 0000000000..52e6639f13
--- /dev/null
+++ b/src/components/views/rooms/RoomPreviewBar.js
@@ -0,0 +1,83 @@
+/*
+Copyright 2015, 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+module.exports = React.createClass({
+ displayName: 'RoomPreviewBar',
+
+ propTypes: {
+ onJoinClick: React.PropTypes.func,
+ onRejectClick: React.PropTypes.func,
+ inviterName: React.PropTypes.string,
+ canJoin: React.PropTypes.bool,
+ canPreview: React.PropTypes.bool,
+ },
+
+ getDefaultProps: function() {
+ return {
+ onJoinClick: function() {},
+ canJoin: false,
+ canPreview: true,
+ };
+ },
+
+ render: function() {
+ var joinBlock, previewBlock;
+
+ if (this.props.inviterName) {
+ joinBlock = (
+
+
+ You have been invited to join this room by { this.props.inviterName }
+
+
+ Would you like to accept or decline this invitation?
+
{entry.getImageJsx()}
{entry.getText()}
diff --git a/src/components/views/rooms/UserTile.js b/src/components/views/rooms/UserTile.js
new file mode 100644
index 0000000000..6597796764
--- /dev/null
+++ b/src/components/views/rooms/UserTile.js
@@ -0,0 +1,56 @@
+/*
+Copyright 2015, 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.
+*/
+
+'use strict';
+
+var React = require('react');
+
+var Avatar = require("../../../Avatar");
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var sdk = require('../../../index');
+var dis = require('../../../dispatcher');
+var Modal = require("../../../Modal");
+
+module.exports = React.createClass({
+ displayName: 'UserTile',
+
+ propTypes: {
+ user: React.PropTypes.any.isRequired // User
+ },
+
+ render: function() {
+ var EntityTile = sdk.getComponent("rooms.EntityTile");
+ var user = this.props.user;
+ var name = user.displayName || user.userId;
+ var active = -1;
+
+ // FIXME: make presence data update whenever User.presence changes...
+ active = (
+ (Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) || -1
+ );
+
+ var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
+ var avatarJsx = (
+
+ );
+
+ return (
+
+ );
+ }
+});
diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js
index f024f53a9a..89303856b2 100644
--- a/src/components/views/settings/ChangeAvatar.js
+++ b/src/components/views/settings/ChangeAvatar.js
@@ -25,6 +25,8 @@ module.exports = React.createClass({
room: React.PropTypes.object,
// if false, you need to call changeAvatar.onFileSelected yourself.
showUploadSection: React.PropTypes.bool,
+ width: React.PropTypes.number,
+ height: React.PropTypes.number,
className: React.PropTypes.string
},
@@ -37,7 +39,9 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
showUploadSection: true,
- className: "mx_Dialog_content" // FIXME - shouldn't be this by default
+ className: "",
+ width: 80,
+ height: 80,
};
},
@@ -111,13 +115,14 @@ module.exports = React.createClass({
// 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 = ;
+ avatarImg = ;
} else {
var style = {
- maxWidth: 320,
- maxHeight: 240,
+ width: this.props.width,
+ height: this.props.height,
+ objectFit: 'cover',
};
- avatarImg = ;
+ avatarImg = ;
}
var uploadSection;
diff --git a/src/components/views/settings/ChangeDisplayName.js b/src/components/views/settings/ChangeDisplayName.js
index 8b31fbf1e3..ed5eb3fa42 100644
--- a/src/components/views/settings/ChangeDisplayName.js
+++ b/src/components/views/settings/ChangeDisplayName.js
@@ -99,7 +99,9 @@ module.exports = React.createClass({
var EditableText = sdk.getComponent('elements.EditableText');
return (
);
}
diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js
index bc82556163..ed44313b9e 100644
--- a/src/components/views/voip/CallView.js
+++ b/src/components/views/voip/CallView.js
@@ -33,6 +33,12 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'CallView',
+ propTypes: {
+ // a callback which is called when the video within the callview
+ // due to a change in video metadata
+ onResize: React.PropTypes.func,
+ },
+
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
if (this.props.room) {
@@ -97,7 +103,7 @@ module.exports = React.createClass({
render: function(){
var VideoView = sdk.getComponent('voip.VideoView');
return (
-
+
);
}
});
diff --git a/src/components/views/voip/VideoFeed.js b/src/components/views/voip/VideoFeed.js
index e4833dba9f..c4a65d1145 100644
--- a/src/components/views/voip/VideoFeed.js
+++ b/src/components/views/voip/VideoFeed.js
@@ -21,9 +21,29 @@ var React = require('react');
module.exports = React.createClass({
displayName: 'VideoFeed',
+ propTypes: {
+ // a callback which is called when the video element is resized
+ // due to a change in video metadata
+ onResize: React.PropTypes.func,
+ },
+
+ componentDidMount() {
+ this.refs.vid.addEventListener('resize', this.onResize);
+ },
+
+ componentWillUnmount() {
+ this.refs.vid.removeEventListener('resize', this.onResize);
+ },
+
+ onResize: function(e) {
+ if(this.props.onResize) {
+ this.props.onResize(e);
+ }
+ },
+
render: function() {
return (
-