[NEW] Threads (#13541)

Co-authored-by: Oliver Jägle <github@beimir.net>
Co-authored-by: vickyokrm <vickyokrm@gmail.com>
pull/12992/head^2
Guilherme Gazzo 7 years ago committed by Diego Sampaio
parent 8849ece29e
commit b93b31b8dc
  1. 2
      .gitignore
  2. 1
      .meteor/packages
  3. 1
      .meteor/versions
  4. 6
      .vscode/launch.json
  5. 6
      client/routes/roomRoute.js
  6. 2
      package.json
  7. 49
      packages/assistify-threading/client/createThreadMessageAction.js
  8. 20
      packages/assistify-threading/client/index.js
  9. 32
      packages/assistify-threading/client/lib/messageTypes/threadMessage.js
  10. 3
      packages/assistify-threading/client/lib/threadsOfRoom.js
  11. 34
      packages/assistify-threading/client/public/stylesheets/threading.css
  12. 13
      packages/assistify-threading/client/tabBar.js
  13. 33
      packages/assistify-threading/client/threadFromMessageBox.js
  14. 10
      packages/assistify-threading/client/views/ThreadList.html
  15. 25
      packages/assistify-threading/client/views/ThreadList.js
  16. 22
      packages/assistify-threading/client/views/ThreadsTabbar.html
  17. 54
      packages/assistify-threading/client/views/ThreadsTabbar.js
  18. 107
      packages/assistify-threading/client/views/creationDialog/CreateThread.html
  19. 306
      packages/assistify-threading/client/views/creationDialog/CreateThread.js
  20. 7
      packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.html
  21. 12
      packages/assistify-threading/client/views/fieldTypeThreadLastMessageAge.js
  22. 19
      packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.html
  23. 30
      packages/assistify-threading/client/views/fieldTypeThreadReplyCounter.js
  24. 21
      packages/assistify-threading/lib/threadRoomType.js
  25. 24
      packages/assistify-threading/package.js
  26. 9
      packages/assistify-threading/server/authorization.js
  27. 42
      packages/assistify-threading/server/config.js
  28. 22
      packages/assistify-threading/server/hooks/joinThreadOnMessage.js
  29. 28
      packages/assistify-threading/server/hooks/propagateThreadMetadata.js
  30. 14
      packages/assistify-threading/server/index.js
  31. 162
      packages/assistify-threading/server/methods/createThread.js
  32. 16
      packages/assistify-threading/server/permissions.js
  33. 44
      packages/assistify-threading/server/publications/threadParentAutocomplete.js
  34. 31
      packages/assistify-threading/server/publications/threadsOfRoom.js
  35. 4
      packages/chatpal-search/client/template/result.js
  36. 30
      packages/meteor-autocomplete/client/autocomplete-client.js
  37. 21
      packages/rocketchat-channel-settings/client/views/channelSettings.html
  38. 29
      packages/rocketchat-channel-settings/client/views/channelSettings.js
  39. 7
      packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
  40. 31
      packages/rocketchat-i18n/i18n/de.i18n.json
  41. 35
      packages/rocketchat-i18n/i18n/en.i18n.json
  42. 13
      packages/rocketchat-lib/lib/roomTypes/direct.js
  43. 24
      packages/rocketchat-lib/lib/roomTypes/private.js
  44. 24
      packages/rocketchat-lib/lib/roomTypes/public.js
  45. 14
      packages/rocketchat-lib/server/functions/attachMessage.js
  46. 11
      packages/rocketchat-lib/server/functions/cleanRoomHistory.js
  47. 8
      packages/rocketchat-lib/server/functions/createRoom.js
  48. 3
      packages/rocketchat-lib/server/functions/deleteMessage.js
  49. 9
      packages/rocketchat-lib/server/functions/deleteRoom.js
  50. 2
      packages/rocketchat-lib/server/functions/index.js
  51. 2
      packages/rocketchat-lib/server/functions/sendMessage.js
  52. 4
      packages/rocketchat-lib/server/methods/cleanRoomHistory.js
  53. 25
      packages/rocketchat-lib/server/methods/getRoomMessageMetadata.js
  54. 8
      packages/rocketchat-message-attachments/client/index.js
  55. 7
      packages/rocketchat-message-attachments/client/messageAttachment.html
  56. 20
      packages/rocketchat-message-attachments/client/renderField.html
  57. 56
      packages/rocketchat-message-attachments/client/renderField.js
  58. 60
      packages/rocketchat-models/server/models/Messages.js
  59. 34
      packages/rocketchat-models/server/models/Rooms.js
  60. 5
      packages/rocketchat-models/server/models/Subscriptions.js
  61. 5
      packages/rocketchat-retention-policy/server/cronPruneMessages.js
  62. 8
      packages/rocketchat-ui-account/client/accountPreferences.html
  63. 1
      packages/rocketchat-ui-account/client/accountPreferences.js
  64. 4
      packages/rocketchat-ui-flextab/client/tabs/userInfo.html
  65. 5
      packages/rocketchat-ui-master/public/icons.svg
  66. 3
      packages/rocketchat-ui-master/public/icons/thread.svg
  67. 2
      packages/rocketchat-ui-message/client/message.js
  68. 2
      packages/rocketchat-ui-message/client/popup/messagePopupChannel.js
  69. 20
      packages/rocketchat-ui-sidenav/client/chatRoomItem.js
  70. 11
      packages/rocketchat-ui-sidenav/client/roomList.js
  71. 1
      packages/rocketchat-ui-sidenav/client/sideNav.js
  72. 59
      packages/rocketchat-ui-sidenav/client/sidebarHeader.js
  73. 4
      packages/rocketchat-ui-utils/client/lib/RoomHistoryManager.js
  74. 8
      packages/rocketchat-ui-utils/client/lib/modal.html
  75. 2
      packages/rocketchat-ui-utils/client/lib/modal.js
  76. 2
      packages/rocketchat-ui-utils/client/lib/popover.js
  77. 17
      packages/rocketchat-ui/client/components/header/headerRoom.html
  78. 39
      packages/rocketchat-ui/client/components/header/headerRoom.js
  79. 7
      packages/rocketchat-ui/client/lib/chatMessages.js
  80. 14
      packages/rocketchat-ui/client/views/app/createChannel.html
  81. 8
      packages/rocketchat-ui/client/views/app/createChannel.js
  82. 62
      packages/rocketchat-ui/client/views/app/fullModal.js
  83. 20
      packages/rocketchat-ui/client/views/app/room.js
  84. 2
      packages/rocketchat-ui/client/views/app/roomSearch.js
  85. 7
      packages/rocketchat-utils/client/lib/roomTypes.js
  86. 14
      packages/rocketchat_theme/client/imports/components/header.css
  87. 3
      packages/rocketchat_theme/client/imports/components/modal.css
  88. 15
      packages/rocketchat_theme/client/imports/components/modal/create-channel.css
  89. 13
      packages/rocketchat_theme/client/imports/forms/input.css
  90. 5
      packages/rocketchat_theme/client/imports/forms/popup-list.css
  91. 1
      server/main.js
  92. 22
      server/methods/eraseRoom.js
  93. 3
      server/methods/getRoomById.js
  94. 9
      server/publications/room.js
  95. 3
      server/publications/subscription.js
  96. 2
      tests/end-to-end/api/00-miscellaneous.js
  97. 2
      tests/end-to-end/ui/04-main-elements-render.js
  98. 64
      tests/end-to-end/ui/15-threading.js
  99. 1
      tests/pageobjects/flex-tab.page.js
  100. 20
      tests/pageobjects/keyboard.js
  101. Some files were not shown because too many files have changed in this diff Show More

2
.gitignore vendored

@ -75,3 +75,5 @@ settings.json
build.sh
/public/livechat
packages/rocketchat-i18n/i18n/livechat.*
tests/end-to-end/temporary_staged_test
.screenshots

@ -197,6 +197,7 @@ rocketchat:search
chatpal:search
rocketchat:lazy-load
tap:i18n
assistify:threading
underscore@1.0.10
rocketchat:bigbluebutton
rocketchat:mailmessages

@ -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

@ -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"

@ -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);
};

@ -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",

@ -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>&nbsp;{{_ 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();
});
});

@ -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 });

@ -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() {

@ -330,18 +330,21 @@
</div>
</template>
<template name="channelSettingsInfo">
<main class="rc-user-info__scroll{{#if archived}} archived{{/if}}">
<section class="rc-user-info__scroll{{#if archived}} archived{{/if}}">
{{# with settings=settings}}
{{#unless isDirectMessage}}
<div class="rc-user-info">
<div class="rc-user-info__avatar">
{{#if archived}}
<div class="rc-user-info__banner">
{{_ "Room_archived"}}
{{#if showAvatar}}
<div class="rc-user-info__avatar">
{{#if archived}}
<div class="rc-user-info__banner">
{{_ "Room_archived"}}
</div>
{{/if}}
{{> avatar username=channelName}}
</div>
{{/if}}
{{> avatar username=channelName}}
</div>
{{/if}}
<h3 title="{{name}}" class="rc-user-info__name">{{> icon block="rc-header__icon" icon=channelIcon}}{{ unscape name}}</h3>
{{#if password}}
<label class="rc-user-info__label">
@ -401,7 +404,7 @@
{{> Template.dynamic template=template data=data}}
</div>
{{/each}}
</main>
</section>
<div class="rc-user-info__flex rc-user-info__row">
{{#if canEditRoom}}

@ -29,12 +29,12 @@ const common = {
return roomType && roomTypes.roomTypes[roomType].canBeDeleted(hasPermission, room);
},
canEditRoom() {
const { _id } = Template.instance().room;
return hasAllPermission('edit-room', _id);
const { _id, prid } = Template.instance().room;
return !prid && hasAllPermission('edit-room', _id);
},
isDirectMessage() {
const { room } = Template.instance();
return room.t === 'd';
const { room: { t } } = Template.instance();
return t === 'd';
},
};
@ -797,8 +797,13 @@ Template.channelSettingsInfo.helpers({
channelSettings() {
return ChannelSettings.getOptions(Template.currentData(), 'room');
},
showAvatar() {
const { room } = Template.instance();
return !room.prid;
},
name() {
return Template.instance().room.name;
const { room } = Template.instance();
return roomTypes.getRoomName(room.t, room);
},
description() {
return Template.instance().room.description;
@ -814,19 +819,7 @@ Template.channelSettingsInfo.helpers({
},
channelIcon() {
const roomType = Template.instance().room.t;
switch (roomType) {
case 'd':
return 'at';
case 'p':
return 'lock';
case 'c':
return 'hashtag';
case 'l':
return 'livechat';
default:
return null;
}
return roomTypes.getIcon(Template.instance().room);
},
hasPurge() {
return roomHasPurge(Template.instance().room);

@ -59,6 +59,13 @@ Meteor.methods({
});
}
if (room.prid) {
throw new Meteor.Error('error-action-not-allowed', 'Editing thread room is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room',
});
}
if (room.broadcast && (settings.readOnly || settings.reactWhenReadOnly)) {
throw new Meteor.Error('error-action-not-allowed', 'Editing readOnly/reactWhenReadOnly are not allowed for broadcast rooms', {
method: 'saveRoomSettings',

@ -2012,6 +2012,9 @@
"New_encryption_password": "Neues Verschlüsselungs-Passwort",
"New_role": "Neue Rolle",
"New_Room_Notification": "Neuer-Raum-Benachrichtigung",
"New_thread": "Neuer Thread",
"New_thread_name": "Ein sinnvoller Name für den Thread-Raum",
"New_thread_first_message": "Üblicherweise beginnt ein Thread mit einer Frage, bspw. \"Wie lade ich ein Bild hoch?\"",
"New_Trigger": "Neuer Trigger",
"New_version_available_(s)": "Neue Version verfügbar (%s)",
"New_videocall_request": "Neuer Video-Anruf",
@ -2031,10 +2034,12 @@
"No_messages_yet": "Bisher keine Nachrichten",
"No_pages_yet_Try_hitting_Reload_Pages_button": "Bisher keine Seite. Versicherung die Seite neu zu laden",
"No_pinned_messages": "Es wurden bisher keine Nachrichten fixiert",
"No_replies_yet": "Bisher keine Antworten",
"No_results_found": "Keine Ergebnisse gefunden",
"No_results_found_for": "Keine Ergebnisse gefunden für:",
"No_snippet_messages": "Keine Snippets vorhanden",
"No_starred_messages": "Es wurden bisher keine Nachrichten favorisiert",
"No_threads_yet": "Keine Threads vorhanden",
"No_such_command": "Es gibt keinen Befehl '/__command__'",
"No_user_with_username_%s_was_found": "Es wurde kein Benutzer mit dem Namen <strong>\"%s\"</strong> gefunden!",
"Nobody_available": "Es ist niemand verfügbar",
@ -2315,6 +2320,7 @@
"Remove_someone_from_room": "Jemanden aus dem Raum entfernen",
"Removed": "Entfernt",
"Removed_User": "Benutzer wurde entfernt",
"Replies": "Antworten",
"Reply": "Antwort",
"ReplyTo": "Antwort an",
"Report_Abuse": "Missbrauch melden",
@ -2740,6 +2746,28 @@
"This_month": "Diesen Monat",
"This_room_has_been_archived_by__username_": "Dieser Raum wurde von __username__ archiviert",
"This_room_has_been_unarchived_by__username_": "Dieser Raum wurde von __username__ aus dem Archiv geholt",
"Thread_creation_on_home": "Threads von der Home-Seite aus anlegen",
"Thread_default_parent_Channel": "Standard-Kanal für neue Threads",
"Thread_from_context_menu": "Threads im Kontext-Menü",
"Thread_name": "Thread Name",
"Thread_invitations_threshold_description": "Max. Anzahl der Benutzer, die automatisch zu einem öffentlichen Thread hinzugezogen werden",
"Thread_invitations_threshold": "Max. Anzahl automatisch einzuladender Benutzer",
"Thread_slash_command_description": "Erstelle einen Thread zum aktuellen Kanal",
"Thread_slash_command_params": "Deine Nachricht",
"Thread_start": "Thread starten",
"Thread_target_channel_description": "Wähle einen Kanal oder eine Gruppe aus, die zu Deinem Anliegen passt",
"Thread_target_channel_prefix": "Du erstellst einen Thread in",
"Thread_target_channel_suffix": "- wähle einen anderen übergeordneten Kanal aus",
"Thread_target_channel": "Übergeordneter Kanal oder Gruppe",
"thread-created": "Ich habe einen neuen Thread angelegt: \"__message__\"",
"thread-welcome": "Danke __username__, dass Du einen neuen Thread angelegt hast! Ich habe für Dich Mitglieder aus __parentChannel__ eingeladen. Tipp: mit \"@all\" kannst Du sie anstupsen, wenn sich länger niemand melden sollte ;)",
"thread": "Thread",
"Threading_context_menu_button": "Separater Button",
"Threading_context_menu_none": "Unsichtbar",
"Threading_description": "Erstelle einen Thread, um wichtigen Dingen mehr Raum zu geben. Dort kannst Du mit allen verfügbaren Mitgliedern schreiben, ohne andere zu stören. So sorgst Du für etwas mehr Ordnung in Eurem Chat.",
"Threading_first_message_title": "Deine Nachricht",
"Threads": "Threads",
"Threading_title": "Einen neuen Thread anlegen",
"This_week": "Diese Woche",
"Thursday": "Donnerstag",
"Time_in_seconds": "Zeit in Sekunden",
@ -3094,6 +3122,7 @@
"your_message_optional": "Ihre optionale Nachricht",
"Your_password_is_wrong": "Falsches Passwort",
"Your_push_was_sent_to_s_devices": "Eine Push-Nachricht wurde an %s Geräte gesendet.",
"Your_question": "Deine Frage",
"Your_server_link": "Ihre Serververbindung",
"Your_workspace_is_ready": "Ihr Arbeitsbereich ist einsatzbereit 🎉"
}
}

@ -2050,6 +2050,9 @@
"New_encryption_password": "New encryption password",
"New_role": "New role",
"New_Room_Notification": "New Room Notification",
"New_thread": "New thread",
"New_thread_name": "A meaningful name for the thread room",
"New_thread_first_message": "Usually, a thread starts with a question, like \"How do I upload a picture?\"",
"New_Trigger": "New Trigger",
"New_version_available_(s)": "New version available (%s)",
"New_videocall_request": "New Video Call Request",
@ -2069,11 +2072,13 @@
"No_messages_yet": "No messages yet",
"No_pages_yet_Try_hitting_Reload_Pages_button": "No pages yet. Try hitting \"Reload Pages\" button.",
"No_pinned_messages": "No pinned messages",
"No_replies_yet": "No replies yet",
"No_results_found": "No results found",
"No_results_found_for": "No results found for:",
"No_snippet_messages": "No snippet",
"No_starred_messages": "No starred messages",
"No_such_command": "No such command: `/__command__`",
"No_threads_yet": "No threads yet",
"No_user_with_username_%s_was_found": "No user with username <strong>\"%s\"</strong> was found!",
"Nobody_available": "Nobody available",
"Node_version": "Node Version",
@ -2173,6 +2178,7 @@
"Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Override URL to which files are uploaded. This url also used for downloads unless a CDN is given",
"Page_title": "Page title",
"Page_URL": "Page URL",
"Parent_channel_doesnt_exist": "Channel does not exist.",
"Password": "Password",
"Password_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of passwords",
"Password_changed_successfully": "Password changed successfully",
@ -2354,6 +2360,7 @@
"Remove_someone_from_room": "Remove someone from the room",
"Removed": "Removed",
"Removed_User": "Removed User",
"Replies": "Replies",
"Reply": "Reply",
"ReplyTo": "Reply-To",
"Report_Abuse": "Report Abuse",
@ -2376,6 +2383,7 @@
"Retail": "Retail",
"Retention_setting_changed_successfully": "Retention policy setting changed successfully",
"RetentionPolicy": "Retention Policy",
"RetentionPolicy_DoNotExcludeThreads": "Do not exclude thread messages",
"RetentionPolicy_RoomWarning": "Messages older than __time__ are automatically pruned here",
"RetentionPolicy_RoomWarning_Unpinned": "Unpinned messages older than __time__ are automatically pruned here",
"RetentionPolicy_RoomWarning_FilesOnly": "Files older than __time__ are automatically pruned here (messages stay intact)",
@ -2708,6 +2716,7 @@
"The_emails_are_being_sent": "The emails are being sent.",
"The_field_is_required": "The field %s is required.",
"The_image_resize_will_not_work_because_we_can_not_detect_ImageMagick_or_GraphicsMagick_installed_in_your_server": "The image resize will not work because we can not detect ImageMagick or GraphicsMagick installed on your server.",
"The_message_is_a_thread_you_will_not_be_able_to_recover": "The message is a thread you will not be able to recover the messages!",
"The_peer__peer__does_not_exist": "The peer <em>__peer__</em> does not exist.",
"The_redirectUri_is_required": "The redirectUri is required",
"The_server_will_restart_in_s_seconds": "The server will restart in %s seconds",
@ -2781,6 +2790,29 @@
"This_month": "This Month",
"This_room_has_been_archived_by__username_": "This room has been archived by __username__",
"This_room_has_been_unarchived_by__username_": "This room has been unarchived by __username__",
"Thread_creation_on_home": "Create threads on home screen",
"Thread_default_parent_Channel": "Default channel for new Threads",
"Thread_from_context_menu": "Threads in context-menu",
"Thread_invitations_threshold_description": "Max. count of users who are automatically being invited into a public thread",
"Thread_invitations_threshold": "Max. users to be automatically invited",
"Thread_name": "Thread name",
"Thread_slash_command_description": "Creates a thread for the current channel",
"Thread_slash_command_params": "your message",
"Thread_start": "Start a thread",
"Thread_target_channel_description": "Select a channel which is related to what you want to ask",
"Thread_target_channel_prefix": "You are creating a thread in",
"Thread_target_channel_suffix": "- select a different parent channel",
"Thread_target_channel": "Parent channel or group",
"thread-created" : "Started a new thread: \"__message__\"",
"thread-welcome" : "Thanks __username__ for creating a thread! I invited some members from __parentChannel__ who shall be able to help you. Hint: You can poke them with \"@all\", in case there's nothing happening for a longer time ;)",
"thread": "thread",
"Threading_context_menu_button": "Dedicated button",
"Threading_context_menu_none": "Invisible",
"Threading_description": "Help keeping an overview about what's going on! By creating a thread, a sub-channel of the one you selected is created and both are linked.",
"Threading_first_message_title": "Your message",
"Threads_in_sidebar": "Show threads category on sidebar",
"Threads": "Threads",
"Threading_title": "Create a new thread",
"This_week": "This Week",
"Thursday": "Thursday",
"Time_in_seconds": "Time in seconds",
@ -3135,6 +3167,7 @@
"your_message_optional": "your message (optional)",
"Your_password_is_wrong": "Your password is wrong!",
"Your_push_was_sent_to_s_devices": "Your push was sent to %s devices",
"Your_question": "Your question",
"Your_server_link": "Your server link",
"Your_workspace_is_ready": "Your workspace is ready to use 🎉"
}
}

@ -50,9 +50,16 @@ export class DirectMessageRoomType extends RoomTypeConfig {
}
roomName(roomData) {
const subscription = Subscriptions.findOne({ rid: roomData._id }, { fields: { name: 1, fname: 1 } });
if (!subscription) {
return '';
// this function can receive different types of data
// if it doesn't have fname and name properties, should be a Room object
// so, need to find the related subscription
const subscription = roomData && (roomData.fname || roomData.name) ?
roomData :
Subscriptions.findOne({ rid: roomData._id });
if (subscription === undefined) {
return console.log('roomData', roomData);
}
if (settings.get('UI_Use_Real_Name') && subscription.fname) {

@ -1,5 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { ChatRoom } from 'meteor/rocketchat:models';
import { ChatRoom, ChatSubscription } from 'meteor/rocketchat:models';
import { openRoom } from 'meteor/rocketchat:ui-utils';
import { settings } from 'meteor/rocketchat:settings';
import { hasAtLeastOnePermission, hasPermission } from 'meteor/rocketchat:authorization';
@ -29,6 +29,13 @@ export class PrivateRoomType extends RoomTypeConfig {
});
}
getIcon(roomData) {
if (roomData.prid) {
return 'thread';
}
return this.icon;
}
findRoom(identifier) {
const query = {
t: 'p',
@ -39,6 +46,9 @@ export class PrivateRoomType extends RoomTypeConfig {
}
roomName(roomData) {
if (roomData.prid) {
return roomData.fname;
}
if (settings.get('UI_Allow_room_names_with_special_chars')) {
return roomData.fname || roomData.name;
}
@ -59,6 +69,18 @@ export class PrivateRoomType extends RoomTypeConfig {
return hasAtLeastOnePermission(['add-user-to-any-p-room', 'add-user-to-joined-room'], room._id);
}
canSendMessage(roomId) {
const room = ChatRoom.findOne({ _id: roomId, t: 'p' }, { fields: { prid: 1 } });
if (room.prid) {
return true;
}
// TODO: remove duplicated code
return ChatSubscription.find({
rid: roomId,
}).count() > 0;
}
allowRoomSettingChange(room, setting) {
switch (setting) {
case RoomSettingsEnum.JOIN_CODE:

@ -1,6 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { openRoom } from 'meteor/rocketchat:ui-utils';
import { ChatRoom } from 'meteor/rocketchat:models';
import { ChatRoom, ChatSubscription } from 'meteor/rocketchat:models';
import { settings } from 'meteor/rocketchat:settings';
import { hasAtLeastOnePermission } from 'meteor/rocketchat:authorization';
import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext } from 'meteor/rocketchat:utils';
@ -29,6 +29,13 @@ export class PublicRoomType extends RoomTypeConfig {
});
}
getIcon(roomData) {
if (roomData.prid) {
return 'thread';
}
return this.icon;
}
findRoom(identifier) {
const query = {
t: 'c',
@ -38,6 +45,9 @@ export class PublicRoomType extends RoomTypeConfig {
}
roomName(roomData) {
if (roomData.prid) {
return roomData.fname;
}
if (settings.get('UI_Allow_room_names_with_special_chars')) {
return roomData.fname || roomData.name;
}
@ -65,6 +75,18 @@ export class PublicRoomType extends RoomTypeConfig {
return hasAtLeastOnePermission(['add-user-to-any-c-room', 'add-user-to-joined-room'], room._id);
}
canSendMessage(roomId) {
const room = ChatRoom.findOne({ _id: roomId, t: 'c' }, { fields: { prid: 1 } });
if (room.prid) {
return true;
}
// TODO: remove duplicated code
return ChatSubscription.find({
rid: roomId,
}).count() > 0;
}
enableMembersListProfile() {
return true;
}

@ -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,
};
};

@ -2,8 +2,9 @@ import { TAPi18n } from 'meteor/tap:i18n';
import { FileUpload } from 'meteor/rocketchat:file-upload';
import { Messages, Rooms } from 'meteor/rocketchat:models';
import { Notifications } from 'meteor/rocketchat:notifications';
import { deleteRoom } from './deleteRoom';
export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, filesOnly = false, fromUsers = [] }) {
export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, ignoreThreads = true, filesOnly = false, fromUsers = [] }) {
const gt = inclusive ? '$gte' : '$gt';
const lt = inclusive ? '$lte' : '$lt';
@ -15,6 +16,7 @@ export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = ne
Messages.findFilesByRoomIdPinnedTimestampAndUsers(
rid,
excludePinned,
ignoreThreads,
ts,
fromUsers,
{ fields: { 'file._id': 1, pinned: 1 }, limit }
@ -29,13 +31,18 @@ export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = ne
return fileCount;
}
const count = limit ? Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ts, limit, fromUsers) : Messages.removeByIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers);
if (!ignoreThreads) {
Messages.findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreThreads, ts, fromUsers, { fields: { trid: 1 }, ...(limit && { limit }) }).forEach(({ trid }) => deleteRoom(trid));
}
const count = limit ? Messages.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreThreads, ts, limit, fromUsers) : Messages.removeByIdPinnedTimestampAndUsers(rid, excludePinned, ignoreThreads, ts, fromUsers);
if (count) {
Rooms.resetLastMessageById(rid);
Notifications.notifyRoom(rid, 'deleteMessageBulk', {
rid,
excludePinned,
ignoreThreads,
ts,
users: fromUsers,
});

@ -145,6 +145,10 @@ export const createRoom = function(type, name, owner, members, readOnly, extraDa
extra.open = true;
if (room.prid) {
extra.prid = room.prid;
}
if (username === owner.username) {
extra.ls = now;
}
@ -174,7 +178,7 @@ export const createRoom = function(type, name, owner, members, readOnly, extraDa
}
return {
rid: room._id,
name: room.name,
rid: room._id, // backwards compatible
...room,
};
};

@ -38,13 +38,13 @@ export const deleteMessage = function(message, user) {
}
}
const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } });
Meteor.defer(function() {
callbacks.run('afterDeleteMessage', deletedMsg);
});
// update last message
if (settings.get('Store_Last_Message')) {
const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1 } });
if (!room.lastMessage || room.lastMessage._id === message._id) {
Rooms.resetLastMessageById(message.rid, message._id);
}
@ -60,4 +60,3 @@ export const deleteMessage = function(message, user) {
Apps.getBridges().getListenerBridge().messageEvent('IPostMessageDeleted', deletedMsg);
}
};

@ -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);
};

@ -1,11 +1,13 @@
export { addUserToDefaultChannels } from './addUserToDefaultChannels';
export { addUserToRoom } from './addUserToRoom';
export { archiveRoom } from './archiveRoom';
export { attachMessage } from './attachMessage';
export { checkEmailAvailability } from './checkEmailAvailability';
export { checkUsernameAvailability } from './checkUsernameAvailability';
export { cleanRoomHistory } from './cleanRoomHistory';
export { createRoom } from './createRoom';
export { deleteMessage } from './deleteMessage';
export { deleteRoom } from './deleteRoom';
export { deleteUser } from './deleteUser';
export { getFullUserData } from './getFullUserData';
export { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin';

@ -161,7 +161,7 @@ export const sendMessage = function(user, message, room, upsert = false) {
delete message.tokens;
}
message = callbacks.run('beforeSaveMessage', message);
message = callbacks.run('beforeSaveMessage', message, room);
if (message) {
// Avoid saving sandstormSessionId to the database
let sandstormSessionId = null;

@ -4,7 +4,7 @@ import { hasPermission } from 'meteor/rocketchat:authorization';
import { cleanRoomHistory } from '../functions';
Meteor.methods({
cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, filesOnly = false, fromUsers = [] }) {
cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, ignoreThreads = true, filesOnly = false, fromUsers = [] }) {
check(roomId, String);
check(latest, Date);
check(oldest, Date);
@ -24,6 +24,6 @@ Meteor.methods({
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' });
}
return cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, filesOnly, fromUsers });
return cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, ignoreThreads, filesOnly, fromUsers });
},
});

@ -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,
};

@ -145,11 +145,8 @@
{{#if fields}}
{{#unless collapsed}}
<div class="attachment-fields">
{{#each fields}}
<div class="attachment-field{{# if short }} attachment-field-short{{/if}}">
<div class="attachment-field-title">{{title}}</div>
{{{RocketChatMarkdown value}}}
</div>
{{#each field in fields}}
{{> renderField field=field}}
{{/each}}
</div>
{{/unless}}

@ -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>`;
},
});

@ -24,6 +24,10 @@ export class Messages extends Base {
this.tryEnsureIndex({ location: '2dsphere' });
this.tryEnsureIndex({ slackBotId: 1, slackTs: 1 }, { sparse: 1 });
this.tryEnsureIndex({ unread: 1 }, { sparse: true });
// threads
this.tryEnsureIndex({ trid: 1 }, { sparse: true });
this.loadSettings();
}
@ -156,7 +160,7 @@ export class Messages extends Base {
return this.find(query, { fields: { 'file._id': 1 }, ...options });
}
findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, users = [], options = {}) {
findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreThreads = true, ts, users = [], options = {}) {
const query = {
rid,
ts,
@ -167,6 +171,10 @@ export class Messages extends Base {
query.pinned = { $ne: true };
}
if (!ignoreThreads) {
query.trid = { $exists: 0 };
}
if (users.length) {
query['u.username'] = { $in: users };
}
@ -836,7 +844,7 @@ export class Messages extends Base {
return this.remove(query);
}
removeByIdPinnedTimestampAndUsers(rid, pinned, ts, users = []) {
removeByIdPinnedTimestampAndUsers(rid, pinned, ignoreThreads = true, ts, users = []) {
const query = {
rid,
ts,
@ -845,7 +853,9 @@ export class Messages extends Base {
if (pinned) {
query.pinned = { $ne: true };
}
if (!ignoreThreads) {
query.trid = { $exists: 0 };
}
if (users.length) {
query['u.username'] = { $in: users };
}
@ -853,7 +863,7 @@ export class Messages extends Base {
return this.remove(query);
}
removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ts, limit, users = []) {
removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ignoreThreads = true, ts, limit, users = []) {
const query = {
rid,
ts,
@ -863,6 +873,10 @@ export class Messages extends Base {
query.pinned = { $ne: true };
}
if (!ignoreThreads) {
query.trid = { $exists: 0 };
}
if (users.length) {
query['u.username'] = { $in: users };
}
@ -948,6 +962,44 @@ export class Messages extends Base {
},
});
}
/**
* Copy metadata from the thread to the system message in the parent channel
* which links to the thread.
* Since we don't pass this metadata into the model's function, it is not a subject
* to race conditions: If multiple updates occur, the current state will be updated
* only if the new state of the thread room is really newer.
*/
refreshThreadMetadata({ rid }) {
if (!rid) {
return false;
}
const { lm, msgs: count } = Rooms.findOneById(rid, {
fields: {
msgs: 1,
lm: 1,
},
});
const query = {
trid: rid,
};
return this.update(query, {
$set: {
'attachments.0.fields': [
{
type: 'messageCounter',
count,
},
{
type: 'lastMessageAge',
lm,
},
],
},
}, { multi: 1 });
}
}
export default new Messages();

@ -17,6 +17,9 @@ export class Rooms extends Base {
this.tryEnsureIndex({ 'tokenpass.tokens.token': 1 });
this.tryEnsureIndex({ open: 1 }, { sparse: 1 });
this.tryEnsureIndex({ departmentId: 1 }, { sparse: 1 });
// threads
this.tryEnsureIndex({ prid: 1 });
}
findOneByIdOrName(_idOrName, options) {
@ -1363,6 +1366,37 @@ export class Rooms extends Base {
return this.remove(query);
}
// ############################
// Threads
findThreadParentByNameStarting(name, options) {
const nameRegex = new RegExp(`^${ s.trim(s.escapeRegExp(name)) }`, 'i');
const query = {
t: {
$in: ['c'],
},
name: nameRegex,
archived: { $ne: true },
prid: {
$exists: false,
},
};
return this.find(query, options);
}
setLinkMessageById(_id, linkMessageId) {
const query = { _id };
const update = {
$set: {
linkMessageId,
},
};
return this.update(query, update);
}
}
export default new Rooms('room', true);

@ -28,6 +28,7 @@ export class Subscriptions extends Base {
this.tryEnsureIndex({ autoTranslate: 1 }, { sparse: 1 });
this.tryEnsureIndex({ autoTranslateLanguage: 1 }, { sparse: 1 });
this.tryEnsureIndex({ 'userHighlights.0': 1 }, { sparse: 1 });
this.tryEnsureIndex({ prid: 1 });
}
findByRoomIds(roomIds) {
@ -1221,6 +1222,10 @@ export class Subscriptions extends Base {
...extraData,
};
if (room.prid) {
subscription.prid = room.prid;
}
const result = this.insert(subscription);
Rooms.incUsersCountById(room._id);

@ -21,6 +21,7 @@ function job() {
const now = new Date();
const filesOnly = settings.get('RetentionPolicy_FilesOnly');
const excludePinned = settings.get('RetentionPolicy_ExcludePinned');
const ignoreThreads = settings.get('RetentionPolicy_DoNotExcludeThreads');
// get all rooms with default values
types.forEach((type) => {
@ -36,7 +37,7 @@ function job() {
],
'retention.overrideGlobal': { $ne: true },
}, { fields : { _id: 1 } }).forEach(({ _id: rid }) => {
cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned });
cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned, ignoreThreads });
});
});
@ -48,7 +49,7 @@ function job() {
}).forEach((room) => {
const { maxAge = 30, filesOnly, excludePinned } = room.retention;
const latest = new Date(now.getTime() - maxAge * toDays);
cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned });
cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned, ignoreThreads });
});
lastPrune = new Date(now.getTime() - gracePeriod);
}

@ -270,6 +270,14 @@
<label><input type="radio" name="roomCounterSidebar" value="false" checked="{{checked 'roomCounterSidebar' false}}"/> {{_ "False"}}</label>
</div>
</div>
<div class="input-line double-col" id="sidebarShowThreads">
<label class="setting-label">{{_ "Threads_in_sidebar"}}</label>
<div>
<label><input type="radio" name="sidebarShowThreads" value="true" checked="{{checked 'sidebarShowThreads' true}}"/> {{_ "True"}}</label>
<label><input type="radio" name="sidebarShowThreads" value="false" checked="{{checked 'sidebarShowThreads' false}}"/> {{_ "False"}}</label>
</div>
</div>
</div>
</div>
<div class="section">

@ -167,6 +167,7 @@ Template.accountPreferences.onCreated(function() {
data.desktopNotifications = $('#desktopNotifications').find('select').val();
data.mobileNotifications = $('#mobileNotifications').find('select').val();
data.unreadAlert = JSON.parse($('#unreadAlert').find('input:checked').val());
data.sidebarShowThreads = JSON.parse($('#sidebarShowThreads').find('input:checked').val());
data.notificationsSoundVolume = parseInt($('#notificationsSoundVolume').val());
data.roomCounterSidebar = JSON.parse($('#roomCounterSidebar').find('input:checked').val());
data.highlights = _.compact(_.map($('[name=highlights]').val().split(/,|\n/), function(e) {

@ -18,7 +18,7 @@
{{> userEdit (userToEdit)}}
{{else}}
{{#with user}}
<main class="contextual-bar__content">
<section class="contextual-bar__content">
<div class="rc-user-info-wrapper">
<div class="rc-user-info">
<div class="rc-user-info__avatar">
@ -114,7 +114,7 @@
{{#if ../showAll}}
<button class="rc-button rc-button--outline js-back" title="{{_ 'View_All'}}">{{_ "View_All"}}</button>
{{/if}}
</main>
</section>
{{/with}}
{{/if}}
{{/if}}

@ -317,6 +317,9 @@
<symbol id="icon-th-list" viewBox="0 0 20 20" fill="currentColor">
<path d="M15.4857143,3 C16.8726857,3 18,4.11367742 18,5.48387097 L18,14.516129 C18,15.8863226 16.8726857,17 15.4857143,17 L4.51428571,17 C3.12731429,17 2,15.8863226 2,14.516129 L2,5.48387097 C2,4.11367742 3.12731429,3 4.51428571,3 L15.4857143,3 Z M15.4857143,15.6451613 C16.1156571,15.6451613 16.6285714,15.1384516 16.6285714,14.516129 L16.6285714,12.483871 L7.94285714,12.483871 L7.94285714,15.6451613 L15.4857143,15.6451613 Z M3.37142857,14.516129 C3.37142857,15.1384516 3.88434286,15.6451613 4.51428571,15.6451613 L6.57142857,15.6451613 L6.57142857,12.483871 L3.37142857,12.483871 L3.37142857,14.516129 Z M4.51428571,4.35483871 C3.88434286,4.35483871 3.37142857,4.86154839 3.37142857,5.48387097 L3.37142857,6.61290323 L6.57142857,6.61290323 L6.57142857,4.35483871 L4.51428571,4.35483871 Z M16.6285714,5.48387097 C16.6285714,4.86154839 16.1156571,4.35483871 15.4857143,4.35483871 L7.94285714,4.35483871 L7.94285714,6.61290323 L16.6285714,6.61290323 L16.6285714,5.48387097 Z M3.37142857,11.1290323 L6.57142857,11.1290323 L6.57142857,7.96774194 L3.37142857,7.96774194 L3.37142857,11.1290323 Z M7.94285714,11.1290323 L16.6285714,11.1290323 L16.6285714,7.96774194 L7.94285714,7.96774194 L7.94285714,11.1290323 Z"/>
</symbol>
<symbol viewBox="0 0 20 20" id="icon-thread">
<path d="M10.1102,16.5461 C14.6082,16.5611 16.0062,15.8201 16.4222,15.3421 C16.3202,15.2811 16.1942,15.2151 16.0962,15.1641 C15.4632,14.8301 13.9762,14.0471 14.7322,12.4211 C14.8092,12.2361 14.9092,12.0211 15.0102,11.8031 C15.3012,11.1801 15.5762,10.5901 15.5762,10.0001 C15.5762,6.3901 12.6332,3.4531 9.0162,3.4531 C5.3992,3.4531 2.4562,6.3901 2.4562,10.0001 C2.4562,13.7931 5.5982,16.5461 9.9272,16.5461 L10.1102,16.5461 Z M16.7762,13.8791 C17.2932,14.1511 18.3672,14.7171 17.8742,15.7631 C16.9412,17.7441 13.1662,18.0001 10.3092,18.0001 L10.1032,18.0001 L9.9272,17.9991 C4.7552,17.9991 1.0002,14.6351 1.0002,10.0001 C1.0002,5.5881 4.5962,2.0001 9.0162,2.0001 C13.4362,2.0001 17.0332,5.5881 17.0332,10.0001 C17.0332,10.9121 16.6602,11.7121 16.3302,12.4171 C16.2382,12.6151 16.1472,12.8111 16.0662,13.0041 C15.9242,13.3121 15.8812,13.4061 16.7762,13.8791 Z M5.643,8.5885 L5.643,8.5885 C5.643,8.18726513 5.96826513,7.862 6.3695,7.862 L11.2955,7.862 C11.6967349,7.862 12.022,8.18726513 12.022,8.5885 L12.022,8.5885 C12.022,8.98973487 11.6967349,9.315 11.2955,9.315 L6.3695,9.315 C5.96826513,9.315 5.643,8.98973487 5.643,8.5885 Z M5.643,11.01 L5.643,11.01 C5.643,10.608489 5.96848899,10.283 6.37,10.283 L12.103,10.283 C12.504511,10.283 12.83,10.608489 12.83,11.01 L12.83,11.01 C12.83,11.411511 12.504511,11.737 12.103,11.737 L6.37,11.737 C5.96848899,11.737 5.643,11.411511 5.643,11.01 Z" id="path-1"></path>
</symbol>
<symbol id="icon-trash" viewBox="0 0 20 20" fill="currentColor">
<path d="M12.1492186,3.45454545 L15.0265167,3.45454545 C15.8400972,3.45454545 16.5,4.10133269 16.5,4.90822364 L16.5,6.19480519 C16.5,6.69218044 16.1368851,7.10474565 15.6612903,7.18186567 L15.6612903,16.5466994 C15.6612903,17.348586 14.9981972,18 14.191474,18 L5.80852599,18 C4.99741268,18 4.33870968,17.3496052 4.33870968,16.5466994 L4.33870968,7.40135259 C4.33870968,7.32762903 4.34431449,7.25517734 4.35512044,7.18438682 C3.87149335,7.11419962 3.5,6.69788587 3.5,6.19480519 L3.5,4.90822364 C3.5,4.10412026 4.16026768,3.45454545 4.9734833,3.45454545 L7.85078142,3.45454545 C8.18629427,2.60314663 9.02207188,2 10,2 C10.9779281,2 11.8137057,2.60314663 12.1492186,3.45454545 Z M10.6291345,3.45454545 C10.4538978,3.32407054 10.23606,3.24675325 10,3.24675325 C9.76393996,3.24675325 9.54610219,3.32407054 9.37086553,3.45454545 L10.6291345,3.45454545 Z M14.4032258,7.19480519 L5.80852599,7.19480519 C5.69416365,7.19480519 5.59677419,7.29047932 5.59677419,7.40135259 L5.59677419,16.5466994 C5.59677419,16.6600184 5.69119346,16.7532468 5.80852599,16.7532468 L14.191474,16.7532468 C14.3058363,16.7532468 14.4032258,16.6575726 14.4032258,16.5466994 L14.4032258,7.19480519 Z M5.80852599,5.94805195 L15.2419355,5.94805195 L15.2419355,4.90822364 C15.2419355,4.79299635 15.1483784,4.7012987 15.0265167,4.7012987 L4.9734833,4.7012987 C4.8530312,4.7012987 4.75806452,4.79472742 4.75806452,4.90822364 L4.75806452,5.94805195 L5.80852599,5.94805195 Z M8.32258065,9.27272727 C8.66998557,9.27272727 8.9516129,9.55435461 8.9516129,9.90175953 L8.9516129,14.0462924 C8.9516129,14.3936973 8.66998557,14.6753247 8.32258065,14.6753247 C7.97517572,14.6753247 7.69354839,14.3936973 7.69354839,14.0462924 L7.69354839,9.90175953 C7.69354839,9.55435461 7.97517572,9.27272727 8.32258065,9.27272727 Z M11.6774194,9.27272727 C12.0248243,9.27272727 12.3064516,9.55435461 12.3064516,9.90175953 L12.3064516,14.0462924 C12.3064516,14.3936973 12.0248243,14.6753247 11.6774194,14.6753247 C11.3300144,14.6753247 11.0483871,14.3936973 11.0483871,14.0462924 L11.0483871,9.90175953 C11.0483871,9.55435461 11.3300144,9.27272727 11.6774194,9.27272727 Z"/>
</symbol>
@ -353,4 +356,4 @@
<symbol id="icon-warning" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.98344445,7.04264254 C10.4190789,7.04264254 10.7723523,7.39520794 10.7723523,7.83027001 L10.7723523,12.3994926 C10.7723523,12.8345547 10.4190789,13.1871201 9.98344445,13.1871201 C9.54780997,13.1871201 9.19453663,12.8345547 9.19453663,12.3994926 L9.19453663,7.83027001 C9.19453663,7.39520794 9.54780997,7.04264254 9.98344445,7.04264254 Z M0.959352356,16.870054 L9.04441459,2.40226256 C9.32933111,1.8924195 9.98368057,1.7021729 10.5098756,1.97341266 C10.6984618,2.07062387 10.853564,2.21970064 10.9555854,2.40226256 L19.0406476,16.870054 C19.3644696,17.4495162 18.9313899,18.15 18.2590107,18.15 L1.74098931,18.15 C1.06861014,18.15 0.635530413,17.4495162 0.959352356,16.870054 Z M9.96597455,13.7448416 C10.4490813,13.7448416 10.8196237,14.1257886 10.8196237,14.6151726 C10.8196237,15.1078483 10.450184,15.4898784 9.96597455,15.4898784 C9.48176512,15.4898784 9.11232535,15.1078483 9.11232535,14.6151726 C9.11232535,14.1257886 9.48286785,13.7448416 9.96597455,13.7448416 Z M2.73547986,16.7123645 L17.2645201,16.7123645 L10,3.71288982 L2.73547986,16.7123645 Z"/>
</symbol>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 196 KiB

@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" id="thread">
<path d="M10.1102,16.5461 C14.6082,16.5611 16.0062,15.8201 16.4222,15.3421 C16.3202,15.2811 16.1942,15.2151 16.0962,15.1641 C15.4632,14.8301 13.9762,14.0471 14.7322,12.4211 C14.8092,12.2361 14.9092,12.0211 15.0102,11.8031 C15.3012,11.1801 15.5762,10.5901 15.5762,10.0001 C15.5762,6.3901 12.6332,3.4531 9.0162,3.4531 C5.3992,3.4531 2.4562,6.3901 2.4562,10.0001 C2.4562,13.7931 5.5982,16.5461 9.9272,16.5461 L10.1102,16.5461 Z M16.7762,13.8791 C17.2932,14.1511 18.3672,14.7171 17.8742,15.7631 C16.9412,17.7441 13.1662,18.0001 10.3092,18.0001 L10.1032,18.0001 L9.9272,17.9991 C4.7552,17.9991 1.0002,14.6351 1.0002,10.0001 C1.0002,5.5881 4.5962,2.0001 9.0162,2.0001 C13.4362,2.0001 17.0332,5.5881 17.0332,10.0001 C17.0332,10.9121 16.6602,11.7121 16.3302,12.4171 C16.2382,12.6151 16.1472,12.8111 16.0662,13.0041 C15.9242,13.3121 15.8812,13.4061 16.7762,13.8791 Z M5.643,8.5885 L5.643,8.5885 C5.643,8.18726513 5.96826513,7.862 6.3695,7.862 L11.2955,7.862 C11.6967349,7.862 12.022,8.18726513 12.022,8.5885 L12.022,8.5885 C12.022,8.98973487 11.6967349,9.315 11.2955,9.315 L6.3695,9.315 C5.96826513,9.315 5.643,8.98973487 5.643,8.5885 Z M5.643,11.01 L5.643,11.01 C5.643,10.608489 5.96848899,10.283 6.37,10.283 L12.103,10.283 C12.504511,10.283 12.83,10.608489 12.83,11.01 L12.83,11.01 C12.83,11.411511 12.504511,11.737 12.103,11.737 L6.37,11.737 C5.96848899,11.737 5.643,11.411511 5.643,11.01 Z" id="path-1"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -356,7 +356,7 @@ Template.message.helpers({
if (room && room.t === 'd') {
return 'at';
}
return roomTypes.getIcon(room && room.t);
return roomTypes.getIcon(room);
},
fromSearch() {
return this.customClass === 'search';

@ -3,6 +3,6 @@ import { roomTypes } from 'meteor/rocketchat:utils';
Template.messagePopupChannel.helpers({
channelIcon() {
return roomTypes.getIcon(this.t);
return roomTypes.getIcon(this);
},
});

@ -9,15 +9,6 @@ import { callbacks } from 'meteor/rocketchat:callbacks';
Template.chatRoomItem.helpers({
roomData() {
let { name } = this;
if (this.fname) {
const realNameForDirectMessages = this.t === 'd' && settings.get('UI_Use_Real_Name');
const realNameForChannel = this.t !== 'd' && settings.get('UI_Allow_room_names_with_special_chars');
if (realNameForDirectMessages || realNameForChannel) {
name = this.fname;
}
}
const openedRoom = Tracker.nonreactive(() => Session.get('openedRoom'));
const unread = this.unread > 0 ? this.unread : false;
// if (this.unread > 0 && (!hasFocus || openedRoom !== this.rid)) {
@ -30,16 +21,18 @@ Template.chatRoomItem.helpers({
this.alert = !this.hideUnreadStatus && this.alert; // && (!hasFocus || FlowRouter.getParam('_id') !== this.rid);
const icon = roomTypes.getIcon(this.t);
const icon = roomTypes.getIcon(this);
const avatar = !icon;
const name = roomTypes.getRoomName(this.t, this);
const roomData = {
...this,
icon,
avatar,
username : this.name,
route: roomTypes.getRouteLink(this.t, this),
name: name || roomTypes.getRoomName(this.t, this),
name,
unread,
active,
archivedClass,
@ -47,6 +40,11 @@ Template.chatRoomItem.helpers({
};
roomData.username = roomData.username || roomData.name;
// hide icon for threads
if (this.prid) {
roomData.darken = true;
}
if (!this.lastMessage && settings.get('Store_Last_Message')) {
const room = Rooms.findOne(this.rid || this._id, { fields: { lastMessage: 1 } });
roomData.lastMessage = (room && room.lastMessage) || { msg: t('No_messages_yet') };

@ -23,6 +23,7 @@ Template.roomList.helpers({
'settings.preferences.sidebarSortby': 1,
'settings.preferences.sidebarShowFavorites': 1,
'settings.preferences.sidebarShowUnread': 1,
'settings.preferences.sidebarShowThreads': 1,
'services.tokenpass': 1,
},
});
@ -58,6 +59,11 @@ Template.roomList.helpers({
types = ['c', 'p', 'd'];
}
if (this.identifier === 'thread') {
types = ['c', 'p', 'd'];
query.prid = { $exists: true };
}
if (this.identifier === 'unread' || this.identifier === 'tokens') {
types = ['c', 'p'];
}
@ -68,6 +74,11 @@ Template.roomList.helpers({
query.tokens = { $exists: true };
}
// if we display threads as a separate group, we should hide them from the other lists
if (getUserPreference(user, 'sidebarShowThreads')) {
query.prid = { $exists: false };
}
if (getUserPreference(user, 'sidebarShowUnread')) {
query.$or = [
{ alert: { $ne: true } },

@ -64,7 +64,6 @@ Template.sideNav.events({
'dropped .sidebar'(e) {
return e.preventDefault();
},
'mouseenter .sidebar-item__link'(e) {
const element = e.currentTarget;
setTimeout(() => {

@ -8,7 +8,7 @@ import { AccountBox, menu, SideNav } from 'meteor/rocketchat:ui-utils';
import { callbacks } from 'meteor/rocketchat:callbacks';
import { settings } from 'meteor/rocketchat:settings';
import { hasAtLeastOnePermission } from 'meteor/rocketchat:authorization';
import { modal } from 'meteor/rocketchat:ui-utils';
const setStatus = (status) => {
AccountBox.setStatus(status);
callbacks.run('userStatusManuallySet', status);
@ -166,9 +166,60 @@ const toolbarButtons = (user) => [{
name: t('Create_A_New_Channel'),
icon: 'edit-rounded',
condition: () => hasAtLeastOnePermission(['create-c', 'create-p']),
action: () => {
menu.close();
FlowRouter.go('create-channel');
action: (e) => {
const config = {
columns: [
{
groups: [
{
items: [
{
icon: 'hashtag',
name: t('Channel'),
action: (e) => {
e.preventDefault();
modal.open({
// title: t('Message_info'),
content: 'createChannel',
data: {
onCreate() {
modal.close();
},
},
showConfirmButton: false,
showCancelButton: false,
// confirmButtonText: t('Close'),
});
},
},
{
icon: 'thread',
name: t('Thread'),
action: (e) => {
e.preventDefault();
modal.open({
// title: t('Message_info'),
content: 'CreateThread',
data: {
onCreate() {
modal.close();
},
},
showConfirmButton: false,
showCancelButton: false,
// confirmButtonText: t('Close'),
});
},
},
],
},
],
},
],
currentTarget: e.currentTarget,
offsetVertical: e.currentTarget.clientHeight + 10,
};
popover.open(config);
},
},
{

@ -6,7 +6,7 @@ import _ from 'underscore';
import { RoomManager } from './RoomManager';
import { readMessage } from './readMessages';
export const upsertMessage = ({ msg, subscription }) => {
export const upsertMessage = ({ msg: { _id, ...msg }, subscription }) => {
const userId = msg.u && msg.u._id;
if (subscription && subscription.ignored && subscription.ignored.indexOf(userId) > -1) {
@ -21,7 +21,7 @@ export const upsertMessage = ({ msg, subscription }) => {
msg.e2e = 'pending';
}
return ChatMessage.upsert({ _id: msg._id }, msg);
return ChatMessage.upsert({ _id }, msg);
};
function upsertMessageBulk({ msgs, subscription }) {

@ -1,6 +1,6 @@
<template name="rc_modal">
<div class="rc-modal-wrapper">
<div class="rc-modal rc-modal--{{modalClass}}" data-modal="modal">
<dialog class="rc-modal rc-modal--{{modalClass}}" data-modal="modal">
{{#if template}}
{{> Template.dynamic template=template data=data}}
{{else}}
@ -17,7 +17,7 @@
</button>
</header>
<main class="rc-modal__content">
<section class="rc-modal__content">
{{#if content}}
{{> Template.dynamic template=content data=data}}
{{/if}}
@ -50,12 +50,12 @@
<span class="rc-checkbox__text rc-text__small">{{_ "Dont_ask_me_again"}}</span>
</label>
{{/if}}
</main>
</section>
<div class="rc-modal__footer {{#unless showFooter}}rc-modal__footer--empty{{/unless}}">
<input class="rc-button rc-button--nude js-close {{#unless showCancelButton}}rc-button--invisible{{/unless}}" type="submit" data-button="cancel" value="{{cancelButtonText}}">
<input style="background-color:{{confirmButtonColor}}" class="rc-button rc-button--primary js-confirm {{#unless showConfirmButton}}rc-button--invisible{{/unless}}" type="submit" data-button="create" value="{{confirmButtonText}}">
</div>
{{/if}}
</div>
</dialog>
</div>
</template>

@ -97,7 +97,7 @@ Template.rc_modal.helpers({
return !!this.action;
},
type() {
return `rc-modal__content-icon rc-modal__content-icon--modal-${ this.type }`;
return this.type && `rc-modal__content-icon rc-modal__content-icon--modal-${ this.type }`;
},
modalIcon() {

@ -162,7 +162,7 @@ Template.popover.events({
const { id } = event.currentTarget.dataset;
const action = messageBox.actions.getById(id);
if ((action[0] != null ? action[0].action : undefined) != null) {
action[0].action({ rid: t.data.data.rid, messageBox: document.querySelector('.rc-message-box'), element: event.currentTarget, event });
action[0].action({ rid: t.data.data.rid, ...t.data.data, messageBox: document.querySelector('.rc-message-box'), element: event.currentTarget, event });
if (id !== 'audio-message') {
popover.close();
}

@ -6,9 +6,18 @@
{{> burger}}
</div>
<div class="rc-header__block rc-header__favorite">
<a href="#favorite" class="rc-header__toggle-favorite {{state}}">{{> icon block="rc-header__icon" icon="star"}}</a>
</div>
{{#if isThread}}
<div class="rc-header__block rc-header__block--action js-open-parent-channel">
<span class="rc-header__first-icon">{{> icon block="rc-header__icon rc-header__icon" icon="back"}}</span>
</div>
{{/if}}
{{#if showToggleFavorite}}
<div class="rc-header__block rc-header__favorite rc-header__block--action">
<a href="#favorite" class="rc-header__toggle-favorite rc-header__first-icon {{state}}">{{> icon block="rc-header__icon" icon="star"}}</a>
</div>
{{/if}}
<!-- TODO: fix it style and helper -->
{{#if tokenAccessChannel}}
@ -29,7 +38,7 @@
<div class="rc-header__data">
{{#unless secondaryName}}
<div class="rc-header__name">{{> icon block="rc-header__icon" icon=channelIcon}}{{roomName}}</div>
<div class="rc-header__name">{{> icon block="rc-header__icon" icon=roomIcon}}{{roomName}}</div>
{{else}}
<div class="rc-header__name">{{roomName}} <div class="rc-header__username">@{{secondaryName}}</div></div>
{{/unless}}

@ -4,8 +4,9 @@ import { Session } from 'meteor/session';
import { Template } from 'meteor/templating';
import { t, roomTypes, handleError } from 'meteor/rocketchat:utils';
import { TabBar, fireGlobalEvent } from 'meteor/rocketchat:ui-utils';
import { ChatSubscription, Rooms } from 'meteor/rocketchat:models';
import { ChatSubscription, Rooms, ChatRoom } from 'meteor/rocketchat:models';
import { settings } from 'meteor/rocketchat:settings';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { emoji } from 'meteor/rocketchat:emoji';
import { Markdown } from 'meteor/rocketchat:markdown';
@ -13,6 +14,12 @@ const isSubscribed = (_id) => ChatSubscription.find({ rid: _id }).count() > 0;
const favoritesEnabled = () => settings.get('Favorite_Rooms');
const isThread = ({ _id }) => {
const room = ChatRoom.findOne({ _id });
return !!(room && room.prid);
};
Template.headerRoom.helpers({
back() {
return Template.instance().data.back;
@ -26,6 +33,10 @@ Template.headerRoom.helpers({
return TabBar.getButtons();
},
isThread() {
return isThread(Template.instance().data);
},
isTranslated() {
const sub = ChatSubscription.findOne({ rid: this._id }, { fields: { autoTranslate: 1, autoTranslateLanguage: 1 } });
return settings.get('AutoTranslate_Enabled') && ((sub != null ? sub.autoTranslate : undefined) === true) && (sub.autoTranslateLanguage != null);
@ -80,27 +91,11 @@ Template.headerRoom.helpers({
return roomTopic;
},
channelIcon() {
const roomType = Rooms.findOne(this._id).t;
switch (roomType) {
case 'd':
return 'at';
case 'p':
return 'lock';
case 'c':
return 'hashtag';
case 'l':
return 'livechat';
default:
return roomTypes.getIcon(roomType);
}
},
roomIcon() {
const roomData = Session.get(`roomData${ this._id }`);
if (!(roomData != null ? roomData.t : undefined)) { return ''; }
return roomTypes.getIcon(roomData != null ? roomData.t : undefined);
return roomTypes.getIcon(roomData);
},
tokenAccessChannel() {
@ -118,7 +113,7 @@ Template.headerRoom.helpers({
},
showToggleFavorite() {
if (isSubscribed(this._id) && favoritesEnabled()) { return true; }
return !isThread(Template.instance().data) && isSubscribed(this._id) && favoritesEnabled();
},
fixedHeight() {
@ -166,6 +161,12 @@ Template.headerRoom.events({
.select(),
10);
},
'click .js-open-parent-channel'(event, t) {
event.preventDefault();
const { prid } = t.currentChannel;
FlowRouter.goToRoomById(prid);
},
});
Template.headerRoom.onCreated(function() {

@ -359,9 +359,14 @@ export const ChatMessages = class ChatMessages {
confirmDeleteMsg(message, done = function() {}) {
if (MessageTypes.isSystemMessage(message)) { return; }
const room = message.trid && Rooms.findOne({
_id: message.trid,
prid: { $exists: true },
});
modal.open({
title: t('Are_you_sure'),
text: t('You_will_not_be_able_to_recover'),
text: room ? t('The_message_is_a_thread_you_will_not_be_able_to_recover') : t('You_will_not_be_able_to_recover'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',

@ -10,7 +10,7 @@
<h1 class="create-channel__title">{{_ "Create_A_New_Channel"}}</h1>
<p class="create-channel__description">{{_ "Channels_are_where_your_team_communicate"}}</p>
</header>
<form class="create-channel__content">
<form id="create-channel" name="create-channel" class="create-channel__content">
<div class="create-channel__switches">
<div class="rc-switch">
<label class="rc-switch__label" tabindex="-1">
@ -106,9 +106,9 @@
{{#with config}}
{{#if autocomplete 'isShowing'}}
<div class="fadeInDown">
{{#if autocomplete 'isLoaded'}}
{{> popupList data=config items=items}}
{{/if}}
{{> popupList data=config items=items}}
</div>
{{/if}}
{{/with}}
@ -133,10 +133,10 @@
{{> tokenpass extensionsConfig}}
{{/if}}
{{/if}}
<div class="create-channel__inputs">
<input class="rc-button rc-button--primary" type='submit' data-button="create" {{createIsDisabled}} value="{{_ "Create"}}" />
</div>
</form>
<div class="create-channel__inputs">
<input form='create-channel' class="rc-button rc-button--primary" type='submit' data-button="create" {{createIsDisabled}} value="{{_ "Create"}}" />
</div>
</div>
{{#each roomType in roomTypesAfterStandard}}
<div class="room-type-creation">

@ -260,7 +260,9 @@ Template.createChannel.events({
if (!isPrivate) {
callbacks.run('aftercreateCombined', { _id: result.rid, name: result.name });
}
if (instance.data.onCreate) {
instance.data.onCreate(result);
}
return FlowRouter.go(isPrivate ? 'group' : 'channel', { name: result.name }, FlowRouter.current().queryParams);
});
return false;
@ -334,15 +336,17 @@ Template.createChannel.onCreated(function() {
this.ac = new AutoComplete(
{
selector:{
anchor: '.rc-input__label',
item: '.rc-popup-list__item',
container: '.rc-popup-list__list',
},
position:'fixed',
limit: 10,
inputDelay: 300,
rules: [
{
// @TODO maybe change this 'collection' and/or template
collection: 'UserAndRoom',
subscription: 'userAutocomplete',
field: 'username',

@ -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');
// });

@ -8,7 +8,6 @@ import { Session } from 'meteor/session';
import { Template } from 'meteor/templating';
import { t, roomTypes, getUserPreference, handleError } from 'meteor/rocketchat:utils';
import { WebRTC } from 'meteor/rocketchat:webrtc';
import { ChatSubscription, ChatMessage, RoomRoles, Users, Subscriptions, Rooms } from 'meteor/rocketchat:models';
import {
fireGlobalEvent,
@ -340,7 +339,7 @@ Template.room.helpers({
const roomData = Session.get(`roomData${ this._id }`);
if (!(roomData != null ? roomData.t : undefined)) { return ''; }
const roomIcon = roomTypes.getIcon(roomData != null ? roomData.t : undefined);
const roomIcon = roomTypes.getIcon(roomData);
// Remove this 'codegueira' on header redesign
if (!roomIcon) {
@ -714,12 +713,12 @@ Template.room.events({
if (!Meteor.userId()) {
return;
}
const roomNameOrId = $(e.currentTarget).data('channel');
if (roomNameOrId) {
const channel = $(e.currentTarget).data('channel');
if (channel) {
if (Layout.isEmbedded()) {
fireGlobalEvent('click-mention-link', { path: FlowRouter.path('channel', { name: roomNameOrId }), channel: roomNameOrId });
fireGlobalEvent('click-mention-link', { path: FlowRouter.path('channel', { name: channel }), channel });
}
FlowRouter.goToRoomById(roomNameOrId);
FlowRouter.goToRoomById(channel);
return;
}
const username = $(e.currentTarget).data('username');
@ -1133,10 +1132,17 @@ Template.room.onRendered(function() {
newMessage.classList.remove('not');
}
});
Tracker.autorun(function() {
this.autorun(function() {
if (template.data._id !== RoomManager.openedRoom) {
return;
}
const room = Rooms.findOne({ _id: template.data._id });
if (!room) {
FlowRouter.go('home');
}
});
});

@ -7,7 +7,7 @@ Template.roomSearch.helpers({
return 'icon-at';
}
if (this.type === 'r') {
return roomTypes.getIcon(this.t);
return roomTypes.getIcon(this);
}
},
userStatus() {

@ -10,8 +10,11 @@ export const roomTypes = new class RocketChatRoomTypes extends RoomTypesCommon {
getTypes() {
return _.sortBy(this.roomTypesOrder, 'order').map((type) => this.roomTypes[type.identifier]).filter((type) => !type.condition || type.condition());
}
getIcon(roomType) {
return this.roomTypes[roomType] && this.roomTypes[roomType].icon;
getIcon(roomData) {
if (!roomData || !roomData.t || !this.roomTypes[roomData.t]) {
return;
}
return (this.roomTypes[roomData.t].getIcon && this.roomTypes[roomData.t].getIcon(roomData)) || this.roomTypes[roomData.t].icon;
}
getRoomName(roomType, roomData) {
return this.roomTypes[roomType] && this.roomTypes[roomType].roomName && this.roomTypes[roomType].roomName(roomData);

@ -3,6 +3,16 @@
font-size: var(--text-heading-size);
&__first-icon {
display: flex;
width: 48px;
padding: 0 0.25rem;
justify-content: center;
}
&--room {
padding: 1.25rem;
@ -39,6 +49,10 @@
padding: 0 0.5rem;
align-items: center;
&--action {
cursor: pointer;
}
}
&__content {

@ -9,6 +9,8 @@
animation: dropdown-show 0.3s cubic-bezier(0.45, 0.05, 0.55, 0.95);
border: none;
background: white;
box-shadow: 0 0 2px 0 rgba(47, 52, 61, 0.08), 0 0 12px 0 rgba(47, 52, 61, 0.12);
@ -59,7 +61,6 @@
position: relative;
display: flex;
overflow: auto;
flex-direction: column;

@ -4,14 +4,21 @@
width: 100%;
margin: 0 -25px;
animation-name: fadeIn;
animation-duration: 1s;
&__content {
overflow: auto;
flex: 1 1 auto;
margin: 0 -40px;
padding: 0 40px;
}
&__wrapper {
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 100%;

@ -4,6 +4,8 @@
width: 100%;
&__label {
display: block;
cursor: pointer;
}
@ -43,10 +45,17 @@
}
}
textarea.&__element {
font-family: inherit;
line-height: 2rem;
}
&__element {
width: 100%;
padding: 0 1rem;
text-overflow: ellipsis;
color: var(--input-text-color);
border-width: var(--input-border-width);
@ -63,6 +72,8 @@
}
&::placeholder {
text-overflow: ellipsis;
color: var(--input-placeholder-color);
}
@ -128,6 +139,8 @@
width: 100%;
&__label {
display: block;
cursor: pointer;
}

@ -45,6 +45,11 @@
}
&-name {
overflow: hidden;
text-overflow: ellipsis;
color: var(--popup-list-name-color);
font-size: var(--popup-list-name-size);

@ -28,6 +28,7 @@ import './methods/deleteFileMessage';
import './methods/deleteUser';
import './methods/eraseRoom';
import './methods/getAvatarSuggestion';
import './methods/getRoomById';
import './methods/getRoomIdByNameOrId';
import './methods/getRoomNameById';
import './methods/getTotalChannels';

@ -1,9 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { roomTypes } from 'meteor/rocketchat:utils';
import { deleteRoom } from 'meteor/rocketchat:lib';
import { hasPermission } from 'meteor/rocketchat:authorization';
import { Rooms, Messages, Subscriptions } from 'meteor/rocketchat:models';
import { Rooms } from 'meteor/rocketchat:models';
import { Apps } from 'meteor/rocketchat:apps';
import { roomTypes } from 'meteor/rocketchat:utils';
Meteor.methods({
eraseRoom(rid) {
@ -23,6 +24,12 @@ Meteor.methods({
});
}
if (!roomTypes.roomTypes[room.t].canBeDeleted(hasPermission, room)) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'eraseRoom',
});
}
if (Apps && Apps.isLoaded()) {
const prevent = Promise.await(Apps.getBridges().getListenerBridge().roomEvent('IPreRoomDeletePrevent', room));
if (prevent) {
@ -30,16 +37,7 @@ Meteor.methods({
}
}
if (!roomTypes.roomTypes[room.t].canBeDeleted(hasPermission, room)) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'eraseRoom',
});
}
Messages.removeFilesByRoomId(rid);
Messages.removeByRoomId(rid);
Subscriptions.removeByRoomId(rid);
const result = Rooms.removeById(rid);
const result = deleteRoom(rid);
if (Apps && Apps.isLoaded()) {
Apps.getBridges().getListenerBridge().roomEvent('IPostRoomDeleted', room);

@ -2,7 +2,6 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
import { Rooms } from 'meteor/rocketchat:models';
import { canAccessRoom } from 'meteor/rocketchat:authorization';
@ -37,4 +36,4 @@ DDPRateLimiter.addRule({
userId() {
return true;
},
}, 1, 60000);
}, 10, 60000);

@ -26,6 +26,7 @@ const fields = {
customFields: 1,
lastMessage: 1,
retention: 1,
prid: 1,
// @TODO create an API to register this fields based on room type
livechatData: 1,
@ -58,7 +59,7 @@ const roomMap = (record) => {
Meteor.methods({
'rooms/get'(updatedAt) {
let options = { fields };
const options = { fields };
if (!Meteor.userId()) {
if (settings.get('Accounts_AllowAnonymousRead') === true) {
@ -67,12 +68,6 @@ Meteor.methods({
return [];
}
this.unblock();
options = {
fields,
};
if (updatedAt instanceof Date) {
return {
update: Rooms.findBySubscriptionUserIdUpdatedAfter(Meteor.userId(), updatedAt, options).fetch(),

@ -16,6 +16,7 @@ const fields = {
alert: 1,
roles: 1,
unread: 1,
prid: 1,
userMentions: 1,
groupMentions: 1,
archived: 1,
@ -44,8 +45,6 @@ Meteor.methods({
return [];
}
this.unblock();
const options = { fields };
const records = Subscriptions.findByUserId(Meteor.userId(), options).fetch();

@ -109,7 +109,7 @@ describe('miscellaneous', function() {
const allUserPreferencesKeys = ['enableAutoAway', 'idleTimeLimit', 'desktopNotificationDuration', 'audioNotifications',
'desktopNotifications', 'mobileNotifications', 'unreadAlert', 'useEmojis', 'convertAsciiEmoji', 'autoImageLoad',
'saveMobileBandwidth', 'collapseMediaByDefault', 'hideUsernames', 'hideRoles', 'hideFlexTab', 'hideAvatars',
'sidebarViewMode', 'sidebarHideAvatar', 'sidebarShowUnread', 'sidebarShowFavorites', 'sidebarGroupByType',
'sidebarViewMode', 'sidebarHideAvatar', 'sidebarShowUnread', 'sidebarShowThreads', 'sidebarShowFavorites', 'sidebarGroupByType',
'sendOnEnter', 'messageViewMode', 'emailNotificationMode', 'roomCounterSidebar', 'newRoomNotification', 'newMessageNotification',
'muteFocusedConversations', 'notificationsSoundVolume'];
expect(res.body).to.have.property('success', true);

@ -22,7 +22,7 @@ describe('[Main Elements Render]', function() {
});
it('it should show the new channel button', () => {
sideNav.newChannelBtn.isVisible().should.be.true;
sideNav.newChannelBtnToolbar.isVisible().should.be.true;
});
it('it should show "general" channel', () => {

@ -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);
});
});
});

@ -23,6 +23,7 @@ class FlexTab extends Page {
get editAnnouncementTextInput() { return browser.element('.channel-settings input[name="announcement"]'); }
get editDescriptionTextInput() { return browser.element('.channel-settings input[name="description"]'); }
get editNameSave() { return browser.element('.channel-settings .save'); }
get deleteBtn() { return browser.element('.channel-settings .js-delete'); }
// Members Tab
get membersTab() { return browser.element('.tab-button:not(.hidden) .tab-button-icon--team'); }

@ -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…
Cancel
Save