diff --git a/.gitignore b/.gitignore index ae7a0245e404..d50255837f82 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ settings.json build.sh /public/livechat packages/rocketchat-i18n/i18n/livechat.* +tests/end-to-end/temporary_staged_test +.screenshots diff --git a/.meteor/packages b/.meteor/packages index da66a3df2f9a..4afdcd082e4f 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -197,6 +197,7 @@ rocketchat:search chatpal:search rocketchat:lazy-load tap:i18n +assistify:threading underscore@1.0.10 rocketchat:bigbluebutton rocketchat:mailmessages diff --git a/.meteor/versions b/.meteor/versions index a249651b8136..c038c62a817e 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -8,6 +8,7 @@ accounts-password@1.5.1 accounts-twitter@1.4.2 aldeed:simple-schema@1.5.4 allow-deny@1.1.0 +assistify:threading@0.1.0 autoupdate@1.5.0 babel-compiler@7.2.4 babel-runtime@1.3.0 diff --git a/.vscode/launch.json b/.vscode/launch.json index 465d780a4a59..58b8073d89b2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,7 @@ { "version": "0.2.0", "configurations": [ + { "name": "Attach to meteor debug", "type": "node", @@ -13,6 +14,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -26,6 +28,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" } }, { @@ -43,6 +46,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -61,6 +65,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "protocol": "inspector" }, @@ -79,6 +84,7 @@ "meteor://💻app/*": "${workspaceFolder}/*", "meteor://💻app/packages/rocketchat:*": "${workspaceFolder}/packages/rocketchat-*", "meteor://💻app/packages/chatpal:*": "${workspaceFolder}/packages/chatpal-*", + "meteor://💻app/packages/assistify:*": "${workspaceFolder}/packages/assistify-*" }, "env": { "TEST_MODE": "true" diff --git a/client/routes/roomRoute.js b/client/routes/roomRoute.js index a36e8c99f3d6..80cbd08fb193 100644 --- a/client/routes/roomRoute.js +++ b/client/routes/roomRoute.js @@ -1,8 +1,12 @@ +import mem from 'mem'; + import { FlowRouter } from 'meteor/kadira:flow-router'; import { ChatSubscription } from 'meteor/rocketchat:models'; import { roomTypes } from 'meteor/rocketchat:utils'; import { call } from 'meteor/rocketchat:ui-utils'; +const getRoomById = mem((rid) => call('getRoomById', rid)); + FlowRouter.goToRoomById = async(rid) => { if (!rid) { return; @@ -12,6 +16,6 @@ FlowRouter.goToRoomById = async(rid) => { return roomTypes.openRouteLink(subscription.t, subscription, FlowRouter.current().queryParams); } - const room = await call('getRoomById', rid); + const room = await getRoomById(rid); return roomTypes.openRouteLink(room.t, room, FlowRouter.current().queryParams); }; diff --git a/package.json b/package.json index 7c3b9949d268..0fa7a429c755 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,8 @@ "jslint": "eslint .", "stylelint": "stylelint \"packages/**/*.css\"", "test": "node .scripts/start.js", + "deploy": "npm run build && pm2 startOrRestart pm2.json", + "chimp-path": "chimp tests/chimp-config.js --path=$CHIMP_PATH", "chimp-watch": "chimp --ddp=http://localhost:3000 --watch --mocha --path=tests/end-to-end", "chimp-test": "chimp tests/chimp-config.js", "postinstall": "cd packages/rocketchat-katex && npm i", diff --git a/packages/assistify-threading/client/createThreadMessageAction.js b/packages/assistify-threading/client/createThreadMessageAction.js new file mode 100644 index 000000000000..8f77ca56c119 --- /dev/null +++ b/packages/assistify-threading/client/createThreadMessageAction.js @@ -0,0 +1,49 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { Subscriptions } from 'meteor/rocketchat:models'; +import { settings } from 'meteor/rocketchat:settings'; +import { hasPermission } from 'meteor/rocketchat:authorization'; +import { MessageAction, modal } from 'meteor/rocketchat:ui-utils'; + + +const condition = (rid, uid) => { + if (!Subscriptions.findOne({ rid })) { + return false; + } + return uid !== Meteor.userId() ? hasPermission('start-thread-other-user') : hasPermission('start-thread'); +}; + +Meteor.startup(function() { + Tracker.autorun(() => { + if (settings.get('Thread_from_context_menu') !== 'button') { + return MessageAction.removeButton('start-thread'); + } + + MessageAction.addButton({ + id: 'start-thread', + icon: 'thread', + label: 'Thread_start', + context: ['message', 'message-mobile'], + async action() { + const [, message] = this._arguments; + + modal.open({ + content: 'CreateThread', + data: { rid: message.rid, message, onCreate() { + modal.close(); + } }, + showConfirmButton: false, + showCancelButton: false, + }); + }, + condition({ rid, u: { _id: uid }, attachments }) { + if (attachments && attachments[0] && attachments[0].fields && attachments[0].fields[0].type === 'messageCounter') { + return false; + } + return condition(rid, uid); + }, + order: 0, + group: 'menu', + }); + }); +}); diff --git a/packages/assistify-threading/client/index.js b/packages/assistify-threading/client/index.js new file mode 100644 index 000000000000..41920c3c03dd --- /dev/null +++ b/packages/assistify-threading/client/index.js @@ -0,0 +1,20 @@ +// Templates +import './views/creationDialog/CreateThread.html'; +import './views/creationDialog/CreateThread'; +import './views/ThreadList.html'; +import './views/ThreadList'; +import './views/ThreadsTabbar.html'; +import './views/ThreadsTabbar'; +import './views/fieldTypeThreadReplyCounter.html'; +import './views/fieldTypeThreadReplyCounter'; +import './views/fieldTypeThreadLastMessageAge.html'; +import './views/fieldTypeThreadLastMessageAge'; + +// Other UI extensions +import './lib/messageTypes/threadMessage'; +import './lib/threadsOfRoom'; +import './createThreadMessageAction'; +import './threadFromMessageBox'; +import './tabBar'; + +import '../lib/threadRoomType'; diff --git a/packages/assistify-threading/client/lib/messageTypes/threadMessage.js b/packages/assistify-threading/client/lib/messageTypes/threadMessage.js new file mode 100644 index 000000000000..2e4b6a0c2c52 --- /dev/null +++ b/packages/assistify-threading/client/lib/messageTypes/threadMessage.js @@ -0,0 +1,32 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { MessageTypes } from 'meteor/rocketchat:ui-utils'; + +Meteor.startup(function() { + MessageTypes.registerType({ + id: 'thread-created', + system: true, + message: 'thread-created', + data(message) { + return { + // channelLink: `${ TAPi18n.__('thread') }`, + message: message.msg, + username: `@${ message.u.username }`, + }; + }, + }); + + MessageTypes.registerType({ + id: 'thread-welcome', + system: true, + message: 'thread-welcome', + data(message) { + const threadChannelName = TAPi18n.__('a_direct_message'); + + return { + parentChannel: `${ threadChannelName }`, + username: `@${ message.mentions[0].name }`, + }; + }, + }); +}); diff --git a/packages/assistify-threading/client/lib/threadsOfRoom.js b/packages/assistify-threading/client/lib/threadsOfRoom.js new file mode 100644 index 000000000000..76d63d68195e --- /dev/null +++ b/packages/assistify-threading/client/lib/threadsOfRoom.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const ThreadsOfRoom = new Mongo.Collection('rocketchat_threads_of_room'); diff --git a/packages/assistify-threading/client/public/stylesheets/threading.css b/packages/assistify-threading/client/public/stylesheets/threading.css new file mode 100644 index 000000000000..1c22bfb6f154 --- /dev/null +++ b/packages/assistify-threading/client/public/stylesheets/threading.css @@ -0,0 +1,34 @@ +.attachment-fields button { + min-height: auto; + padding: 3px; + + font-weight: normal; +} + +.attachment-fields button:hover { + text-decoration: none; +} + +.attachment-fields button.no-replies { + opacity: 0.4; +} + +.threads-list .empty { + margin-top: 60px; + + text-align: center; + + color: #7f7f7f; +} + +.threads-list .load-more { + text-align: center; + text-transform: lowercase; + + font-style: italic; + line-height: 40px; +} + +.threads-list .load-more .load-more-loading { + color: #aaaaaa; +} diff --git a/packages/assistify-threading/client/tabBar.js b/packages/assistify-threading/client/tabBar.js new file mode 100644 index 000000000000..8eee0bd96317 --- /dev/null +++ b/packages/assistify-threading/client/tabBar.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { TabBar } from 'meteor/rocketchat:ui-utils'; + +Meteor.startup(function() { + return TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'threads', + i18nTitle: 'Threads', + icon: 'thread', + template: 'threadsTabbar', + order: 10, + }); +}); diff --git a/packages/assistify-threading/client/threadFromMessageBox.js b/packages/assistify-threading/client/threadFromMessageBox.js new file mode 100644 index 000000000000..443b4b7c136e --- /dev/null +++ b/packages/assistify-threading/client/threadFromMessageBox.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { messageBox, modal } from 'meteor/rocketchat:ui-utils'; +import { settings } from 'meteor/rocketchat:settings'; + +Meteor.startup(function() { + Tracker.autorun(() => { + if (settings.get('Thread_from_context_menu') !== 'button') { + return messageBox.actions.remove('Create_new', /start-thread/); + } + messageBox.actions.add('Create_new', 'Thread', { + id: 'start-thread', + icon: 'thread', + condition: () => true, + action(data) { + modal.open({ + // title: t('Message_info'), + content: 'CreateThread', + data: { + ...data, + onCreate() { + modal.close(); + }, + }, + showConfirmButton: false, + showCancelButton: false, + // confirmButtonText: t('Close'), + }); + }, + }); + + }); +}); diff --git a/packages/assistify-threading/client/views/ThreadList.html b/packages/assistify-threading/client/views/ThreadList.html new file mode 100644 index 000000000000..35df571080ba --- /dev/null +++ b/packages/assistify-threading/client/views/ThreadList.html @@ -0,0 +1,10 @@ + diff --git a/packages/assistify-threading/client/views/ThreadList.js b/packages/assistify-threading/client/views/ThreadList.js new file mode 100644 index 000000000000..5098291dd678 --- /dev/null +++ b/packages/assistify-threading/client/views/ThreadList.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { ChatSubscription } from 'meteor/rocketchat:models'; +import { getUserPreference } from 'meteor/rocketchat:utils'; +import { settings } from 'meteor/rocketchat:settings'; +Template.ThreadList.helpers({ + rooms() { + const user = Meteor.userId(); + const sortBy = getUserPreference(user, 'sidebarSortby') || 'alphabetical'; + const query = { + open: true, + }; + + const sort = {}; + + if (sortBy === 'activity') { + sort.lm = -1; + } else { // alphabetical + sort[this.identifier === 'd' && settings.get('UI_Use_Real_Name') ? 'lowerCaseFName' : 'lowerCaseName'] = /descending/.test(sortBy) ? -1 : 1; + } + + query.prid = { $exists: true }; + return ChatSubscription.find(query, { sort }); + }, +}); diff --git a/packages/assistify-threading/client/views/ThreadsTabbar.html b/packages/assistify-threading/client/views/ThreadsTabbar.html new file mode 100644 index 000000000000..502f83585d43 --- /dev/null +++ b/packages/assistify-threading/client/views/ThreadsTabbar.html @@ -0,0 +1,22 @@ + diff --git a/packages/assistify-threading/client/views/ThreadsTabbar.js b/packages/assistify-threading/client/views/ThreadsTabbar.js new file mode 100644 index 000000000000..ea328c8d809e --- /dev/null +++ b/packages/assistify-threading/client/views/ThreadsTabbar.js @@ -0,0 +1,54 @@ +import _ from 'underscore'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { ThreadsOfRoom } from '../lib/threadsOfRoom'; + +Template.threadsTabbar.helpers({ + hasMessages() { + return ThreadsOfRoom.find({ + rid: this.rid, + }, { + sort: { + ts: -1, + }, + }).count() > 0; + }, + messages() { + return ThreadsOfRoom.find({ + rid: this.rid, + }, { + sort: { + ts: -1, + }, + }); + }, + message() { + return _.extend(this, { customClass: 'pinned', actionContext: 'pinned' }); + }, + hasMore() { + return Template.instance().hasMore.get(); + }, +}); + +Template.threadsTabbar.onCreated(function() { + this.hasMore = new ReactiveVar(true); + this.limit = new ReactiveVar(50); + return this.autorun(() => { + const data = Template.currentData(); + return this.subscribe('threadsOfRoom', data.rid, this.limit.get(), () => { + if (ThreadsOfRoom.find({ + rid: data.rid, + }).count() < this.limit.get()) { + return this.hasMore.set(false); + } + }); + }); +}); + +Template.threadsTabbar.events({ + 'scroll .js-list': _.throttle(function(e, instance) { + if (e.target.scrollTop >= e.target.scrollHeight - e.target.clientHeight && instance.hasMore.get()) { + return instance.limit.set(instance.limit.get() + 50); + } + }, 200), +}); diff --git a/packages/assistify-threading/client/views/creationDialog/CreateThread.html b/packages/assistify-threading/client/views/creationDialog/CreateThread.html new file mode 100644 index 000000000000..596b3e3c7e83 --- /dev/null +++ b/packages/assistify-threading/client/views/creationDialog/CreateThread.html @@ -0,0 +1,107 @@ + + + diff --git a/packages/assistify-threading/client/views/creationDialog/CreateThread.js b/packages/assistify-threading/client/views/creationDialog/CreateThread.js new file mode 100755 index 000000000000..041c13bdfcc6 --- /dev/null +++ b/packages/assistify-threading/client/views/creationDialog/CreateThread.js @@ -0,0 +1,306 @@ +import { Meteor } from 'meteor/meteor'; +import { roomTypes } from 'meteor/rocketchat:utils'; +import { callbacks } from 'meteor/rocketchat:callbacks'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { AutoComplete } from 'meteor/mizzao:autocomplete'; +import { ChatRoom } from 'meteor/rocketchat:models'; +import { Blaze } from 'meteor/blaze'; +import { call } from 'meteor/rocketchat:ui-utils'; + +import { TAPi18n } from 'meteor/tap:i18n'; +import toastr from 'toastr'; + + +Template.CreateThread.helpers({ + + onSelectUser() { + return Template.instance().onSelectUser; + }, + disabled() { + if (Template.instance().selectParent.get()) { + return 'disabled'; + } + }, + targetChannelText() { + const instance = Template.instance(); + const parentChannel = instance.parentChannel.get(); + return parentChannel && `${ TAPi18n.__('Thread_target_channel_prefix') } "${ parentChannel }"`; + }, + createIsDisabled() { + const instance = Template.instance(); + if (instance.reply.get() && instance.parentChannel.get()) { + return ''; + } + return 'disabled'; + }, + parentChannel() { + const instance = Template.instance(); + return instance.parentChannel.get(); + }, + selectedUsers() { + const { message } = this; + const users = Template.instance().selectedUsers.get(); + if (message) { + users.unshift(message.u); + } + return users; + }, + + onClickTagUser() { + return Template.instance().onClickTagUser; + }, + deleteLastItemUser() { + return Template.instance().deleteLastItemUser; + }, + onClickTagRoom() { + return Template.instance().onClickTagRoom; + }, + deleteLastItemRoom() { + return Template.instance().deleteLastItemRoom; + }, + selectedRoom() { + return Template.instance().selectedRoom.get(); + }, + onSelectRoom() { + return Template.instance().onSelectRoom; + }, + roomCollection() { + return ChatRoom; + }, + roomSelector() { + return (expression) => ({ name: { $regex: `.*${ expression }.*` } }); + }, + roomModifier() { + return (filter, text = '') => { + const f = filter.get(); + return `#${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), (part) => `${ part }`) }`; + }; + }, + userModifier() { + return (filter, text = '') => { + const f = filter.get(); + return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), (part) => `${ part }`) }`; + }; + }, + channelName() { + return Template.instance().threadName.get(); + }, +}); + +Template.CreateThread.events({ + 'input #thread_name'(e, t) { + t.threadName.set(e.target.value); + }, + 'input #thread_message'(e, t) { + const { value } = e.target; + t.reply.set(value); + }, + async 'submit #create-thread, click .js-save-thread'(event, instance) { + event.preventDefault(); + const parentChannel = instance.parentChannel.get(); + + const { pmid } = instance; + const t_name = instance.threadName.get(); + const users = instance.selectedUsers.get().map(({ username }) => username).filter((value, index, self) => self.indexOf(value) === index); + + const prid = instance.parentChannelId.get(); + const reply = instance.reply.get(); + + if (!prid) { + const errorText = TAPi18n.__('Invalid_room_name', `${ parentChannel }...`); + return toastr.error(errorText); + } + const result = await call('createThread', { prid, pmid, t_name, reply, users }); + // callback to enable tracking + callbacks.run('afterCreateThread', Meteor.user(), result); + + if (instance.data.onCreate) { + instance.data.onCreate(result); + } + + roomTypes.openRouteLink(result.t, result); + }, +}); + +Template.CreateThread.onRendered(function() { + this.find(this.data.rid ? '#thread_name' : '#parentChannel').focus(); +}); + +Template.CreateThread.onCreated(function() { + const { rid, message: msg } = this.data; + + const parentRoom = rid && ChatRoom.findOne(rid); + + // if creating a thread from inside a thread, uses the same channel as parent channel + const room = parentRoom && parentRoom.prid ? ChatRoom.findOne(parentRoom.prid) : parentRoom; + + if (room) { + room.text = room.name; + this.threadName = new ReactiveVar(`${ room.name } - ${ msg && msg.msg }`); + } else { + this.threadName = new ReactiveVar(''); + } + + + this.pmid = msg && msg._id; + + this.parentChannel = new ReactiveVar(roomTypes.getRoomName(room)); + this.parentChannelId = new ReactiveVar(rid); + + this.selectParent = new ReactiveVar(!!rid); + + this.reply = new ReactiveVar(''); + + + this.selectedRoom = new ReactiveVar(room ? [room] : []); + + + this.onClickTagRoom = () => { + this.selectedRoom.set([]); + }; + this.deleteLastItemRoom = () => { + this.selectedRoom.set([]); + }; + + this.onSelectRoom = ({ item: room }) => { + room.text = room.name; + this.selectedRoom.set([room]); + }; + + this.autorun(() => { + const [room = {}] = this.selectedRoom.get(); + this.parentChannel.set(room && room.name); // determine parent Channel from setting and allow to overwrite + this.parentChannelId.set(room && room._id); + }); + + + this.selectedUsers = new ReactiveVar([]); + this.onSelectUser = ({ item: user }) => { + const users = this.selectedUsers.get(); + if (!users.find((u) => user.username === u.username)) { + this.selectedUsers.set([...this.selectedUsers.get(), user].filter()); + } + }; + this.onClickTagUser = (({ username }) => { + this.selectedUsers.set(this.selectedUsers.get().filter((user) => user.username !== username)); + }); + this.deleteLastItemUser = (() => { + const arr = this.selectedUsers.get(); + arr.pop(); + this.selectedUsers.set(arr); + }); + + + // callback to allow setting a parent Channel or e. g. tracking the event using Piwik or GA + const { parentChannel, reply } = callbacks.run('openThreadCreationScreen') || {}; + + if (parentChannel) { + this.parentChannel.set(parentChannel); + } + if (reply) { + this.reply.set(reply); + } +}); + +Template.SearchCreateThread.helpers({ + list() { + return this.list; + }, + items() { + return Template.instance().ac.filteredList(); + }, + config() { + const { filter } = Template.instance(); + const { noMatchTemplate, templateItem, modifier } = Template.instance().data; + return { + filter: filter.get(), + template_item: templateItem, + noMatchTemplate, + modifier(text) { + return modifier(filter, text); + }, + }; + }, + autocomplete(key) { + const instance = Template.instance(); + const param = instance.ac[key]; + return typeof param === 'function' ? param.apply(instance.ac) : param; + }, +}); + +Template.SearchCreateThread.events({ + 'input input'(e, t) { + const input = e.target; + const position = input.selectionEnd || input.selectionStart; + const { length } = input.value; + document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); + t.filter.set(input.value); + }, + 'click .rc-popup-list__item'(e, t) { + t.ac.onItemClick(this, e); + }, + 'keydown input'(e, t) { + t.ac.onKeyDown(e); + if ([8, 46].includes(e.keyCode) && e.target.value === '') { + const { deleteLastItem } = t; + return deleteLastItem && deleteLastItem(); + } + + }, + 'keyup input'(e, t) { + t.ac.onKeyUp(e); + }, + 'focus input'(e, t) { + t.ac.onFocus(e); + }, + 'blur input'(e, t) { + t.ac.onBlur(e); + }, + 'click .rc-tags__tag'({ target }, t) { + const { onClickTag } = t; + return onClickTag & onClickTag(Blaze.getData(target)); + }, +}); +Template.SearchCreateThread.onRendered(function() { + + const { name } = this.data; + + this.ac.element = this.firstNode.querySelector(`[name=${ name }]`); + this.ac.$element = $(this.ac.element); +}); + +Template.SearchCreateThread.onCreated(function() { + this.filter = new ReactiveVar(''); + this.selected = new ReactiveVar([]); + this.onClickTag = this.data.onClickTag; + this.deleteLastItem = this.data.deleteLastItem; + + const { collection, subscription, field, sort, onSelect, selector = (match) => ({ term: match }) } = this.data; + this.ac = new AutoComplete( + { + selector: { + anchor: '.rc-input__label', + item: '.rc-popup-list__item', + container: '.rc-popup-list__list', + }, + onSelect, + position: 'fixed', + limit: 10, + inputDelay: 300, + rules: [ + { + collection, + subscription, + field, + matchAll: true, + // filter, + doNotChangeWidth: false, + selector, + sort, + }, + ], + + }); + this.ac.tmplInst = this; +}); diff --git a/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.html b/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.html new file mode 100644 index 000000000000..93fc2f4bb17f --- /dev/null +++ b/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.html @@ -0,0 +1,7 @@ + diff --git a/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.js b/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.js new file mode 100644 index 000000000000..f1d7c228c39b --- /dev/null +++ b/packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.js @@ -0,0 +1,12 @@ +import { registerFieldTemplate } from 'meteor/rocketchat:message-attachments'; +import { Template } from 'meteor/templating'; +import moment from 'moment'; + +Template.LastMessageAge.helpers({ + lastMessageAge() { + const lastMessageTimestamp = Template.instance().data.field.lm; + return lastMessageTimestamp && moment(lastMessageTimestamp).format('LLL'); + }, +}); + +registerFieldTemplate('lastMessageAge', 'LastMessageAge', {}); diff --git a/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.html b/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.html new file mode 100644 index 000000000000..bdc7ea5b7a74 --- /dev/null +++ b/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.html @@ -0,0 +1,19 @@ + diff --git a/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.js b/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.js new file mode 100644 index 000000000000..8fde492d3b10 --- /dev/null +++ b/packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.js @@ -0,0 +1,30 @@ +import { registerFieldTemplate } from 'meteor/rocketchat:message-attachments'; +import { Template } from 'meteor/templating'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +Template.MessageCounter.helpers({ + hasReplies() { + return Template.instance().data.field.count > 0; + }, + + replyCount() { + return Template.instance().data.field.count; + }, + + i18nKeyReply() { + return Template.instance().data.field.count > 1 + ? 'Replies' + : 'Reply'; + }, + +}); + +const events = { + 'click .js-navigate-to-thread'(event) { + event.preventDefault(); + const [, { trid }] = this._arguments; + FlowRouter.goToRoomById(trid); + }, +}; + +registerFieldTemplate('messageCounter', 'MessageCounter', events); diff --git a/packages/assistify-threading/lib/threadRoomType.js b/packages/assistify-threading/lib/threadRoomType.js new file mode 100644 index 000000000000..eba52f97596b --- /dev/null +++ b/packages/assistify-threading/lib/threadRoomType.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { RoomTypeConfig, roomTypes, getUserPreference } from 'meteor/rocketchat:utils'; + +export class ThreadRoomType extends RoomTypeConfig { + constructor() { + super({ + identifier: 't', + order: 25, + label: 'Threads', + }); + + // we need a custom template in order to have a custom query showing the subscriptions to threads + this.customTemplate = 'ThreadList'; + } + + condition() { + return getUserPreference(Meteor.userId(), 'sidebarShowThreads'); + } +} + +roomTypes.add(new ThreadRoomType()); diff --git a/packages/assistify-threading/package.js b/packages/assistify-threading/package.js new file mode 100644 index 000000000000..961398387d63 --- /dev/null +++ b/packages/assistify-threading/package.js @@ -0,0 +1,24 @@ +Package.describe({ + name: 'assistify:threading', + version: '0.1.0', + summary: 'Adds heavy-weight threading to Rocket.Chat', + git: 'http://github.com/assistify/Rocket.Chat', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md', +}); + +Package.onUse(function(api) { + api.versionsFrom('1.2.1'); + api.use(['ecmascript', 'mizzao:autocomplete']); + api.use('rocketchat:authorization'); // In order to create custom permissions + api.use('rocketchat:callbacks', 'server'); + api.use('rocketchat:models', 'server'); + api.use('templating', 'client'); + + api.mainModule('client/index.js', 'client'); + api.mainModule('server/index.js', 'server'); + + // styling + api.addFiles('client/public/stylesheets/threading.css', 'client'); +}); diff --git a/packages/assistify-threading/server/authorization.js b/packages/assistify-threading/server/authorization.js new file mode 100644 index 000000000000..4562679cd0a6 --- /dev/null +++ b/packages/assistify-threading/server/authorization.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; +import { addRoomAccessValidator, canAccessRoom } from 'meteor/rocketchat:authorization'; +import { Rooms } from 'meteor/rocketchat:models'; + +Meteor.startup(() => { + addRoomAccessValidator(function(room, user) { + return room.prid && canAccessRoom(Rooms.findOne(room.prid), user); + }); +}); diff --git a/packages/assistify-threading/server/config.js b/packages/assistify-threading/server/config.js new file mode 100644 index 000000000000..41a97712fcc6 --- /dev/null +++ b/packages/assistify-threading/server/config.js @@ -0,0 +1,42 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from 'meteor/rocketchat:settings'; + +Meteor.startup(() => { + settings.addGroup('Threading', function() { + // the channel for which threads are created if none is explicitly chosen + + this.add('Thread_from_context_menu', 'button', { + group: 'Threading', + i18nLabel: 'Thread_from_context_menu', + type: 'select', + values: [ + { key: 'button', i18nLabel: 'Threading_context_menu_button' }, + { key: 'none', i18nLabel: 'Threading_context_menu_none' }, + ], + public: true, + }); + }); + + settings.add('Accounts_Default_User_Preferences_sidebarShowThreads', true, { + group: 'Accounts', + section: 'Accounts_Default_User_Preferences', + type: 'boolean', + public: true, + i18nLabel: 'Threads_in_sidebar', + }); + + const globalQuery = { + _id: 'RetentionPolicy_Enabled', + value: true, + }; + + settings.add('RetentionPolicy_DoNotExcludeThreads', true, { + group: 'RetentionPolicy', + section: 'Global Policy', + type: 'boolean', + public: true, + i18nLabel: 'RetentionPolicy_DoNotExcludeThreads', + i18nDescription: 'RetentionPolicy_DoNotExcludeThreads_Description', + enableQuery: globalQuery, + }); +}); diff --git a/packages/assistify-threading/server/hooks/joinThreadOnMessage.js b/packages/assistify-threading/server/hooks/joinThreadOnMessage.js new file mode 100644 index 000000000000..107cd8d3f481 --- /dev/null +++ b/packages/assistify-threading/server/hooks/joinThreadOnMessage.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { callbacks } from 'meteor/rocketchat:callbacks'; +import { Subscriptions } from 'meteor/rocketchat:models'; + +callbacks.add('beforeSaveMessage', (message, room) => { + + // abort if room is not a thread + if (!room || !room.prid) { + return message; + } + + // check if user already joined the thread + const sub = Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, { fields: { _id: 1 } }); + if (sub) { + return message; + } + + // if no subcription, call join + Meteor.runAsUser(message.u._id, () => Meteor.call('joinRoom', room._id)); + + return message; +}); diff --git a/packages/assistify-threading/server/hooks/propagateThreadMetadata.js b/packages/assistify-threading/server/hooks/propagateThreadMetadata.js new file mode 100644 index 000000000000..3fec296066fd --- /dev/null +++ b/packages/assistify-threading/server/hooks/propagateThreadMetadata.js @@ -0,0 +1,28 @@ + +import { callbacks } from 'meteor/rocketchat:callbacks'; +import { Messages, Rooms } from 'meteor/rocketchat:models'; +import { deleteRoom } from 'meteor/rocketchat:lib'; +/** + * We need to propagate the writing of new message in a thread to the linking + * system message + */ +callbacks.add('afterSaveMessage', function(message, { _id, prid } = {}) { + if (prid) { + Messages.refreshThreadMetadata({ rid: _id }, message); + } + return message; +}, callbacks.priority.LOW, 'PropagateThreadMetadata'); + +callbacks.add('afterDeleteMessage', function(message, { _id, prid } = {}) { + if (prid) { + Messages.refreshThreadMetadata({ rid: _id }, message); + } + if (message.trid) { + deleteRoom(message.trid); + } + return message; +}, callbacks.priority.LOW, 'PropagateThreadMetadata'); + +callbacks.add('afterDeleteRoom', function(rid) { + Rooms.find({ prid: rid }, { fields: { _id: 1 } }).forEach(({ _id }) => deleteRoom(_id)); +}, 'DeleteThreadChain'); diff --git a/packages/assistify-threading/server/index.js b/packages/assistify-threading/server/index.js new file mode 100644 index 000000000000..4940d472c405 --- /dev/null +++ b/packages/assistify-threading/server/index.js @@ -0,0 +1,14 @@ +import './config'; +import './authorization'; +import './permissions'; + +import './hooks/joinThreadOnMessage'; +import './hooks/propagateThreadMetadata'; +import './publications/threadParentAutocomplete'; +import './publications/threadsOfRoom'; + +// Methods +import './methods/createThread'; + +// Lib +import '../lib/threadRoomType'; diff --git a/packages/assistify-threading/server/methods/createThread.js b/packages/assistify-threading/server/methods/createThread.js new file mode 100644 index 000000000000..c451f571eb22 --- /dev/null +++ b/packages/assistify-threading/server/methods/createThread.js @@ -0,0 +1,162 @@ +/* UserRoles RoomRoles*/ +// import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +// import { getAvatarUrlFromUsername } from 'meteor/rocketchat:utils'; +import { hasAtLeastOnePermission, canAccessRoom } from 'meteor/rocketchat:authorization'; +import { Messages, Rooms } from 'meteor/rocketchat:models'; +import { createRoom, addUserToRoom, sendMessage, attachMessage } from 'meteor/rocketchat:lib'; + +const fields = [ + { + type: 'messageCounter', + count: 0, + }, + { + type: 'lastMessageAge', + lm: null, + }, +]; + +export const createThreadMessage = (rid, user, trid, msg, message_embedded) => { + const welcomeMessage = { + msg, + rid, + trid, + attachments: [{ + fields, + }, message_embedded].filter((e) => e), + }; + return Messages.createWithTypeRoomIdMessageAndUser('thread-created', trid, '', user, welcomeMessage); +}; + +export const mentionThreadMessage = (rid, user, msg, message_embedded) => { + const welcomeMessage = { + msg, + rid, + attachments: [message_embedded].filter((e) => e), + }; + return Messages.createWithTypeRoomIdMessageAndUser('thread-created', rid, '', user, welcomeMessage); +}; + +const cloneMessage = ({ _id, ...msg }) => ({ ...msg }); + +export const create = ({ prid, pmid, t_name, reply, users }) => { + // if you set both, prid and pmid, and the rooms doesnt match... should throw an error) + let message = false; + if (pmid) { + message = Messages.findOne({ _id: pmid }); + if (prid) { + if (prid !== message.rid) { + throw new Meteor.Error('error-invalid-arguments', { method: 'ThreadCreation' }); + } + } else { + prid = message.rid; + } + } + + if (!prid) { + throw new Meteor.Error('error-invalid-arguments', { method: 'ThreadCreation' }); + } + const p_room = Rooms.findOne(prid); + + if (p_room.prid) { + throw new Meteor.Error('error-nested-thread', 'Cannot create nested threads', { method: 'ThreadCreation' }); + } + + const user = Meteor.user(); + + if (!canAccessRoom(p_room, user)) { + throw new Meteor.Error('error-not-allowed', { method: 'ThreadCreation' }); + } + + if (pmid) { + const threadAlreadyExists = Rooms.findOne({ + prid, + pmid, + }, { + fields: { _id: 1 }, + }); + if (threadAlreadyExists) { // do not allow multiple threads to the same message'\ + addUserToRoom(threadAlreadyExists._id, user); + return threadAlreadyExists; + } + } + + const name = Random.id(); + + // auto invite the replied message owner + const invitedUsers = message ? [message.u.username, ...users] : users; + + // threads are always created as private groups + const thread = createRoom('p', name, user.username, [...new Set(invitedUsers)], false, { + fname: t_name, + description: message.msg, // TODO threads remove + topic: p_room.name, // TODO threads remove + prid, + }); + + if (pmid) { + const clonedMessage = cloneMessage(message); + + Messages.update({ + _id: message._id, + }, { + ...clonedMessage, + attachments: [ + { fields }, + ...(message.attachments || []), + ], + trid: thread._id, + }); + + mentionThreadMessage(thread._id, user, reply, attachMessage(message, p_room)); + + // check if the message is in the latest 10 messages sent to the room + // if not creates a new message saying about the thread creation + const lastMessageIds = Messages.findByRoomId(message.rid, { + sort: { + ts: -1, + }, + limit: 15, + fields: { + _id: 1, + }, + }).fetch(); + + if (!lastMessageIds.find((msg) => msg._id === message._id)) { + createThreadMessage(message.rid, user, thread._id, reply, attachMessage(message, p_room)); + } + } else { + createThreadMessage(prid, user, thread._id, reply); + if (reply) { + sendMessage(user, { msg: reply }, thread); + } + } + return thread; +}; + +Meteor.methods({ + /** + * Create thread by room or message + * @constructor + * @param {string} prid - Parent Room Id - The room id, optional if you send pmid. + * @param {string} pmid - Parent Message Id - Create the thread by a message, optional. + * @param {string} reply - The reply, optional + * @param {string} t_name - thread name + * @param {string[]} users - users to be added + */ + createThread({ prid, pmid, t_name, reply, users }) { + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'ThreadCreation' }); + } + + if (!hasAtLeastOnePermission(uid, ['start-thread', 'start-thread-other-user'])) { + throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a thread', { method: 'createThread' }); + } + + return create({ uid, prid, pmid, t_name, reply, users }); + }, +}); diff --git a/packages/assistify-threading/server/permissions.js b/packages/assistify-threading/server/permissions.js new file mode 100644 index 000000000000..c1f5f9b0efce --- /dev/null +++ b/packages/assistify-threading/server/permissions.js @@ -0,0 +1,16 @@ +import { Meteor } from 'meteor/meteor'; +import { Permissions } from 'meteor/rocketchat:models'; + +Meteor.startup(() => { + // Add permissions for threading + const permissions = [ + { _id: 'start-thread', roles: ['admin', 'user', 'expert', 'guest'] }, + { _id: 'start-thread-other-user', roles: ['admin', 'user', 'expert', 'owner'] }, + ]; + + for (const permission of permissions) { + if (!Permissions.findOneById(permission._id)) { + Permissions.upsert(permission._id, { $set: permission }); + } + } +}); diff --git a/packages/assistify-threading/server/publications/threadParentAutocomplete.js b/packages/assistify-threading/server/publications/threadParentAutocomplete.js new file mode 100644 index 000000000000..afdd5876f56f --- /dev/null +++ b/packages/assistify-threading/server/publications/threadParentAutocomplete.js @@ -0,0 +1,44 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms } from 'meteor/rocketchat:models'; + +import { hasPermission } from 'meteor/rocketchat:authorization'; + +Meteor.publish('threadParentAutocomplete', function(selector) { + if (!this.userId) { + return this.ready(); + } + + if (hasPermission(this.userId, 'view-c-room') !== true) { + return this.ready(); + } + + const pub = this; + const options = { + fields: { + _id: 1, + name: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const cursorHandle = Rooms.findThreadParentByNameStarting(selector.name, options).observeChanges({ + added(_id, record) { + return pub.added('autocompleteRecords', _id, record); + }, + changed(_id, record) { + return pub.changed('autocompleteRecords', _id, record); + }, + removed(_id, record) { + return pub.removed('autocompleteRecords', _id, record); + }, + }); + + this.ready(); + + this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/packages/assistify-threading/server/publications/threadsOfRoom.js b/packages/assistify-threading/server/publications/threadsOfRoom.js new file mode 100644 index 000000000000..f4aeb5dda43c --- /dev/null +++ b/packages/assistify-threading/server/publications/threadsOfRoom.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import { Messages } from 'meteor/rocketchat:models'; + +Meteor.publish('threadsOfRoom', function(rid, limit = 50) { + if (!this.userId) { + return this.ready(); + } + + const publication = this; + + if (!Meteor.call('canAccessRoom', rid, this.userId)) { + return this.ready(); + } + + const cursorHandle = Messages.find({ rid, trid: { $exists: 1 } }, { sort: { ts: -1 }, limit }).observeChanges({ + added(_id, record) { + return publication.added('rocketchat_threads_of_room', _id, record); + }, + changed(_id, record) { + return publication.changed('rocketchat_threads_of_room', _id, record); + }, + removed(_id) { + return publication.removed('rocketchat_threads_of_room', _id); + }, + }); + + this.ready(); + return this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/packages/chatpal-search/client/template/result.js b/packages/chatpal-search/client/template/result.js index 750fa9a83028..5a091f08e8d0 100644 --- a/packages/chatpal-search/client/template/result.js +++ b/packages/chatpal-search/client/template/result.js @@ -92,7 +92,7 @@ Template.ChatpalSearchSingleMessage.helpers({ if (room && room.t === 'd') { return 'at'; } - return roomTypes.getIcon(room && room.t); + return roomTypes.getIcon(room); }, roomLink() { @@ -119,7 +119,7 @@ Template.ChatpalSearchSingleRoom.helpers({ if (room && room.t === 'd') { return 'at'; } - return roomTypes.getIcon(room && room.t); + return roomTypes.getIcon(room); }, roomLink() { const subscription = Subscriptions.findOne({ rid: this._id }); diff --git a/packages/meteor-autocomplete/client/autocomplete-client.js b/packages/meteor-autocomplete/client/autocomplete-client.js index e57cbcbd6e00..6f39c688cb2a 100755 --- a/packages/meteor-autocomplete/client/autocomplete-client.js +++ b/packages/meteor-autocomplete/client/autocomplete-client.js @@ -91,6 +91,8 @@ export default class AutoComplete { validateRule(rule); }); + this.onSelect = settings.onSelect; + this.expressions = (() => Object.keys(rules).map((key) => { const rule = rules[key]; return getRegExp(rule); @@ -122,9 +124,7 @@ export default class AutoComplete { this.setLoaded(true); return; } - const params = getFindParams(rule, filter, this.limit); - const selector = params[0]; - const options = params[1]; + const [selector, options] = getFindParams(rule, filter, this.limit); // console.debug 'Subscribing to <%s> in <%s>.<%s>', filter, rule.collection, rule.field this.setLoaded(false); @@ -342,6 +342,7 @@ export default class AutoComplete { // TODO this is a hack; see above this.onBlur(); } + this.onSelect && this.onSelect(doc); this.$element.trigger('autocompleteselect', doc); } @@ -371,10 +372,9 @@ export default class AutoComplete { setText(text) { if (this.$element.is('input,textarea')) { - this.$element.val(text); - } else { - this.$element.html(text); + return this.$element.val(text); } + this.$element.html(text); } @@ -385,7 +385,14 @@ export default class AutoComplete { positionContainer() { // First render; Pick the first item and set css whenever list gets shown let pos = {}; - const position = this.$element.position(); + const element = this.tmplInst.$(this.selector.anchor || this.$element); + + if (this.position === 'fixed') { + const width = element.outerWidth(); + return this.tmplInst.$(this.selector.container).css({ width, position: 'fixed' }); + } + + const position = element.position(); const rule = this.matchedRule(); const offset = getCaretCoordinates(this.element, this.element.selectionStart); @@ -394,7 +401,7 @@ export default class AutoComplete { if (rule && isWholeField(rule)) { pos.left = position.left; if (rule.doNotChangeWidth !== false) { - pos.width = this.$element.outerWidth(); // position.offsetWidth + pos.width = element.outerWidth(); // position.offsetWidth } } else { // Normal positioning, at token word @@ -403,11 +410,12 @@ export default class AutoComplete { // Position menu from top (above) or from bottom of caret (below, default) if (this.position === 'top') { - pos.bottom = this.$element.offsetParent().height() - position.top - offset.top; + pos.bottom = element.offsetParent().height() - position.top - offset.top; } else { - pos.top = position.top + offset.top + parseInt(this.$element.css('font-size')); + pos.top = position.top + offset.top + parseInt(element.css('font-size')); } - this.tmplInst.$(this.selector.container).css(pos); + + this.tmplInst.$(this.selector.container).css({ ...pos, position: 'absolute' }); } ensureSelection() { diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.html b/packages/rocketchat-channel-settings/client/views/channelSettings.html index e994bc760c7b..c7c50ecff63a 100644 --- a/packages/rocketchat-channel-settings/client/views/channelSettings.html +++ b/packages/rocketchat-channel-settings/client/views/channelSettings.html @@ -330,18 +330,21 @@