From 6cd07731c4b35bcaae70d026d3890f7b36a6e87e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 14:37:47 -0600 Subject: [PATCH 1/8] Add MemberPresenceAvatar and control presence ourselves Includes rudimentary support for custom statuses and user-controlled status. Some minor tweaks have also been made to better control how we advertise our presence. Signed-off-by: Travis Ralston --- src/MatrixClientPeg.js | 1 + src/Presence.js | 39 ++++- .../views/avatars/MemberPresenceAvatar.js | 135 ++++++++++++++++++ src/components/views/rooms/MessageComposer.js | 4 +- 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/components/views/avatars/MemberPresenceAvatar.js 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/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js new file mode 100644 index 0000000000..e90f2e3e62 --- /dev/null +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -0,0 +1,135 @@ +/* + 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"; + +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; + return { + status: presenceState, + }; + }, + + 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, + }); + }, + + onClick: function() { + if (Presence.getState() === "online") { + Presence.setState("unavailable", "This is a message", true); + } else { + Presence.stopMaintainingStatus(); + } + console.log("CLICK"); + + 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; + + console.log({ + presenceState, + presenceLastActiveAgo, + presenceLastTs, + presenceCurrentlyActive, + presenceMessage, + }); + }, + + render: function() { + const MemberAvatar = sdk.getComponent("avatars.MemberAvatar"); + + let onClickFn = null; + if (this.props.member.userId === MatrixClientPeg.get().getUserId()) { + onClickFn = this.onClick; + } + + const avatarNode = ( + + ); + const statusNode = ( + + ); + + 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 8e27520d89..d06cd76bdb 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(
- +
, ); From 0b20681f6a2963a260f4c2031123a5b3975995d4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 19:13:46 -0600 Subject: [PATCH 2/8] Put presence management behind a labs setting Signed-off-by: Travis Ralston --- src/UserSettingsStore.js | 4 ++++ src/components/views/avatars/MemberPresenceAvatar.js | 9 ++++++++- src/i18n/strings/en_EN.json | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index b274e6a594..cb4d184eff 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -34,6 +34,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/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js index e90f2e3e62..486688250e 100644 --- a/src/components/views/avatars/MemberPresenceAvatar.js +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -22,6 +22,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg"; import AccessibleButton from '../elements/AccessibleButton'; import Presence from "../../../Presence"; import dispatcher from "../../../dispatcher"; +import UserSettingsStore from "../../../UserSettingsStore"; module.exports = React.createClass({ displayName: 'MemberPresenceAvatar', @@ -112,10 +113,16 @@ module.exports = React.createClass({ ); - const statusNode = ( + let statusNode = ( ); + // LABS: Disable presence management functions for now + if (!UserSettingsStore.isFeatureEnabled("feature_presence_management")) { + statusNode = null; + onClickFn = null; + } + let avatar = (
{avatarNode} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index df236636a2..1a9e59e3cd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -620,6 +620,7 @@ "Cancel": "Cancel", "or": "or", "Message Pinning": "Message Pinning", + "Presence Management": "Presence Management", "Active call": "Active call", "Monday": "Monday", "Tuesday": "Tuesday", From 788e16a716dcef588f5d73cfc799df3dd91972c4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 20:23:50 -0600 Subject: [PATCH 3/8] Linting Signed-off-by: Travis Ralston --- src/components/views/avatars/MemberPresenceAvatar.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js index 486688250e..8005dcd405 100644 --- a/src/components/views/avatars/MemberPresenceAvatar.js +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -111,10 +111,10 @@ module.exports = React.createClass({ const avatarNode = ( + resizeMethod={this.props.resizeMethod} /> ); let statusNode = ( - + ); // LABS: Disable presence management functions for now @@ -125,15 +125,15 @@ module.exports = React.createClass({ let avatar = (
- {avatarNode} - {statusNode} + { avatarNode } + { statusNode }
); if (onClickFn) { avatar = ( - {avatarNode} - {statusNode} + { avatarNode } + { statusNode } ); } From 03800b747608873f242446a330ba2387db2f871d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 21:43:47 -0600 Subject: [PATCH 4/8] Support more positioning options on context menus Signed-off-by: Travis Ralston --- src/components/structures/ContextualMenu.js | 50 ++++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) 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 = {}; From c4837172821bed0be652abd64663c46c031f60c0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 21:44:07 -0600 Subject: [PATCH 5/8] Make onClick be a context menu for presence Signed-off-by: Travis Ralston --- .../views/avatars/MemberPresenceAvatar.js | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js index 8005dcd405..de7c28154f 100644 --- a/src/components/views/avatars/MemberPresenceAvatar.js +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -23,6 +23,7 @@ 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', @@ -44,8 +45,10 @@ module.exports = React.createClass({ getInitialState: function() { const presenceState = this.props.member.user.presence; + const presenceMessage = this.props.member.user.presenceStatusMsg; return { status: presenceState, + message: presenceMessage, }; }, @@ -78,27 +81,38 @@ module.exports = React.createClass({ }); }, - onClick: function() { - if (Presence.getState() === "online") { - Presence.setState("unavailable", "This is a message", true); - } else { - Presence.stopMaintainingStatus(); - } - console.log("CLICK"); + onStatusChange: function(newStatus) { + console.log(this.state); + console.log(newStatus); + }, - 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; + onClick: function(e) { + const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu'); + const elementRect = e.target.getBoundingClientRect(); - console.log({ - presenceState, - presenceLastActiveAgo, - presenceLastTs, - presenceCurrentlyActive, - presenceMessage, + // 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 + + const self = this; + ContextualMenu.createMenu(PresenceContextMenu, { + chevronOffset: chevronOffset, + chevronFace: 'bottom', + left: x, + top: y, + menuWidth: 300, + 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() { From 7307bc412f794e06f4035c9210e6394f9158ce17 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 14 Oct 2017 23:16:12 -0600 Subject: [PATCH 6/8] Respond to updates from presence context menu Signed-off-by: Travis Ralston --- src/components/views/avatars/MemberPresenceAvatar.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js index de7c28154f..19342f3492 100644 --- a/src/components/views/avatars/MemberPresenceAvatar.js +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -82,8 +82,10 @@ module.exports = React.createClass({ }, onStatusChange: function(newStatus) { - console.log(this.state); - console.log(newStatus); + Presence.stopMaintainingStatus(); + if (newStatus === "online") { + Presence.setState(newStatus); + } else Presence.setState(newStatus, null, true); }, onClick: function(e) { @@ -96,13 +98,12 @@ module.exports = React.createClass({ let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset; y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron - const self = this; ContextualMenu.createMenu(PresenceContextMenu, { chevronOffset: chevronOffset, chevronFace: 'bottom', left: x, top: y, - menuWidth: 300, + menuWidth: 125, currentStatus: this.state.status, onChange: this.onStatusChange, }); From 5c9970950bde6581fae7623fd8b656b47e81f2ba Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 25 Oct 2017 11:21:12 -0600 Subject: [PATCH 7/8] Undo i18n change to make merge easier Signed-off-by: Travis Ralston --- src/i18n/strings/en_EN.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1a9e59e3cd..df236636a2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -620,7 +620,6 @@ "Cancel": "Cancel", "or": "or", "Message Pinning": "Message Pinning", - "Presence Management": "Presence Management", "Active call": "Active call", "Monday": "Monday", "Tuesday": "Tuesday", From 24000ee45689d397f35249ec8c66418e10b5741b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 25 Oct 2017 11:23:04 -0600 Subject: [PATCH 8/8] Re-add i18n for labs Signed-off-by: Travis Ralston --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index fbff51299a..a33560a052 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -152,6 +152,7 @@ "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s", "Communities": "Communities", "Message Pinning": "Message Pinning", + "Presence Management": "Presence Management", "%(displayName)s is typing": "%(displayName)s is typing", "%(names)s and one other are typing": "%(names)s and one other are typing", "%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing",