Merge pull request #1743 from matrix-org/luke/feature-tag-panel-tile-context-menu

Add context menu to TagTile
This commit is contained in:
Luke Barnard 2018-02-13 17:28:43 +00:00 committed by GitHub
commit c670b76ec8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 101 additions and 3 deletions

View file

@ -56,4 +56,51 @@ TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
}); });
}; };
/**
* Creates an action thunk that will do an asynchronous request to
* label a tag as removed in im.vector.web.tag_ordering account data.
*
* The reason this is implemented with new state `removedTags` is that
* we incrementally and initially populate `tags` with groups that
* have been joined. If we remove a group from `tags`, it will just
* get added (as it looks like a group we've recently joined).
*
* NB: If we ever support adding of tags (which is planned), we should
* take special care to remove the tag from `removedTags` when we add
* it.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to remove.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.removeTag = function(matrixClient, tag) {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
// an asynchronous action here, the tag is already removed.
return () => {};
}
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {removedTags};
});
};
export default TagOrderActions; export default TagOrderActions;

View file

@ -21,6 +21,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import ContextualMenu from '../../structures/ContextualMenu';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
@ -81,6 +82,35 @@ export default React.createClass({
}); });
}, },
onContextButtonClick: function(e) {
e.preventDefault();
e.stopPropagation();
// Hide the (...) immediately
this.setState({ hover: false });
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = elementRect.right + window.pageXOffset + 3;
const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
const self = this;
ContextualMenu.createMenu(TagTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
tag: this.props.tag,
onFinished: function() {
self.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
},
onMouseOver: function() { onMouseOver: function() {
this.setState({hover: true}); this.setState({hover: true});
}, },
@ -109,10 +139,15 @@ export default React.createClass({
const tip = this.state.hover ? const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> : <RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<div />; <div />;
const contextButton = this.state.hover || this.state.menuDisplayed ?
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
{ "\u00B7\u00B7\u00B7" }
</div> : <div />;
return <AccessibleButton className={className} onClick={this.onClick}> return <AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}> <div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} /> <BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
{ tip } { tip }
{ contextButton }
</div> </div>
</AccessibleButton>; </AccessibleButton>;
}, },

View file

@ -500,8 +500,8 @@
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",
"Invalid file%(extra)s": "Invalid file%(extra)s", "Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image", "Error decrypting image": "Error decrypting image",
"Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.",
"This image cannot be displayed.": "This image cannot be displayed.", "This image cannot be displayed.": "This image cannot be displayed.",
"Image '%(Body)s' cannot be displayed.": "Image '%(Body)s' cannot be displayed.",
"Error decrypting video": "Error decrypting video", "Error decrypting video": "Error decrypting video",
"%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s",
"%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.",

View file

@ -55,6 +55,7 @@ class TagOrderStore extends Store {
const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {}; const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {};
this._setState({ this._setState({
orderedTagsAccountData: tagOrderingEventContent.tags || null, orderedTagsAccountData: tagOrderingEventContent.tags || null,
removedTagsAccountData: tagOrderingEventContent.removedTags || null,
hasSynced: true, hasSynced: true,
}); });
this._updateOrderedTags(); this._updateOrderedTags();
@ -70,6 +71,7 @@ class TagOrderStore extends Store {
this._setState({ this._setState({
orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null, orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null,
removedTagsAccountData: payload.event_content ? payload.event_content.removedTags : null,
}); });
this._updateOrderedTags(); this._updateOrderedTags();
break; break;
@ -90,6 +92,14 @@ class TagOrderStore extends Store {
}); });
break; break;
} }
case 'TagOrderActions.removeTag.pending': {
// Optimistic update of a removed tag
this._setState({
removedTagsAccountData: payload.request.removedTags,
});
this._updateOrderedTags();
break;
}
case 'select_tag': { case 'select_tag': {
let newTags = []; let newTags = [];
// Shift-click semantics // Shift-click semantics
@ -165,13 +175,15 @@ class TagOrderStore extends Store {
_mergeGroupsAndTags() { _mergeGroupsAndTags() {
const groupIds = this._state.joinedGroupIds || []; const groupIds = this._state.joinedGroupIds || [];
const tags = this._state.orderedTagsAccountData || []; const tags = this._state.orderedTagsAccountData || [];
const removedTags = new Set(this._state.removedTagsAccountData || []);
const tagsToKeep = tags.filter( const tagsToKeep = tags.filter(
(t) => t[0] !== '+' || groupIds.includes(t), (t) => (t[0] !== '+' || groupIds.includes(t)) && !removedTags.has(t),
); );
const groupIdsToAdd = groupIds.filter( const groupIdsToAdd = groupIds.filter(
(groupId) => !tags.includes(groupId), (groupId) => !tags.includes(groupId) && !removedTags.has(groupId),
); );
return tagsToKeep.concat(groupIdsToAdd); return tagsToKeep.concat(groupIdsToAdd);
@ -181,6 +193,10 @@ class TagOrderStore extends Store {
return this._state.orderedTags; return this._state.orderedTags;
} }
getRemovedTagsAccountData() {
return this._state.removedTagsAccountData;
}
getStoreId() { getStoreId() {
// Generate a random ID to prevent this store from clobbering its // Generate a random ID to prevent this store from clobbering its
// state with redundant remote echos. // state with redundant remote echos.