[NEW] Threads (#13541)
Co-authored-by: Oliver Jägle <github@beimir.net> Co-authored-by: vickyokrm <vickyokrm@gmail.com>pull/12992/head^2
parent
8849ece29e
commit
b93b31b8dc
@ -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', |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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'; |
||||
@ -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: `<a class="mention-link" data-channel= ${ message.channels[0]._id } title="">${ TAPi18n.__('thread') }</a>`,
|
||||
message: message.msg, |
||||
username: `<a class="mention-link" data-username=${ message.u.username } title="">@${ message.u.username }</a>`, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
MessageTypes.registerType({ |
||||
id: 'thread-welcome', |
||||
system: true, |
||||
message: 'thread-welcome', |
||||
data(message) { |
||||
const threadChannelName = TAPi18n.__('a_direct_message'); |
||||
|
||||
return { |
||||
parentChannel: `<a class="mention-link" data-channel= ${ threadChannelName } title="">${ threadChannelName }</a>`, |
||||
username: `<a class="mention-link" data-username= ${ message.mentions[0].name } title="">@${ message.mentions[0].name }</a>`, |
||||
}; |
||||
}, |
||||
}); |
||||
}); |
||||
@ -0,0 +1,3 @@ |
||||
import { Mongo } from 'meteor/mongo'; |
||||
|
||||
export const ThreadsOfRoom = new Mongo.Collection('rocketchat_threads_of_room'); |
||||
@ -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; |
||||
} |
||||
@ -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, |
||||
}); |
||||
}); |
||||
@ -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'),
|
||||
}); |
||||
}, |
||||
}); |
||||
|
||||
}); |
||||
}); |
||||
@ -0,0 +1,10 @@ |
||||
<template name="ThreadList"> |
||||
{{#if rooms}} |
||||
<h3 class="rooms-list__type"> |
||||
{{_ "Threads"}} |
||||
</h3> |
||||
<ul class="rooms-list__list"> |
||||
{{#each room in rooms}} {{> chatRoomItem room }} {{/each}} |
||||
</ul> |
||||
{{/if}} |
||||
</template> |
||||
@ -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 }); |
||||
}, |
||||
}); |
||||
@ -0,0 +1,22 @@ |
||||
<template name="threadsTabbar"> |
||||
{{#if Template.subscriptionsReady}} |
||||
{{#unless hasMessages}} |
||||
<div class="list-view threads-list flex-tab__header"> |
||||
<h2>{{_ "No_threads_yet"}}</h2> |
||||
</div> |
||||
{{/unless}} |
||||
{{/if}} |
||||
<div class="flex-tab__result threads-list js-list"> |
||||
<ul class="list clearfix"> |
||||
{{#each messages}} |
||||
{{#nrr nrrargs 'message' message}}{{/nrr}} |
||||
{{/each}} |
||||
</ul> |
||||
|
||||
{{#if hasMore}} |
||||
<div class="load-more"> |
||||
{{> loading}} |
||||
</div> |
||||
{{/if}} |
||||
</div> |
||||
</template> |
||||
@ -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), |
||||
}); |
||||
@ -0,0 +1,107 @@ |
||||
<template name="CreateThread"> |
||||
|
||||
<section class="create-channel"> |
||||
<div class="create-channel__wrapper"> |
||||
<header class="create-channel__header"> |
||||
<h1 class="create-channel__title">{{_ "Threading_title"}}</h1> |
||||
<p class="create-channel__description">{{_ "Threading_description"}}</p> |
||||
</header> |
||||
<form id="create-thread" class="create-channel__content"> |
||||
{{#unless disabled}} |
||||
{{> SearchCreateThread |
||||
onClickTag=onClickTagRoom |
||||
deleteLastItem=deleteLastItemRoom |
||||
list=selectedRoom |
||||
onSelect=onSelectRoom |
||||
collection=roomCollection |
||||
field='name' |
||||
sort='name' |
||||
label="Thread_target_channel" |
||||
placeholder="Thread_target_channel_description" |
||||
name="parentChannel" |
||||
disabled=disabled |
||||
selector=roomSelector |
||||
description=targetChannelText |
||||
noMatchTemplate="roomSearchEmpty" |
||||
templateItem="popupList_item_channel" |
||||
modifier=roomModifier |
||||
}} {{else}} |
||||
<div class="rc-input" disabled> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Thread_target_channel"}}</div> |
||||
<div class="rc-input__wrapper"> |
||||
<input disabled name="parentChannel" id="parentChannel" value={{parentChannel}} class="rc-input__element" /> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
{{/unless}} |
||||
|
||||
<div class="create-channel__inputs"> |
||||
<div class="rc-input"> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Thread_name"}}</div> |
||||
<div class="rc-input__wrapper"> |
||||
<input name="thread_name" id="thread_name" class="rc-input__element" placeholder="{{_ 'New_thread_name'}}" |
||||
maxlength="{{maxMessageLength}}" value="{{channelName}}"/> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
<div class="rc-input"> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ "Threading_first_message_title"}}</div> |
||||
<div class="rc-input__wrapper"> |
||||
<textarea name="thread_message" id="thread_message" class="rc-input__element" placeholder="{{_ 'New_thread_first_message'}}" |
||||
maxlength="{{maxMessageLength}}"></textarea> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
{{> SearchCreateThread |
||||
onClickTag=onClickTagUser |
||||
deleteLastItem=deleteLastItemUser |
||||
list=selectedUsers |
||||
onSelect=onSelectUser |
||||
collection='UserAndRoom' |
||||
subscription='userAutocomplete' |
||||
field='username' |
||||
sort='username' |
||||
label="Invite_Users" |
||||
placeholder="Username_Placeholder" |
||||
name="users" |
||||
icon="at" |
||||
noMatchTemplate="userSearchEmpty" |
||||
templateItem="popupList_item_default" |
||||
modifier=userModifier |
||||
}} |
||||
</div> |
||||
</form> |
||||
<div class="rc-input"> |
||||
<button form="create-thread" class="rc-button rc-button--primary js-save-thread {{createIsDisabled}}" {{createIsDisabled}}>{{_ " Create "}}</button> |
||||
</div> |
||||
</div> |
||||
</section> |
||||
</template> |
||||
|
||||
<template name="SearchCreateThread"> |
||||
<div class="rc-input" id='search-{{name}}' {{disabled}}> |
||||
<label class="rc-input__label"> |
||||
<div class="rc-input__title">{{_ label}}</div> |
||||
<div class="rc-input__wrapper"> |
||||
{{# if icon}} |
||||
<div class="rc-input__icon"> |
||||
{{> icon block="rc-input__icon-svg" icon=icon}} |
||||
</div> |
||||
{{/if}} |
||||
<div class="rc-tags{{# unless icon}} rc-tags--no-icon{{/unless}}"> |
||||
{{#each item in list}} {{> tag item}} {{/each}} |
||||
<input type="text" id="{{name}}" class="rc-tags__input" placeholder="{{_ placeholder}}" name="{{name}}" autocomplete="off" {{disabled}} /> |
||||
</div> |
||||
</div> |
||||
{{#with config}} {{#if autocomplete 'isShowing'}} |
||||
<div class="fadeInDown"> |
||||
{{> popupList data=config items=items}} |
||||
</div> |
||||
{{/if}} {{/with}} |
||||
</label> |
||||
<div class="rc-input__description">{{ description }}</div> |
||||
</div> |
||||
</template> |
||||
@ -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) => `<strong>${ part }</strong>`) }`; |
||||
}; |
||||
}, |
||||
userModifier() { |
||||
return (filter, text = '') => { |
||||
const f = filter.get(); |
||||
return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), (part) => `<strong>${ part }</strong>`) }`; |
||||
}; |
||||
}, |
||||
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; |
||||
}); |
||||
@ -0,0 +1,7 @@ |
||||
<template name="LastMessageAge"> |
||||
{{#with field}} |
||||
{{#if lastMessageAge}} |
||||
<button class="rc-tags__tag rc-tags--no-icon">{{lastMessageAge}}</button> |
||||
{{/if}} |
||||
{{/with}} |
||||
</template> |
||||
@ -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', {}); |
||||
@ -0,0 +1,19 @@ |
||||
<template name="MessageCounter"> |
||||
{{#with field}} |
||||
{{#if hasReplies}} |
||||
<button |
||||
class="js-navigate-to-thread rc-tags__tag rc-tags--no-icon" |
||||
data-rid={{roomId}} |
||||
> |
||||
<span class='reply-counter'>{{replyCount}}</span> {{_ i18nKeyReply}} |
||||
</button> |
||||
{{else}} |
||||
<button |
||||
class="js-navigate-to-thread rc-tags__tag rc-tags--no-icon no-replies" |
||||
data-rid={{roomId}} |
||||
> |
||||
{{_ "No_replies_yet" }} |
||||
</button> |
||||
{{/if}} |
||||
{{/with}} |
||||
</template> |
||||
@ -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); |
||||
@ -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()); |
||||
@ -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'); |
||||
}); |
||||
@ -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); |
||||
}); |
||||
}); |
||||
@ -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, |
||||
}); |
||||
}); |
||||
@ -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; |
||||
}); |
||||
@ -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'); |
||||
@ -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'; |
||||
@ -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 }); |
||||
}, |
||||
}); |
||||
@ -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 }); |
||||
} |
||||
} |
||||
}); |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,14 @@ |
||||
|
||||
import { getAvatarUrlFromUsername } from 'meteor/rocketchat:utils'; |
||||
import { roomTypes } from 'meteor/rocketchat:utils'; |
||||
export const attachMessage = function(message, room) { |
||||
const { msg, u: { username }, ts, attachments, _id } = message; |
||||
return { |
||||
text: msg, |
||||
author_name: username, |
||||
author_icon: getAvatarUrlFromUsername(username), |
||||
message_link: `${ roomTypes.getRouteLink(room.t, room) }?msg=${ _id }`, |
||||
attachments, |
||||
ts, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,9 @@ |
||||
import { Messages, Subscriptions, Rooms } from 'meteor/rocketchat:models'; |
||||
import { callbacks } from 'meteor/rocketchat:callbacks'; |
||||
export const deleteRoom = function(rid) { |
||||
Messages.removeFilesByRoomId(rid); |
||||
Messages.removeByRoomId(rid); |
||||
Subscriptions.removeByRoomId(rid); |
||||
callbacks.run('afterDeleteRoom', rid); |
||||
return Rooms.removeById(rid); |
||||
}; |
||||
@ -0,0 +1,25 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { check } from 'meteor/check'; |
||||
import { Messages } from 'meteor/rocketchat:models'; |
||||
Meteor.methods({ |
||||
/** |
||||
* Non-reactively retrieves metadata about messages of a room |
||||
* @param {String} roomId |
||||
* @returns {visibleMessagesCount, lastMessageTimestamp} |
||||
*/ |
||||
'getRoomMessageMetadata'(roomId) { |
||||
|
||||
check(roomId, String); |
||||
if (!Meteor.userId()) { |
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); |
||||
} |
||||
|
||||
const metadata = {}; |
||||
|
||||
metadata.visibleMessagesCount = Messages.findVisibleByRoomId(roomId).count(); |
||||
const lastMessage = Messages.getLastVisibleMessageSentWithNoTypeByRoomId(roomId); |
||||
metadata.lastMessageTimestamp = lastMessage && lastMessage.ts; |
||||
|
||||
return metadata; |
||||
}, |
||||
}); |
||||
@ -1,2 +1,10 @@ |
||||
import './messageAttachment.html'; |
||||
import './messageAttachment'; |
||||
import './renderField.html'; |
||||
import './renderField'; |
||||
|
||||
import { registerFieldTemplate } from './renderField'; |
||||
|
||||
export { |
||||
registerFieldTemplate, |
||||
}; |
||||
|
||||
@ -0,0 +1,20 @@ |
||||
<template name="renderField"> |
||||
{{#if field.type}} |
||||
<!-- a custom rendering is requested --> |
||||
<div class="field"> |
||||
{{{specializedRendering field=field message=../..}}} |
||||
</div> |
||||
{{else}} |
||||
{{#if short}} |
||||
<div class="attachment-field attachment-field-short"> |
||||
<div class="attachment-field-title">{{field.title}}</div> |
||||
{{{RocketChatMarkdown field.value}}} |
||||
</div> |
||||
{{else}} |
||||
<div class="attachment-field"> |
||||
<div class="attachment-field-title">{{field.title}}</div> |
||||
{{{RocketChatMarkdown field.value}}} |
||||
</div> |
||||
{{/if}} |
||||
{{/if}} |
||||
</template> |
||||
@ -0,0 +1,56 @@ |
||||
import { Template } from 'meteor/templating'; |
||||
import { Blaze } from 'meteor/blaze'; |
||||
|
||||
const renderers = {}; |
||||
|
||||
/** |
||||
* The field templates will be rendered non-reactive for all messages by the messages-list (@see rocketchat-nrr) |
||||
* Thus, we cannot provide helpers or events to the template, but we need to register this interactivity at the parent |
||||
* template which is the room. The event will be bubbled by the Blaze-framework |
||||
* @param fieldType |
||||
* @param templateName |
||||
* @param helpers |
||||
* @param events |
||||
*/ |
||||
export function registerFieldTemplate(fieldType, templateName, events) { |
||||
renderers[fieldType] = templateName; |
||||
|
||||
// propagate helpers and events to the room template, changing the selectors
|
||||
// loop at events. For each event (like 'click .accept'), copy the function to a function of the room events.
|
||||
// While doing that, add the fieldType as class selector to the events function in order to avoid naming clashes
|
||||
if (events != null) { |
||||
const uniqueEvents = {}; |
||||
// rename the event handlers so they are unique in the "parent" template to which the events bubble
|
||||
for (const property in events) { |
||||
if (events.hasOwnProperty(property)) { |
||||
const event = property.substr(0, property.indexOf(' ')); |
||||
const selector = property.substr(property.indexOf(' ') + 1); |
||||
Object.defineProperty(uniqueEvents, |
||||
`${ event } .${ fieldType } ${ selector }`, |
||||
{ |
||||
value: events[property], |
||||
enumerable: true, // assign as a own property
|
||||
}); |
||||
} |
||||
} |
||||
Template.room.events(uniqueEvents); |
||||
} |
||||
} |
||||
|
||||
// onRendered is not being executed (no idea why). Consequently, we cannot use Blaze.renderWithData(), since we don't
|
||||
// have access to the DOM outside onRendered. Therefore, we can only translate the content of the field to HTML and
|
||||
// embed it non-reactively.
|
||||
// This in turn means that onRendered of the field template will not be processed either.
|
||||
// I guess it may have someting to do with rocketchat-nrr
|
||||
Template.renderField.helpers({ |
||||
specializedRendering({ hash: { field, message } }) { |
||||
let html = ''; |
||||
if (field.type && renderers[field.type]) { |
||||
html = Blaze.toHTMLWithData(Template[renderers[field.type]], { field, message }); |
||||
} else { |
||||
// consider the value already formatted as html
|
||||
html = field.value; |
||||
} |
||||
return `<div class="${ field.type }">${ html }</div>`; |
||||
}, |
||||
}); |
||||
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -1,37 +1,37 @@ |
||||
import { Blaze } from 'meteor/blaze'; |
||||
import { FlowRouter } from 'meteor/kadira:flow-router'; |
||||
import { BlazeLayout } from 'meteor/kadira:blaze-layout'; |
||||
import { Template } from 'meteor/templating'; |
||||
let oldRoute = ''; |
||||
const parent = document.querySelector('.main-content'); |
||||
// import { Blaze } from 'meteor/blaze';
|
||||
// import { FlowRouter } from 'meteor/kadira:flow-router';
|
||||
// import { BlazeLayout } from 'meteor/kadira:blaze-layout';
|
||||
// import { Template } from 'meteor/templating';
|
||||
// let oldRoute = '';
|
||||
// const parent = document.querySelector('.main-content');
|
||||
|
||||
FlowRouter.route('/create-channel', { |
||||
name: 'create-channel', |
||||
// FlowRouter.route('/create-channel', {
|
||||
// name: 'create-channel',
|
||||
|
||||
triggersEnter: [function() { |
||||
oldRoute = FlowRouter.current().oldRoute; |
||||
}], |
||||
// triggersEnter: [function() {
|
||||
// oldRoute = FlowRouter.current().oldRoute;
|
||||
// }],
|
||||
|
||||
action() { |
||||
if (parent) { |
||||
Blaze.renderWithData(Template.fullModal, { template: 'createChannel' }, parent); |
||||
} else { |
||||
BlazeLayout.render('main', { center: 'fullModal', template: 'createChannel' }); |
||||
} |
||||
}, |
||||
// action() {
|
||||
// if (parent) {
|
||||
// Blaze.renderWithData(Template.fullModal, { template: 'createChannel' }, parent);
|
||||
// } else {
|
||||
// BlazeLayout.render('main', { center: 'fullModal', template: 'createChannel' });
|
||||
// }
|
||||
// },
|
||||
|
||||
triggersExit: [function() { |
||||
Blaze.remove(Blaze.getView(document.getElementsByClassName('full-modal')[0])); |
||||
$('.main-content').addClass('rc-old'); |
||||
}], |
||||
}); |
||||
// triggersExit: [function() {
|
||||
// Blaze.remove(Blaze.getView(document.getElementsByClassName('full-modal')[0]));
|
||||
// $('.main-content').addClass('rc-old');
|
||||
// }],
|
||||
// });
|
||||
|
||||
Template.fullModal.events({ |
||||
'click button'() { |
||||
oldRoute ? history.back() : FlowRouter.go('home'); |
||||
}, |
||||
}); |
||||
// Template.fullModal.events({
|
||||
// 'click button'() {
|
||||
// oldRoute ? history.back() : FlowRouter.go('home');
|
||||
// },
|
||||
// });
|
||||
|
||||
Template.fullModal.onRendered(function() { |
||||
$('.main-content').removeClass('rc-old'); |
||||
}); |
||||
// Template.fullModal.onRendered(function() {
|
||||
// $('.main-content').removeClass('rc-old');
|
||||
// });
|
||||
|
||||
@ -0,0 +1,64 @@ |
||||
/* eslint-env mocha */ |
||||
/* eslint-disable func-names, prefer-arrow-callback, no-var, space-before-function-paren, |
||||
quotes, prefer-template, no-undef, no-unused-vars*/ |
||||
|
||||
import mainContent from '../../pageobjects/main-content.page'; |
||||
import sideNav from '../../pageobjects/side-nav.page'; |
||||
import { sendEscape } from '../../pageobjects/keyboard'; |
||||
import { threading } from '../../pageobjects/threading.page'; |
||||
import { username, email, password } from '../../data/user.js'; |
||||
import { checkIfUserIsValid } from '../../data/checks'; |
||||
const parentChannelName = 'unit-testing'; |
||||
const threadName = 'Lorem ipsum dolor sit amet'; |
||||
const message = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; |
||||
|
||||
describe('[Threading]', function () { |
||||
|
||||
before(function () { |
||||
checkIfUserIsValid(username, email, password); |
||||
sideNav.spotlightSearchIcon.waitForVisible(3000); |
||||
|
||||
try { |
||||
sideNav.spotlightSearchIcon.click(); |
||||
sideNav.searchChannel(parentChannelName); |
||||
console.log('Parent channel already Exists'); |
||||
} catch (e) { |
||||
sendEscape(); // leave a potentially opened search
|
||||
sideNav.createChannel(parentChannelName, true, false); |
||||
console.log('Parent channel created'); |
||||
} |
||||
}); |
||||
|
||||
describe('via creation screen', function() { |
||||
it('Create a thread', function () { |
||||
threading.createThread(parentChannelName, threadName, message); |
||||
}); |
||||
}); |
||||
|
||||
describe('from context menu', function() { |
||||
before(() => { |
||||
// sideNav.openChannel(parentChannelName);
|
||||
mainContent.sendMessage(message); |
||||
}); |
||||
|
||||
it('it should show a dialog for starting a thread', () => { |
||||
mainContent.openMessageActionMenu(); |
||||
threading.startThreadContextItem.click(); |
||||
}); |
||||
|
||||
it('it should have create a new room', function () { |
||||
mainContent.channelTitle.waitForVisible(3000); |
||||
}); |
||||
|
||||
it('The message should be copied', function () { |
||||
mainContent.waitForLastMessageEqualsText(message); |
||||
}); |
||||
}); |
||||
|
||||
after(function () { |
||||
it('remove parent channel', () => { |
||||
threading.deleteRoom(parentChannelName); |
||||
}); |
||||
}); |
||||
|
||||
}); |
||||
@ -0,0 +1,20 @@ |
||||
const Keys = { |
||||
TAB: '\uE004', |
||||
ENTER: '\uE007', |
||||
ESCAPE: 'u\ue00c', |
||||
}; |
||||
|
||||
const sendEnter = function() { |
||||
browser.keys(Keys.ENTER); |
||||
}; |
||||
|
||||
const sendEscape = function() { |
||||
browser.keys(Keys.ESCAPE); |
||||
}; |
||||
|
||||
const sendTab = function() { |
||||
browser.keys(Keys.TAB); |
||||
}; |
||||
|
||||
|
||||
export { sendEnter, sendEscape, sendTab }; |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue