diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 0c3d5b3775..7a4f0b99b0 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -93,6 +93,7 @@ class MatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; + opts.disablePresence = true; // we do this manually try { const promise = this.matrixClient.store.startup(); diff --git a/src/Presence.js b/src/Presence.js index fab518e1cb..2652c64c96 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -56,13 +56,27 @@ class Presence { return this.state; } + /** + * Get the current status message. + * @returns {String} the status message, may be null + */ + getStatusMessage() { + return this.statusMessage; + } + /** * Set the presence state. * If the state has changed, the Home Server will be notified. * @param {string} newState the new presence state (see PRESENCE enum) + * @param {String} statusMessage an optional status message for the presence + * @param {boolean} maintain true to have this status maintained by this tracker */ - setState(newState) { - if (newState === this.state) { + setState(newState, statusMessage=null, maintain=false) { + if (this.maintain) { + // Don't update presence if we're maintaining a particular status + return; + } + if (newState === this.state && statusMessage === this.statusMessage) { return; } if (PRESENCE_STATES.indexOf(newState) === -1) { @@ -72,21 +86,37 @@ class Presence { return; } const old_state = this.state; + const old_message = this.statusMessage; this.state = newState; + this.statusMessage = statusMessage; + this.maintain = maintain; if (MatrixClientPeg.get().isGuest()) { return; // don't try to set presence when a guest; it won't work. } + const updateContent = { + presence: this.state, + status_msg: this.statusMessage ? this.statusMessage : '', + }; + const self = this; - MatrixClientPeg.get().setPresence(this.state).done(function() { + MatrixClientPeg.get().setPresence(updateContent).done(function() { console.log("Presence: %s", newState); + + // We have to dispatch because the js-sdk is unreliable at telling us about our own presence + dis.dispatch({action: "self_presence_updated", statusInfo: updateContent}); }, function(err) { console.error("Failed to set presence: %s", err); self.state = old_state; + self.statusMessage = old_message; }); } + stopMaintainingStatus() { + this.maintain = false; + } + /** * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private @@ -95,7 +125,8 @@ class Presence { this.setState("unavailable"); } - _onUserActivity() { + _onUserActivity(payload) { + if (payload.action === "sync_state" || payload.action === "self_presence_updated") return; this._resetTimer(); } diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 8d60790812..163ef75c50 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -30,6 +30,10 @@ const FEATURES = [ id: 'feature_pinning', name: _td("Message Pinning"), }, + { + id: 'feature_presence_management', + name: _td("Presence Management"), + }, ]; export default { diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index c3ad7f9cd1..3c2308e6a7 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -33,6 +33,7 @@ module.exports = { menuHeight: React.PropTypes.number, chevronOffset: React.PropTypes.number, menuColour: React.PropTypes.string, + chevronFace: React.PropTypes.string, // top, bottom, left, right }, getOrCreateContainer: function() { @@ -58,12 +59,30 @@ module.exports = { } }; - const position = { - top: props.top, - }; + const position = {}; + let chevronFace = null; + + if (props.top) { + position.top = props.top; + } else { + position.bottom = props.bottom; + } + + if (props.left) { + position.left = props.left; + chevronFace = 'left'; + } else { + position.right = props.right; + chevronFace = 'right'; + } const chevronOffset = {}; - if (props.chevronOffset) { + if (props.chevronFace) { + chevronFace = props.chevronFace; + } + if (chevronFace === 'top' || chevronFace === 'bottom') { + chevronOffset.left = props.chevronOffset; + } else { chevronOffset.top = props.chevronOffset; } @@ -74,28 +93,27 @@ module.exports = { .mx_ContextualMenu_chevron_left:after { border-right-color: ${props.menuColour}; } - .mx_ContextualMenu_chevron_right:after { border-left-color: ${props.menuColour}; } + .mx_ContextualMenu_chevron_top:after { + border-left-color: ${props.menuColour}; + } + .mx_ContextualMenu_chevron_bottom:after { + border-left-color: ${props.menuColour}; + } `; } - let chevron = null; - if (props.left) { - chevron =
; - position.left = props.left; - } else { - chevron =
; - position.right = props.right; - } - + const chevron =
; const className = 'mx_ContextualMenu_wrapper'; const menuClasses = classNames({ 'mx_ContextualMenu': true, - 'mx_ContextualMenu_left': props.left, - 'mx_ContextualMenu_right': !props.left, + 'mx_ContextualMenu_left': chevronFace === 'left', + 'mx_ContextualMenu_right': chevronFace === 'right', + 'mx_ContextualMenu_top': chevronFace === 'top', + 'mx_ContextualMenu_bottom': chevronFace === 'bottom', }); const menuStyle = {}; diff --git a/src/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js new file mode 100644 index 0000000000..19342f3492 --- /dev/null +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -0,0 +1,157 @@ +/* + Copyright 2017 Travis Ralston + + 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'; + +import React from "react"; +import * as sdk from "../../../index"; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import AccessibleButton from '../elements/AccessibleButton'; +import Presence from "../../../Presence"; +import dispatcher from "../../../dispatcher"; +import UserSettingsStore from "../../../UserSettingsStore"; +import * as ContextualMenu from "../../structures/ContextualMenu"; + +module.exports = React.createClass({ + displayName: 'MemberPresenceAvatar', + + 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', + }; + }, + + getInitialState: function() { + const presenceState = this.props.member.user.presence; + const presenceMessage = this.props.member.user.presenceStatusMsg; + return { + status: presenceState, + message: presenceMessage, + }; + }, + + componentWillMount: function() { + MatrixClientPeg.get().on("User.presence", this.onUserPresence); + this.dispatcherRef = dispatcher.register(this.onAction); + }, + + componentWillUnmount: function() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("User.presence", this.onUserPresence); + } + dispatcher.unregister(this.dispatcherRef); + }, + + onAction: function(payload) { + if (payload.action !== "self_presence_updated") return; + if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) return; + this.setState({ + status: payload.statusInfo.presence, + message: payload.statusInfo.status_msg, + }); + }, + + onUserPresence: function(event, user) { + if (user.userId !== MatrixClientPeg.get().getUserId()) return; + this.setState({ + status: user.presence, + message: user.presenceStatusMsg, + }); + }, + + onStatusChange: function(newStatus) { + Presence.stopMaintainingStatus(); + if (newStatus === "online") { + Presence.setState(newStatus); + } else Presence.setState(newStatus, null, true); + }, + + onClick: function(e) { + const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu'); + const elementRect = e.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3; + const chevronOffset = 12; + let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron + + ContextualMenu.createMenu(PresenceContextMenu, { + chevronOffset: chevronOffset, + chevronFace: 'bottom', + left: x, + top: y, + menuWidth: 125, + currentStatus: this.state.status, + onChange: this.onStatusChange, + }); + + e.stopPropagation(); + // const presenceState = this.props.member.user.presence; + // const presenceLastActiveAgo = this.props.member.user.lastActiveAgo; + // const presenceLastTs = this.props.member.user.lastPresenceTs; + // const presenceCurrentlyActive = this.props.member.user.currentlyActive; + // const presenceMessage = this.props.member.user.presenceStatusMsg; + }, + + render: function() { + const MemberAvatar = sdk.getComponent("avatars.MemberAvatar"); + + let onClickFn = null; + if (this.props.member.userId === MatrixClientPeg.get().getUserId()) { + onClickFn = this.onClick; + } + + const avatarNode = ( + + ); + let statusNode = ( + + ); + + // LABS: Disable presence management functions for now + if (!UserSettingsStore.isFeatureEnabled("feature_presence_management")) { + statusNode = null; + onClickFn = null; + } + + let avatar = ( +
+ { avatarNode } + { statusNode } +
+ ); + if (onClickFn) { + avatar = ( + + { avatarNode } + { statusNode } + + ); + } + return avatar; + }, +}); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 93f20b8ec3..38d340f6e4 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -238,7 +238,7 @@ export default class MessageComposer extends React.Component { render() { const me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); const uploadInputStyle = {display: 'none'}; - const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + const MemberPresenceAvatar = sdk.getComponent('avatars.MemberPresenceAvatar'); const TintableSvg = sdk.getComponent("elements.TintableSvg"); const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); @@ -246,7 +246,7 @@ export default class MessageComposer extends React.Component { controls.push(
- +
, ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 04b30c1a07..4bcda13db8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -151,6 +151,7 @@ "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s", "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", "Message Pinning": "Message Pinning", + "Presence Management": "Presence Management", "%(displayName)s is typing": "%(displayName)s is typing", "%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing", "%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing",