Merge pull request #1743 from matrix-org/luke/feature-tag-panel-tile-context-menu
Add context menu to TagTile
This commit is contained in:
commit
c670b76ec8
4 changed files with 101 additions and 3 deletions
|
@ -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;
|
||||||
|
|
|
@ -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>;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue