[IMPROVE] Threads (#17416)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
Co-authored-by: gabriellsh <40830821+gabriellsh@users.noreply.github.com>
Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
pull/17982/head
Guilherme Gazzo 6 years ago committed by GitHub
parent 6c2f4ff703
commit b64fb67263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 19
      app/api/server/v1/chat.js
  2. 5
      app/api/server/v1/rooms.js
  3. 1
      app/api/server/v1/users.js
  4. 15
      app/channel-settings/client/views/channelSettings.html
  5. 12
      app/channel-settings/server/methods/saveRoomSettings.js
  6. 5
      app/discussion/client/public/stylesheets/discussion.css
  7. 10
      app/discussion/server/config.js
  8. 2
      app/emoji-emojione/client/emojione-sprites.css
  9. 6
      app/federation/server/endpoints/dispatch.js
  10. 2
      app/lib/lib/roomTypes/direct.js
  11. 2
      app/lib/lib/roomTypes/private.js
  12. 2
      app/lib/lib/roomTypes/public.js
  13. 6
      app/lib/server/functions/cleanRoomHistory.js
  14. 5
      app/lib/server/functions/deleteMessage.js
  15. 5
      app/lib/server/functions/notifications/audio.js
  16. 6
      app/lib/server/functions/notifications/desktop.js
  17. 3
      app/lib/server/functions/notifications/email.js
  18. 6
      app/lib/server/functions/notifications/mobile.js
  19. 12
      app/lib/server/functions/sendMessage.js
  20. 147
      app/lib/server/lib/notifyUsersOnMessage.js
  21. 6
      app/lib/server/lib/sendNotificationsOnMessage.js
  22. 5
      app/lib/server/methods/cleanRoomHistory.js
  23. 6
      app/lib/server/methods/sendMessage.js
  24. 9
      app/lib/server/methods/updateMessage.js
  25. 7
      app/lib/server/startup/settings.js
  26. 2
      app/markdown/lib/markdown.js
  27. 14
      app/mentions-flextab/client/actionButton.js
  28. 2
      app/mentions/client/client.js
  29. 2
      app/mentions/client/mentionLink.css
  30. 12
      app/mentions/lib/MentionsParser.js
  31. 15
      app/message-pin/client/actionButton.js
  32. 13
      app/message-star/client/actionButton.js
  33. 14
      app/models/server/models/Messages.js
  34. 12
      app/models/server/models/Rooms.js
  35. 57
      app/models/server/models/Subscriptions.js
  36. 7
      app/retention-policy/server/cronPruneMessages.js
  37. 12
      app/search/client/provider/result.js
  38. 7
      app/theme/client/imports/components/badge.css
  39. 19
      app/theme/client/imports/components/contextual-bar.css
  40. 7
      app/theme/client/imports/components/header.css
  41. 2
      app/theme/client/imports/components/messages.css
  42. 2
      app/theme/client/imports/components/tooltip.css
  43. 2
      app/theme/client/imports/general/base_old.css
  44. 24
      app/theme/client/imports/general/variables.css
  45. 100
      app/threads/client/components/ThreadComponent.js
  46. 266
      app/threads/client/components/ThreadList.js
  47. 127
      app/threads/client/components/ThreadListMessage.js
  48. 38
      app/threads/client/components/hooks/useLocalstorage.js
  49. 4
      app/threads/client/components/hooks/useUserRoom.js
  50. 4
      app/threads/client/components/hooks/useUserSubscription.js
  51. 5
      app/threads/client/flextab/messageBoxFollow.html
  52. 11
      app/threads/client/flextab/messageBoxFollow.js
  53. 20
      app/threads/client/flextab/thread.html
  54. 138
      app/threads/client/flextab/thread.js
  55. 1
      app/threads/client/flextab/threadlist.js
  56. 39
      app/threads/client/flextab/threads.html
  57. 173
      app/threads/client/flextab/threads.js
  58. 1
      app/threads/client/index.js
  59. 42
      app/threads/client/lib/normalizeThreadTitle.js
  60. 16
      app/threads/client/messageAction/replyInThread.js
  61. 53
      app/threads/client/threads.css
  62. 14
      app/threads/server/functions.js
  63. 19
      app/threads/server/hooks/afterReadMessages.js
  64. 30
      app/threads/server/hooks/afterdeletemessage.js
  65. 20
      app/threads/server/hooks/aftersavemessage.js
  66. 2
      app/threads/server/hooks/index.js
  67. 7
      app/ui-account/client/accountPreferences.html
  68. 1
      app/ui-account/client/accountPreferences.js
  69. 2
      app/ui-cached-collection/client/models/CachedCollection.js
  70. 5
      app/ui-clean-history/client/views/cleanHistory.html
  71. 11
      app/ui-clean-history/client/views/cleanHistory.js
  72. 2
      app/ui-flextab/client/flexTabBar.js
  73. 43
      app/ui-message/client/message.html
  74. 83
      app/ui-message/client/message.js
  75. 31
      app/ui-message/client/messageBox/messageBox.html
  76. 4
      app/ui-message/client/messageBox/messageBox.js
  77. 18
      app/ui-message/client/messageThread.html
  78. 61
      app/ui-message/client/messageThread.js
  79. 21
      app/ui-sidenav/client/roomList.js
  80. 29
      app/ui-sidenav/client/sidebarItem.js
  81. 15
      app/ui-utils/client/lib/MessageAction.js
  82. 5
      app/ui-utils/client/lib/RocketChatTabBar.js
  83. 5
      app/ui-utils/client/lib/RoomHistoryManager.js
  84. 4
      app/ui-utils/client/lib/messageContext.js
  85. 21
      app/ui-utils/client/lib/openRoom.js
  86. 45
      app/ui-utils/client/lib/renderMessageBody.js
  87. 42
      app/ui/client/components/contextualBar.html
  88. 18
      app/ui/client/lib/chatMessages.js
  89. 6
      app/ui/client/lib/notification.js
  90. 11
      app/ui/client/views/app/room.html
  91. 90
      app/ui/client/views/app/room.js
  92. 2
      client/admin/settings/Section.js
  93. 6
      client/admin/settings/Setting.js
  94. 11
      client/admin/settings/inputs/SelectSettingInput.js
  95. 5
      client/components/basic/Page.js
  96. 87
      client/components/basic/VerticalBar.js
  97. 9
      client/hooks/useEndpointDataExperimental.js
  98. 4
      client/hooks/useTimeAgo.js
  99. 53
      imports/message-read-receipt/client/room.js
  100. 88
      package-lock.json
  101. Some files were not shown because too many files have changed in this diff Show More

@ -10,6 +10,7 @@ import { executeSetReaction } from '../../../reactions/server/setReaction';
import { API } from '../api';
import Rooms from '../../../models/server/models/Rooms';
import Users from '../../../models/server/models/Users';
import Subscriptions from '../../../models/server/models/Subscriptions';
import { settings } from '../../../settings';
import { findMentionedMessages, findStarredMessages, findSnippetedMessageById, findSnippetedMessages, findDiscussionsFromRoom } from '../lib/messages';
@ -427,9 +428,10 @@ API.v1.addRoute('chat.getPinnedMessages', { authRequired: true }, {
API.v1.addRoute('chat.getThreadsList', { authRequired: true }, {
get() {
const { rid } = this.queryParams;
const { rid, type, text } = this.queryParams;
const { offset, count } = this.getPaginationItems();
const { sort, fields, query } = this.parseJsonQuery();
if (!rid) {
throw new Meteor.Error('The required "rid" query param is missing.');
}
@ -441,9 +443,20 @@ API.v1.addRoute('chat.getThreadsList', { authRequired: true }, {
if (!canAccessRoom(room, user)) {
throw new Meteor.Error('error-not-allowed', 'Not Allowed');
}
const threadQuery = Object.assign({}, query, { rid, tcount: { $exists: true } });
const typeThread = {
...type === 'following' && { replies: { $in: [this.userId] } },
...type === 'unread' && { _id: { $in: Subscriptions.findOneByRoomIdAndUserId(room._id, user._id).tunread } },
...text && {
$text: {
$search: text,
},
},
};
const threadQuery = { ...query, ...typeThread, rid, tcount: { $exists: true } };
const cursor = Messages.find(threadQuery, {
sort: sort || { ts: 1 },
sort: sort || { tlm: -1 },
skip: offset,
limit: count,
fields,

@ -198,8 +198,9 @@ API.v1.addRoute('rooms.cleanHistory', { authRequired: true }, {
oldest,
inclusive,
limit: this.bodyParams.limit,
excludePinned: this.bodyParams.excludePinned,
filesOnly: this.bodyParams.filesOnly,
excludePinned: [true, 'true', 1, '1'].includes(this.bodyParams.excludePinned),
filesOnly: [true, 'true', 1, '1'].includes(this.bodyParams.filesOnly),
ignoreThreads: [true, 'true', 1, '1'].includes(this.bodyParams.ignoreThreads),
fromUsers: this.bodyParams.users,
}));

@ -543,6 +543,7 @@ API.v1.addRoute('users.setPreferences', { authRequired: true }, {
highlights: Match.Maybe(Array),
desktopNotificationRequireInteraction: Match.Maybe(Boolean),
messageViewMode: Match.Maybe(Number),
showMessageInMainThread: Match.Maybe(Boolean),
hideUsernames: Match.Maybe(Boolean),
hideRoles: Match.Maybe(Boolean),
hideAvatars: Match.Maybe(Boolean),

@ -324,6 +324,21 @@
</div>
</div>
{{/with}}
{{#with settings.retentionKeepThreads}}
<div class="rc-user-info__row">
<div class="rc-switch rc-switch--blue">
<label class="rc-switch__label">
<span class="rc-switch__text">
{{_ label}}{{equal default value '*'}}
</span>
<input type="checkbox" class="rc-switch__input js-input-check" name="retentionKeepThreads" checked="{{checked}}" disabled="{{./disabled}}">
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
</label>
</div>
</div>
{{/with}}
{{/if}}
{{/if}}
</div>

@ -17,7 +17,7 @@ import { saveRoomTokenpass } from '../functions/saveRoomTokens';
import { saveStreamingOptions } from '../functions/saveStreamingOptions';
import { RoomSettingsEnum, roomTypes } from '../../../utils';
const fields = ['featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionOverrideGlobal', 'encrypted', 'favorite'];
const fields = ['featured', 'roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionIgnoreThreads', 'retentionOverrideGlobal', 'encrypted', 'favorite'];
Meteor.methods({
saveRoomSettings(rid, settings, value) {
const userId = Meteor.userId();
@ -128,10 +128,17 @@ Meteor.methods({
action: 'Editing_room',
});
}
if (setting === 'retentionIgnoreThreads' && !hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.ignoreThreads) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
method: 'saveRoomSettings',
action: 'Editing_room',
});
}
if (setting === 'retentionOverrideGlobal') {
delete settings.retentionMaxAge;
delete settings.retentionExcludePinned;
delete settings.retentionFilesOnly;
delete settings.retentionIgnoreThreads;
}
});
@ -215,6 +222,9 @@ Meteor.methods({
case 'retentionFilesOnly':
Rooms.saveRetentionFilesOnlyById(rid, value);
break;
case 'retentionIgnoreThreads':
Rooms.saveRetentionIgnoreThreadsById(rid, value);
break;
case 'retentionOverrideGlobal':
Rooms.saveRetentionOverrideGlobalById(rid, value);
break;

@ -3,6 +3,8 @@
padding: 0.5rem 0;
align-items: center;
flex-wrap: wrap;
}
.discussion-reply-lm {
@ -11,6 +13,9 @@
color: var(--color-gray);
font-size: 12px;
flex-grow: 0;
flex-shrink: 0;
}
.discussions-list .load-more {

@ -36,4 +36,14 @@ Meteor.startup(() => {
i18nDescription: 'RetentionPolicy_DoNotExcludeDiscussion_Description',
enableQuery: globalQuery,
});
settings.add('RetentionPolicy_DoNotExcludeThreads', true, {
group: 'RetentionPolicy',
section: 'Global Policy',
type: 'boolean',
public: true,
i18nLabel: 'RetentionPolicy_DoNotExcludeThreads',
i18nDescription: 'RetentionPolicy_DoNotExcludeThreads_Description',
enableQuery: globalQuery,
});
});

@ -29,7 +29,7 @@
image-rendering: optimizeQuality;
}
.emojione.big {
.message .emojione.big {
width: 44px;
height: 44px;
}

@ -22,7 +22,6 @@ import { getUpload, requestEventsFromLatest } from '../handler';
import { notifyUsersOnMessage } from '../../../lib/server/lib/notifyUsersOnMessage';
import { sendAllNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage';
import { processThreads } from '../../../threads/server/hooks/aftersavemessage';
import { processDeleteInThread } from '../../../threads/server/hooks/afterdeletemessage';
const eventHandlers = {
//
@ -312,11 +311,6 @@ const eventHandlers = {
if (eventResult.success) {
const { data: { roomId, messageId } } = event;
const message = Messages.findOne({ _id: messageId });
if (message) {
processDeleteInThread(message);
}
// Remove the message
Messages.removeById(messageId);

@ -13,7 +13,7 @@ export class DirectMessageRoomRoute extends RoomTypeRouteConfig {
constructor() {
super({
name: 'direct',
path: '/direct/:rid',
path: '/direct/:rid/:tab?/:context?',
});
}

@ -13,7 +13,7 @@ export class PrivateRoomRoute extends RoomTypeRouteConfig {
constructor() {
super({
name: 'group',
path: '/group/:name',
path: '/group/:name/:tab?/:context?',
});
}

@ -11,7 +11,7 @@ export class PublicRoomRoute extends RoomTypeRouteConfig {
constructor() {
super({
name: 'channel',
path: '/channel/:name',
path: '/channel/:name/:tab?/:context?',
});
}

@ -5,7 +5,7 @@ import { FileUpload } from '../../../file-upload';
import { Messages, Rooms } from '../../../models';
import { Notifications } from '../../../notifications';
export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = new Date('0001-01-01T00:00:00Z'), inclusive = true, limit = 0, excludePinned = true, ignoreDiscussion = 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, ignoreDiscussion = true, filesOnly = false, fromUsers = [], ignoreThreads = true }) {
const gt = inclusive ? '$gte' : '$gt';
const lt = inclusive ? '$lte' : '$lt';
@ -20,6 +20,7 @@ export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = ne
ignoreDiscussion,
ts,
fromUsers,
ignoreThreads,
{ fields: { 'file._id': 1, pinned: 1 }, limit },
).forEach((document) => {
FileUpload.getStore('Uploads').deleteById(document.file._id);
@ -28,12 +29,13 @@ export const cleanRoomHistory = function({ rid, latest = new Date(), oldest = ne
Messages.update({ _id: document._id }, { $unset: { file: 1 }, $set: { attachments: [{ color: '#FD745E', text }] } });
}
});
if (filesOnly) {
return fileCount;
}
if (!ignoreDiscussion) {
Messages.findDiscussionByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers, { fields: { drid: 1 }, ...limit && { limit } }).fetch()
Messages.findDiscussionByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ts, fromUsers, { fields: { drid: 1 }, ...limit && { limit } }, ignoreThreads).fetch()
.forEach(({ drid }) => deleteRoom(drid));
}

@ -8,9 +8,10 @@ import { callbacks } from '../../../callbacks/server';
import { Apps } from '../../../apps/server';
export const deleteMessage = function(message, user) {
const keepHistory = settings.get('Message_KeepHistory');
const showDeletedStatus = settings.get('Message_ShowDeletedStatus');
const deletedMsg = Messages.findOneById(message._id);
const isThread = deletedMsg.tcount > 0;
const keepHistory = settings.get('Message_KeepHistory') || isThread;
const showDeletedStatus = settings.get('Message_ShowDeletedStatus') || isThread;
if (deletedMsg && Apps && Apps.isLoaded()) {
const prevent = Promise.await(Apps.getBridges().getListenerBridge().messageEvent('IPreMessageDeletePrevent', deletedMsg));

@ -13,6 +13,7 @@ export function shouldNotifyAudio({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
}) {
if (disableAllMessageNotifications && audioNotifications == null && !hasReplyToThread) {
return false;
@ -23,7 +24,7 @@ export function shouldNotifyAudio({
}
if (!audioNotifications) {
if (settings.get('Accounts_Default_User_Preferences_audioNotifications') === 'all') {
if (settings.get('Accounts_Default_User_Preferences_audioNotifications') === 'all' && (!isThread || hasReplyToThread)) {
return true;
}
if (settings.get('Accounts_Default_User_Preferences_audioNotifications') === 'nothing') {
@ -31,7 +32,7 @@ export function shouldNotifyAudio({
}
}
return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || audioNotifications === 'all' || hasMentionToUser || hasReplyToThread;
return (roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || audioNotifications === 'all' || hasMentionToUser) && (!isThread || hasReplyToThread);
}
export function notifyAudioUser(userId, message, room) {

@ -30,6 +30,7 @@ export function notifyDesktopUser({
payload: {
_id: message._id,
rid: message.rid,
tmid: message.tmid,
sender: message.u,
type: room.t,
name: room.name,
@ -52,6 +53,7 @@ export function shouldNotifyDesktop({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
}) {
if (disableAllMessageNotifications && desktopNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
return false;
@ -62,7 +64,7 @@ export function shouldNotifyDesktop({
}
if (!desktopNotifications) {
if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'all') {
if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'all' && (!isThread || hasReplyToThread)) {
return true;
}
if (settings.get('Accounts_Default_User_Preferences_desktopNotifications') === 'nothing') {
@ -70,5 +72,5 @@ export function shouldNotifyDesktop({
}
}
return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || desktopNotifications === 'all' || hasMentionToUser || hasReplyToThread;
return (roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || desktopNotifications === 'all' || hasMentionToUser) && (!isThread || hasReplyToThread);
}

@ -192,6 +192,7 @@ export function shouldNotifyEmail({
hasMentionToAll,
hasReplyToThread,
roomType,
isThread,
}) {
// email notifications are disabled globally
if (!settings.get('Accounts_AllowEmailNotifications')) {
@ -220,5 +221,5 @@ export function shouldNotifyEmail({
}
}
return roomType === 'd' || isHighlighted || emailNotifications === 'all' || hasMentionToUser || hasReplyToThread || (!disableAllMessageNotifications && hasMentionToAll);
return (roomType === 'd' || isHighlighted || emailNotifications === 'all' || hasMentionToUser || (!disableAllMessageNotifications && hasMentionToAll)) && (!isThread || hasReplyToThread);
}

@ -52,6 +52,7 @@ export async function getPushData({ room, message, userId, receiverUsername, sen
type: room.t,
name: room.name,
messageType: message.t,
tmid: message.tmid,
},
roomName: settings.get('Push_show_username_room') && roomTypes.getConfig(room.t).isGroupChat(room) ? `#${ roomTypes.getRoomName(room.t, room) }` : '',
username,
@ -69,6 +70,7 @@ export function shouldNotifyMobile({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
}) {
if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) {
return false;
@ -79,7 +81,7 @@ export function shouldNotifyMobile({
}
if (!mobilePushNotifications) {
if (settings.get('Accounts_Default_User_Preferences_mobileNotifications') === 'all') {
if (settings.get('Accounts_Default_User_Preferences_mobileNotifications') === 'all' && (!isThread || hasReplyToThread)) {
return true;
}
if (settings.get('Accounts_Default_User_Preferences_mobileNotifications') === 'nothing') {
@ -87,5 +89,5 @@ export function shouldNotifyMobile({
}
}
return roomType === 'd' || (!disableAllMessageNotifications && hasMentionToAll) || isHighlighted || mobilePushNotifications === 'all' || hasMentionToUser || hasReplyToThread;
return (roomType === 'd' || (!disableAllMessageNotifications && hasMentionToAll) || isHighlighted || mobilePushNotifications === 'all' || hasMentionToUser) && (!isThread || hasReplyToThread);
}

@ -1,4 +1,3 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { settings } from '../../../settings';
@ -134,6 +133,8 @@ const validateMessage = (message) => {
text: String,
alias: String,
emoji: String,
tmid: String,
tshow: Boolean,
avatar: ValidPartialURLParam,
attachments: [Match.Any],
blocks: [Match.Any],
@ -154,6 +155,11 @@ export const sendMessage = function(user, message, room, upsert = false) {
if (!message.ts) {
message.ts = new Date();
}
if (message.tshow !== true) {
delete message.tshow;
}
const { _id, username, name } = user;
message.u = {
_id,
@ -175,7 +181,7 @@ export const sendMessage = function(user, message, room, upsert = false) {
}
// For the Rocket.Chat Apps :)
if (message && Apps && Apps.isLoaded()) {
if (Apps && Apps.isLoaded()) {
const prevent = Promise.await(Apps.getBridges().getListenerBridge().messageEvent('IPreMessageSentPrevent', message));
if (prevent) {
if (settings.get('Apps_Framework_Development_Mode')) {
@ -240,7 +246,7 @@ export const sendMessage = function(user, message, room, upsert = false) {
Defer other updates as their return is not interesting to the user
*/
// Execute all callbacks
Meteor.defer(() => callbacks.run('afterSaveMessage', message, room, user._id));
callbacks.runAsync('afterSaveMessage', message, room, user._id);
return message;
}
};

@ -1,4 +1,3 @@
import _ from 'underscore';
import s from 'underscore.string';
import moment from 'moment';
@ -24,77 +23,113 @@ export function messageContainsHighlight(message, highlights) {
});
}
export function updateUsersSubscriptions(message, room, users) {
export function getMentions({ mentions, u: { _id: senderId } }) {
if (!mentions) {
return {
toAll: false,
toHere: false,
mentionIds: [],
};
}
const toAll = mentions.some(({ _id }) => _id === 'all');
const toHere = mentions.some(({ _id }) => _id === 'here');
const mentionIds = mentions
.filter(({ _id }) => _id !== senderId)
.map(({ _id }) => _id);
return {
toAll,
toHere,
mentionIds,
};
}
const incGroupMentions = (rid, roomType, excludeUserId, unreadCount) => {
const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
const incUnread = roomType === 'd' || incUnreadByGroup ? 1 : 0;
Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(rid, excludeUserId, 1, incUnread);
};
const incUserMentions = (rid, roomType, uids, unreadCount) => {
const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
const incUnread = roomType === 'd' || incUnreadByUser ? 1 : 0;
Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(rid, uids, 1, incUnread);
};
const getUserIdsFromHighlights = (rid, message) => {
const highlightOptions = { fields: { userHighlights: 1, 'u._id': 1 } };
const subs = Subscriptions.findByRoomWithUserHighlights(rid, highlightOptions).fetch();
return subs
.filter(({ userHighlights, u: { _id: uid } }) => userHighlights && messageContainsHighlight(message, userHighlights) && uid !== message.u._id)
.map(({ u: { _id: uid } }) => uid);
};
export function updateUsersSubscriptions(message, room) {
if (room != null) {
let toAll = false;
let toHere = false;
const mentionIds = [];
const highlightsIds = [];
const highlightOptions = { fields: { userHighlights: 1, 'u._id': 1 } };
const highlights = users
? Subscriptions.findByRoomAndUsersWithUserHighlights(room._id, users, highlightOptions).fetch()
: Subscriptions.findByRoomWithUserHighlights(room._id, highlightOptions).fetch();
if (message.mentions != null) {
message.mentions.forEach(function(mention) {
if (!toAll && mention._id === 'all') {
toAll = true;
}
if (!toHere && mention._id === 'here') {
toHere = true;
}
if (mention._id !== message.u._id) {
mentionIds.push(mention._id);
}
});
}
const {
toAll,
toHere,
mentionIds,
} = getMentions(message);
highlights.forEach(function(subscription) {
if (subscription.userHighlights && messageContainsHighlight(message, subscription.userHighlights)) {
if (subscription.u._id !== message.u._id) {
highlightsIds.push(subscription.u._id);
}
}
});
const userIds = new Set(mentionIds);
const unreadSetting = room.t === 'd' ? 'Unread_Count_DM' : 'Unread_Count';
const unreadCount = settings.get(unreadSetting);
if (toAll || toHere) {
const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
const incUnread = room.t === 'd' || incUnreadByGroup ? 1 : 0;
Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, incUnread);
} else if (users || (mentionIds && mentionIds.length > 0) || (highlightsIds && highlightsIds.length > 0)) {
const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
const incUnread = room.t === 'd' || users || incUnreadByUser ? 1 : 0;
Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, _.compact(_.unique(mentionIds.concat(highlightsIds, users))), 1, incUnread);
} else if (unreadCount === 'all_messages') {
Subscriptions.incUnreadForRoomIdExcludingUserId(room._id, message.u._id);
incGroupMentions(room._id, room.t, message.u._id, unreadCount);
} else {
getUserIdsFromHighlights(room._id, message)
.forEach((uid) => userIds.add(uid));
if (userIds.size > 0) {
incUserMentions(room._id, room.t, [...userIds], unreadCount);
} else if (unreadCount === 'all_messages') {
Subscriptions.incUnreadForRoomIdExcludingUserId(room._id, message.u._id);
}
}
}
// Update all other subscriptions to alert their owners but witout incrementing
// Update all other subscriptions to alert their owners but without incrementing
// the unread counter, as it is only for mentions and direct messages
// We now set alert and open properties in two separate update commands. This proved to be more efficient on MongoDB - because it uses a more efficient index.
Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id);
Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id);
}
export function updateThreadUsersSubscriptions(message, room, replies) {
// const unreadCount = settings.get('Unread_Count');
// incUserMentions(room._id, room.t, replies, unreadCount);
Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies);
const repliesPlusSender = [...new Set([message.u._id, ...replies])];
Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender);
Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date());
}
export function notifyUsersOnMessage(message, room) {
// skips this callback if the message was edited and increments it if the edit was way in the past (aka imported)
if (message.editedAt && Math.abs(moment(message.editedAt).diff()) > 60000) {
// TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback
Rooms.incMsgCountById(message.rid, 1);
return message;
} if (message.editedAt) {
if (message.editedAt) {
if (Math.abs(moment(message.editedAt).diff()) > 60000) {
// TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback
Rooms.incMsgCountById(message.rid, 1);
return message;
}
// only updates last message if it was edited (skip rest of callback)
if (settings.get('Store_Last_Message') && (!room.lastMessage || room.lastMessage._id === message._id)) {
if (settings.get('Store_Last_Message') && (!message.tmid || message.tshow) && (!room.lastMessage || room.lastMessage._id === message._id)) {
Rooms.setLastMessageById(message.rid, message);
}
return message;
}
@ -103,13 +138,15 @@ export function notifyUsersOnMessage(message, room) {
return message;
}
// Update all the room activity tracker fields
Rooms.incMsgCountAndSetLastMessageById(message.rid, 1, message.ts, settings.get('Store_Last_Message') && message);
if (message.tmid) {
// if message sent ONLY on a thread, skips the rest as it is done on a callback specific to threads
if (message.tmid && !message.tshow) {
Rooms.incMsgCountById(message.rid, 1);
return message;
}
// Update all the room activity tracker fields
Rooms.incMsgCountAndSetLastMessageById(message.rid, 1, message.ts, settings.get('Store_Last_Message') && message);
updateUsersSubscriptions(message, room);
return message;

@ -66,6 +66,8 @@ export const sendNotification = async ({
return;
}
const isThread = !!message.tmid && !message.tshow;
notificationMessage = parseMessageTextPerUser(notificationMessage, message, receiver);
const isHighlighted = messageContainsHighlight(message, subscription.userHighlights);
@ -89,6 +91,7 @@ export const sendNotification = async ({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
})) {
notifyAudioUser(subscription.u._id, message, room);
}
@ -105,6 +108,7 @@ export const sendNotification = async ({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
})) {
notifyDesktopUser({
notificationMessage,
@ -125,6 +129,7 @@ export const sendNotification = async ({
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
})) {
queueItems.push({
type: 'push',
@ -148,6 +153,7 @@ export const sendNotification = async ({
hasMentionToAll,
hasReplyToThread,
roomType,
isThread,
})) {
receiver.emails.some((email) => {
if (email.verified) {

@ -5,7 +5,7 @@ import { hasPermission } from '../../../authorization';
import { cleanRoomHistory } from '../functions';
Meteor.methods({
cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, ignoreDiscussion = true, filesOnly = false, fromUsers = [] }) {
cleanRoomHistory({ roomId, latest, oldest, inclusive = true, limit, excludePinned = false, ignoreDiscussion = true, filesOnly = false, fromUsers = [], ignoreThreads }) {
check(roomId, String);
check(latest, Date);
check(oldest, Date);
@ -13,6 +13,7 @@ Meteor.methods({
check(limit, Match.Maybe(Number));
check(excludePinned, Match.Maybe(Boolean));
check(filesOnly, Match.Maybe(Boolean));
check(ignoreThreads, Match.Maybe(Boolean));
check(fromUsers, Match.Maybe([String]));
const userId = Meteor.userId();
@ -25,6 +26,6 @@ Meteor.methods({
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'cleanRoomHistory' });
}
return cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers });
return cleanRoomHistory({ rid: roomId, latest, oldest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers, ignoreThreads });
},
});

@ -16,6 +16,12 @@ import { canSendMessage } from '../../../authorization/server';
import { SystemLogger } from '../../../logger/server';
export function executeSendMessage(uid, message) {
if (message.tshow && !message.tmid) {
throw new Meteor.Error('invalid-params', 'tshow provided but missing tmid', {
method: 'sendMessage',
});
}
if (message.tmid && !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'not-allowed', {
method: 'sendMessage',

@ -23,6 +23,15 @@ Meteor.methods({
if (originalMessage.msg === message.msg) {
return;
}
if (!!message.tmid && originalMessage._id === message.tmid) {
throw new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', { method: 'updateMessage' });
}
if (!originalMessage.tmid && !!message.tmid) {
throw new Meteor.Error('error-message-change-to-thread', 'Cannot update message to a thread', { method: 'updateMessage' });
}
const _hasPermission = hasPermission(Meteor.userId(), 'edit-message', message.rid);
const editAllowed = settings.get('Message_AllowEditing');
const editOwn = originalMessage.u && originalMessage.u._id === Meteor.userId();

@ -398,11 +398,18 @@ settings.addGroup('Accounts', function() {
i18nLabel: 'Sort_By',
});
this.add('Accounts_Default_User_Preferences_showMessageInMainThread', false, {
type: 'boolean',
public: true,
i18nLabel: 'Show_Message_In_Main_Thread',
});
this.add('Accounts_Default_User_Preferences_sidebarShowFavorites', true, {
type: 'boolean',
public: true,
i18nLabel: 'Group_favorites',
});
this.add('Accounts_Default_User_Preferences_sendOnEnter', 'normal', {
type: 'select',
values: [

@ -95,7 +95,7 @@ const MarkdownMessage = (message) => {
return message;
};
const filterMarkdown = (message) => Markdown.filterMarkdownFromMessage(message);
export const filterMarkdown = (message) => Markdown.filterMarkdownFromMessage(message);
callbacks.add('renderMessage', MarkdownMessage, callbacks.priority.HIGH, 'markdown');
callbacks.add('renderNotification', filterMarkdown, callbacks.priority.HIGH, 'filter-markdown');

@ -1,20 +1,32 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { MessageAction, RoomHistoryManager } from '../../ui-utils';
import { messageArgs } from '../../ui-utils/client/lib/messageArgs';
import { Rooms } from '../../models/client';
Meteor.startup(function() {
MessageAction.addButton({
id: 'jump-to-message',
icon: 'jump',
label: 'Jump_to_message',
context: ['mentions', 'threads'],
context: ['mentions', 'threads', 'message'],
action() {
const { msg: message } = messageArgs(this);
if (window.matchMedia('(max-width: 500px)').matches) {
Template.instance().tabBar.close();
}
if (message.tmid) {
return FlowRouter.go(FlowRouter.getRouteName(), {
tab: 'thread',
context: message.tmid,
rid: message.rid,
name: Rooms.findOne({ _id: message.rid }).name,
}, {
jump: message._id,
});
}
RoomHistoryManager.getSurroundingMessages(message, 50);
},
order: 100,

@ -18,7 +18,7 @@ Meteor.startup(() => Tracker.autorun(() => {
}));
const instance = new MentionsParser({
export const instance = new MentionsParser({
pattern: () => pattern,
useRealName: () => useRealName,
me: () => me,

@ -1,6 +1,6 @@
.message .mention-link,
.mention-link {
padding: 0 6px 2px;
padding: 0 2px 2px;
transition: opacity 0.3s, background-color 0.3s, color 0.3s;

@ -1,10 +1,14 @@
import s from 'underscore.string';
const userTemplateDefault = ({ prefix, className, mention, title, label, type = 'username' }) => `${ prefix }<a class="${ className }" data-${ type }="${ mention }"${ title ? ` title="${ title }"` : '' }>${ label }</a>`;
const roomTemplateDefault = ({ prefix, reference, mention }) => `${ prefix }<a class="mention-link mention-link--room" data-channel="${ reference }">${ `#${ mention }` }</a>`;
export class MentionsParser {
constructor({ pattern, useRealName, me }) {
constructor({ pattern, useRealName, me, roomTemplate = roomTemplateDefault, userTemplate = userTemplateDefault }) {
this.pattern = pattern;
this.useRealName = useRealName;
this.me = me;
this.userTemplate = userTemplate;
this.roomTemplate = roomTemplate;
}
set me(m) {
@ -59,7 +63,7 @@ export class MentionsParser {
const className = classNames.join(' ');
if (mention === 'all' || mention === 'here') {
return `${ prefix }<a class="${ className }" data-group="${ mention }">${ mention }</a>`;
return this.userTemplate({ prefix, className, mention, label: mention, type: 'group' });
}
const label = temp
@ -73,7 +77,7 @@ export class MentionsParser {
return match;
}
return `${ prefix }<a class="${ className }" data-username="${ mention }" title="${ this.useRealName ? mention : label }">${ label }</a>`;
return this.userTemplate({ prefix, className, mention, label, title: this.useRealName ? mention : label });
})
replaceChannels = (msg, { temp, channels }) => msg
@ -85,7 +89,7 @@ export class MentionsParser {
const channel = channels && channels.find(function({ name, dname }) { return dname ? dname === mention : name === mention; });
const reference = channel ? channel._id : mention;
return `${ prefix }<a class="mention-link mention-link--room" data-channel="${ reference }">${ `#${ mention }` }</a>`;
return this.roomTemplate({ prefix, reference, channel, mention });
})
getUserMentions(str) {

@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { FlowRouter } from 'meteor/kadira:flow-router';
import toastr from 'toastr';
import { RoomHistoryManager, MessageAction } from '../../ui-utils';
@ -8,6 +9,7 @@ import { messageArgs } from '../../ui-utils/client/lib/messageArgs';
import { handleError } from '../../utils';
import { settings } from '../../settings';
import { hasAtLeastOnePermission } from '../../authorization';
import { Rooms } from '../../models/client';
Meteor.startup(function() {
MessageAction.addButton({
@ -64,12 +66,23 @@ Meteor.startup(function() {
id: 'jump-to-pin-message',
icon: 'jump',
label: 'Jump_to_message',
context: ['pinned', 'message', 'message-mobile', 'direct'],
context: ['pinned', 'message-mobile', 'direct'],
action() {
const { msg: message } = messageArgs(this);
if (window.matchMedia('(max-width: 500px)').matches) {
Template.instance().tabBar.close();
}
if (message.tmid) {
return FlowRouter.go(FlowRouter.getRouteName(), {
tab: 'thread',
context: message.tmid,
rid: message.rid,
jump: message._id,
name: Rooms.findOne({ _id: message.rid }).name,
}, {
jump: message._id,
});
}
return RoomHistoryManager.getSurroundingMessages(message, 50);
},
condition({ subscription }) {

@ -1,12 +1,14 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { FlowRouter } from 'meteor/kadira:flow-router';
import toastr from 'toastr';
import { handleError } from '../../utils';
import { settings } from '../../settings';
import { RoomHistoryManager, MessageAction } from '../../ui-utils';
import { messageArgs } from '../../ui-utils/client/lib/messageArgs';
import { Rooms } from '../../models/client';
Meteor.startup(function() {
MessageAction.addButton({
@ -69,6 +71,17 @@ Meteor.startup(function() {
if (window.matchMedia('(max-width: 500px)').matches) {
Template.instance().tabBar.close();
}
if (message.tmid) {
return FlowRouter.go(FlowRouter.getRouteName(), {
tab: 'thread',
context: message.tmid,
rid: message.rid,
jump: message._id,
name: Rooms.findOne({ _id: message.rid }).name,
}, {
jump: message._id,
});
}
RoomHistoryManager.getSurroundingMessages(message, 50);
},
condition({ msg, subscription, u }) {

@ -172,7 +172,7 @@ export class Messages extends Base {
return this.find(query, { fields: { 'file._id': 1 }, ...options });
}
findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreDiscussion = true, ts, users = [], options = {}) {
findFilesByRoomIdPinnedTimestampAndUsers(rid, excludePinned, ignoreDiscussion = true, ts, users = [], ignoreThreads = true, options = {}) {
const query = {
rid,
ts,
@ -183,6 +183,11 @@ export class Messages extends Base {
query.pinned = { $ne: true };
}
if (ignoreThreads) {
query.tmid = { $exists: 0 };
query.tcount = { $exists: 0 };
}
if (ignoreDiscussion) {
query.drid = { $exists: 0 };
}
@ -922,7 +927,7 @@ export class Messages extends Base {
return this.remove({ rid: { $in: rids } });
}
removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ignoreDiscussion = true, ts, limit, users = []) {
removeByIdPinnedTimestampLimitAndUsers(rid, pinned, ignoreDiscussion = true, ts, limit, users = [], ignoreThreads = true) {
const query = {
rid,
ts,
@ -936,6 +941,11 @@ export class Messages extends Base {
query.drid = { $exists: 0 };
}
if (ignoreThreads) {
query.tmid = { $exists: 0 };
query.tcount = { $exists: 0 };
}
if (users.length) {
query['u.username'] = { $in: users };
}

@ -985,6 +985,18 @@ export class Rooms extends Base {
return this.update(query, update);
}
saveRetentionIgnoreThreadsById(_id, value) {
const query = { _id };
const update = {
[value === true ? '$set' : '$unset']: {
'retention.ignoreThreads': true,
},
};
return this.update(query, update);
}
saveRetentionFilesOnlyById(_id, value) {
const query = { _id };

@ -543,8 +543,7 @@ export class Subscriptions extends Base {
}
findByRoomId(roomId, options) {
const query = { rid: roomId };
const query = { rid: roomId };
return this.find(query, options);
}
@ -559,16 +558,6 @@ export class Subscriptions extends Base {
return this.find(query, options);
}
findByRoomAndUsersWithUserHighlights(roomId, users, options) {
const query = {
rid: roomId,
'u._id': { $in: users },
'userHighlights.0': { $exists: true },
};
return this.find(query, options);
}
findByRoomWithUserHighlights(roomId, options) {
const query = {
rid: roomId,
@ -989,6 +978,50 @@ export class Subscriptions extends Base {
return this.update(query, update, { multi: true });
}
setAlertForRoomIdAndUserIds(roomId, uids) {
const query = {
rid: roomId,
'u._id': { $in: uids },
alert: { $ne: true },
};
const update = {
$set: {
alert: true,
},
};
return this.update(query, update, { multi: true });
}
setOpenForRoomIdAndUserIds(roomId, uids) {
const query = {
rid: roomId,
'u._id': { $in: uids },
open: { $ne: true },
};
const update = {
$set: {
open: true,
},
};
return this.update(query, update, { multi: true });
}
setLastReplyForRoomIdAndUserIds(roomId, uids, lr) {
const query = {
rid: roomId,
'u._id': { $in: uids },
};
const update = {
$set: {
lr,
},
};
return this.update(query, update, { multi: true });
}
setBlockedByRoomId(rid, blocked, blocker) {
const query = {
rid,

@ -23,6 +23,7 @@ function job() {
const filesOnly = settings.get('RetentionPolicy_FilesOnly');
const excludePinned = settings.get('RetentionPolicy_ExcludePinned');
const ignoreDiscussion = settings.get('RetentionPolicy_DoNotExcludeDiscussion');
const ignoreThreads = settings.get('RetentionPolicy_DoNotExcludeThreads');
// get all rooms with default values
types.forEach((type) => {
@ -37,7 +38,7 @@ function job() {
],
'retention.overrideGlobal': { $ne: true },
}, { fields: { _id: 1 } }).forEach(({ _id: rid }) => {
cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned, ignoreDiscussion });
cleanRoomHistory({ rid, latest, oldest, filesOnly, excludePinned, ignoreDiscussion, ignoreThreads });
});
});
@ -46,9 +47,9 @@ function job() {
'retention.overrideGlobal': { $eq: true },
'retention.maxAge': { $gte: 0 },
}).forEach((room) => {
const { maxAge = 30, filesOnly, excludePinned } = room.retention;
const { maxAge = 30, filesOnly, excludePinned, ignoreThreads } = room.retention;
const latest = new Date(now.getTime() - toDays(maxAge));
cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned, ignoreDiscussion });
cleanRoomHistory({ rid: room._id, latest, oldest, filesOnly, excludePinned, ignoreDiscussion, ignoreThreads });
});
}

@ -9,6 +9,7 @@ import _ from 'underscore';
import { messageContext } from '../../../ui-utils/client/lib/messageContext';
import { MessageAction, RoomHistoryManager } from '../../../ui-utils';
import { messageArgs } from '../../../ui-utils/client/lib/messageArgs';
import { Rooms } from '../../../models/client';
Meteor.startup(function() {
MessageAction.addButton({
@ -18,6 +19,17 @@ Meteor.startup(function() {
context: ['search'],
action() {
const { msg: message } = messageArgs(this);
if (message.tmid) {
return FlowRouter.go(FlowRouter.getRouteName(), {
tab: 'thread',
context: message.tmid,
rid: message.rid,
name: Rooms.findOne({ _id: message.rid }).name,
}, {
jump: message._id,
});
}
if (Session.get('openedRoom') === message.rid) {
return RoomHistoryManager.getSurroundingMessages(message, 50);
}

@ -3,6 +3,7 @@
min-width: 18px;
min-height: 18px;
margin: 0 3px;
padding: 2px 5px;
color: var(--badge-text-color);
@ -15,6 +16,12 @@
justify-content: center;
&--unread {
white-space: nowrap;
background-color: var(--badge-unread-background);
}
&--thread {
margin: 0 3px;
white-space: nowrap;

@ -1,5 +1,5 @@
.contextual-bar {
z-index: 1;
z-index: 10;
display: flex;
@ -11,7 +11,8 @@
height: 100%;
background: var(--color-white);
box-shadow: 0 3px 1px 2px rgba(31, 35, 41, 0.08);
border-inline-start: 2px solid var(--color-gray-lightest);
&-wrap {
position: relative;
@ -54,11 +55,13 @@
display: flex;
flex: 0 0 auto;
padding: var(--default-padding);
height: 64px;
border-bottom: solid 1px var(--color-gray-light);
margin: 0 -8px;
padding: var(--default-padding);
background: var(--color-gray-lightest);
border-bottom: solid 2px var(--color-gray-lightest);
align-items: center;
justify-content: flex-end;
@ -137,6 +140,12 @@
}
}
.contextual-bar + .contextual-bar {
position: absolute;
z-index: -1;
right: 0;
}
@media (width <= 1100px) {
.contextual-bar {
position: absolute;

@ -1,7 +1,4 @@
.rc-header {
z-index: 10;
font-size: var(--text-heading-size);
.rc-badge {
@ -45,7 +42,7 @@
&--room {
padding: 1.25rem;
box-shadow: 0 1px 2px 0 rgba(31, 35, 41, 0.08);
border-bottom: 2px solid var(--color-gray-lightest);
font-size: var(--header-title-font-size);
}
@ -61,8 +58,6 @@
white-space: nowrap;
background-color: var(--header-background-color);
align-items: center;
justify-content: space-between;

@ -205,7 +205,7 @@
.message {
&:hover,
&.active {
background-color: rgba(15, 34, 0, 0.05);
background-color: #f7f8fa;
& .message-actions {
display: flex;

@ -5,7 +5,7 @@
&::before,
&::after {
position: absolute;
z-index: 10;
z-index: 1000;
bottom: 100%;
left: 50%;

@ -2111,6 +2111,8 @@
&-unread {
display: inline-block;
float: right;
width: 10px;
height: 10px;
margin: 0 0.25rem;

@ -40,7 +40,7 @@
/* #region colors Colors */
--rc-color-error: var(--color-red);
--rc-color-error-light: #e1364c;
--rc-color-alert: var(--color-orange);
--rc-color-alert: var(--color-yellow);
--rc-color-alert-light: var(--color-dark-yellow);
--rc-color-success: var(--color-green);
--rc-color-success-light: #25d198;
@ -103,7 +103,7 @@
--toolbar-height: 55px;
--footer-min-height: 70px;
--rooms-box-width: 280px;
--flex-tab-width: 400px;
--flex-tab-width: 380px;
--flex-tab-webrtc-width: 400px;
--flex-tab-webrtc-2-width: 850px;
--border: 2px;
@ -317,20 +317,20 @@
--badge-radius: 12px;
--badge-text-size: 0.75rem;
--badge-background: var(--rc-color-primary-dark);
--badge-unread-background: var(--rc-color-primary-dark);
--badge-user-mentions-background: var(--color-dark-blue);
--badge-group-mentions-background: var(--rc-color-primary-dark);
--badge-unread-background: var(--color-blue);
--badge-user-mentions-background: var(--color-red);
--badge-group-mentions-background: var(--color-orange);
/*
* Mention link
*/
--mention-link-radius: 10px;
--mention-link-background: var(--color-lighter-blue);
--mention-link-text-color: var(--color-dark-blue);
--mention-link-me-background: var(--color-dark-blue);
--mention-link-me-text-color: var(--color-white);
--mention-link-group-background: var(--rc-color-primary-dark);
--mention-link-group-text-color: var(--color-white);
--mention-link-radius: 4px;
--mention-link-background: #fff6d6;
--mention-link-text-color: #b68d00;
--mention-link-me-background: #ffe9ec;
--mention-link-me-text-color: var(--color-red);
--mention-link-group-background: #fde8d7;
--mention-link-group-text-color: var(--color-orange);
/*
* Message box

@ -0,0 +1,100 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Blaze } from 'meteor/blaze';
import { Tracker } from 'meteor/tracker';
import { ChatMessage } from '../../../models/client';
import { useRoute } from '../../../../client/contexts/RouterContext';
import { roomTypes, APIClient } from '../../../utils/client';
import { call } from '../../../ui-utils/client';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
import { useLocalStorage } from './hooks/useLocalstorage';
import { normalizeThreadTitle } from '../lib/normalizeThreadTitle';
export default function ThreadComponent({ mid, rid, jump, room, ...props }) {
const t = useTranslation();
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const [mainMessage, setMainMessage] = useState({});
const [expanded, setExpand] = useLocalStorage('expand-threads', false);
const ref = useRef();
const uid = useMemo(() => Meteor.userId(), []);
const style = useMemo(() => ({
top: 0,
right: 0,
maxWidth: '855px',
...document.dir === 'rtl' ? { borderTopRightRadius: '4px' } : { borderTopLeftRadius: '4px' },
overflow: 'hidden',
bottom: 0,
zIndex: 100,
}), [document.dir]);
const following = mainMessage.replies && mainMessage.replies.includes(uid);
const actionId = useMemo(() => (following ? 'unfollow' : 'follow'), [following]);
const button = useMemo(() => (actionId === 'follow' ? 'bell-off' : 'bell'), [actionId]);
const actionLabel = t(actionId === 'follow' ? 'Not_Following' : 'Following');
const headerTitle = useMemo(() => normalizeThreadTitle(mainMessage), [mainMessage._updatedAt]);
const expandLabel = expanded ? 'collapse' : 'expand';
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand';
const handleExpandButton = useCallback(() => {
setExpand(!expanded);
}, [expanded]);
const handleFollowButton = useCallback(() => call(actionId === 'follow' ? 'followMessage' : 'unfollowMessage', { mid }), [actionId, mid]);
const handleClose = useCallback(() => {
channelRoute.push(room.t === 'd' ? { rid } : { name: room.name });
}, [channelRoute, room.t, room.name]);
useEffect(() => {
const tracker = Tracker.autorun(async () => {
const msg = ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message;
if (!msg) {
return;
}
setMainMessage(msg);
});
return () => tracker.stop();
}, [mid]);
useEffect(() => {
let view;
(async () => {
view = mainMessage.rid && ref.current && Blaze.renderWithData(Template.thread, { mainMessage: ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message, jump, following, ...props }, ref.current);
})();
return () => view && Blaze.remove(view);
}, [mainMessage.rid, mid]);
if (!mainMessage.rid) {
return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }
<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar.Skeleton rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }/>
</Box>
</>;
}
return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }
<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text>{headerTitle}</VerticalBar.Text>
<VerticalBar.Action aria-label={expandLabel} onClick={handleExpandButton} name={expandIcon}/>
<VerticalBar.Action aria-label={actionLabel} onClick={handleFollowButton} name={button}/>
<VerticalBar.Close aria-label={t('Close')} onClick={handleClose}/>
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0} flexShrink={1} flexGrow={1} ref={ref}/>
</VerticalBar>
</Box>
</>;
}

@ -0,0 +1,266 @@
import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import s from 'underscore.string';
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { Box, Icon, TextInput, Select, Margins, Callout } from '@rocket.chat/fuselage';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useDebouncedValue, useDebouncedState, useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { css } from '@rocket.chat/css-in-js';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import RawText from '../../../../client/components/basic/RawText';
import { useRoute } from '../../../../client/contexts/RouterContext';
import { roomTypes } from '../../../utils/client';
import { call, renderMessageBody } from '../../../ui-utils/client';
import { useUserId, useUser } from '../../../../client/contexts/UserContext';
import { Messages } from '../../../models/client';
import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../../../client/hooks/useEndpointDataExperimental';
import { getConfig } from '../../../ui-utils/client/config';
import { useTimeAgo } from '../../../../client/hooks/useTimeAgo';
import ThreadListMessage, { MessageSkeleton } from './ThreadListMessage';
import { useUserSubscription } from './hooks/useUserSubscription';
import { useUserRoom } from './hooks/useUserRoom';
import { useLocalStorage } from './hooks/useLocalstorage';
import { useSetting } from '../../../../client/contexts/SettingsContext';
function clickableItem(WrappedComponent) {
const clickable = css`
cursor: pointer;
border-bottom: 2px solid #F2F3F5 !important;
&:hover,
&:focus {
background: #F7F8FA;
}
`;
return (props) => <WrappedComponent className={clickable} tabIndex={0} {...props}/>;
}
function mapProps(WrappedComponent) {
return ({ msg, username, replies, tcount, ts, ...props }) => <WrappedComponent replies={tcount} participants={replies.length} username={username} msg={msg} ts={ts} {...props}/>;
}
const Thread = React.memo(mapProps(clickableItem(ThreadListMessage)));
const Skeleton = React.memo(clickableItem(MessageSkeleton));
const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 25;
const filterProps = ({ msg, u, replies, mentions, tcount, ts, _id, tlm, attachments }) => ({ ..._id && { _id }, attachments, mentions, msg, u, replies, tcount, ts: new Date(ts), tlm: new Date(tlm) });
const subscriptionFields = { tunread: 1 };
const roomFields = { t: 1, name: 1 };
export function withData(WrappedComponent) {
return ({ rid, ...props }) => {
const room = useUserRoom(rid, roomFields);
const subscription = useUserSubscription(rid, subscriptionFields);
const userId = useUserId();
const [type, setType] = useLocalStorage('thread-list-type', 'all');
const [text, setText] = useState('');
const [total, setTotal] = useState(LIST_SIZE);
const [threads, setThreads] = useDebouncedState([], 100);
const Threads = useRef(new Mongo.Collection(null));
const ref = useRef();
const [pagination, setPagination] = useState({ skip: 0, count: LIST_SIZE });
const params = useMemo(() => ({ rid: room._id, count: pagination.count, offset: pagination.skip, type, text }), [room._id, pagination.skip, pagination.count, type, text]);
const { data, state, error } = useEndpointDataExperimental('chat.getThreadsList', useDebouncedValue(params, 400));
const loadMoreItems = useCallback((skip, count) => {
setPagination({ skip, count: count - skip });
return new Promise((resolve) => { ref.current = resolve; });
}, []);
useEffect(() => () => Threads.current.remove({}, () => {}), [text, type]);
useEffect(() => {
if (state !== ENDPOINT_STATES.DONE || !data || !data.threads) {
return;
}
data.threads.forEach(({ _id, ...message }) => {
Threads.current.upsert({ _id }, filterProps(message));
});
ref.current && ref.current();
setTotal(data.total);
}, [data, state]);
useEffect(() => {
const cursor = Messages.find({ rid: room._id, tcount: { $exists: true }, _hidden: { $ne: true } }).observe({
added: ({ _id, ...message }) => {
Threads.current.upsert({ _id }, message);
}, // Update message to re-render DOM
changed: ({ _id, ...message }) => {
Threads.current.update({ _id }, message);
}, // Update message to re-render DOM
removed: ({ _id }) => {
Threads.current.remove(_id);
},
});
return () => cursor.stop();
}, [room._id]);
useEffect(() => {
const cursor = Tracker.autorun(() => {
const query = {
...type === 'subscribed' && { replies: { $in: [userId] } },
};
setThreads(Threads.current.find(query, { sort: { tlm: -1 } }).fetch().map(filterProps));
});
return () => cursor.stop();
}, [room._id, type, setThreads, userId]);
const handleTextChange = useCallback((e) => {
setPagination({ skip: 0, count: LIST_SIZE });
setText(e.currentTarget.value);
}, []);
return <WrappedComponent
{...props}
unread={subscription && subscription.tunread}
userId={userId}
error={error}
threads={threads}
total={total}
loading={state === ENDPOINT_STATES.LOADING}
loadMoreItems={loadMoreItems}
room={room}
text={text}
setText={handleTextChange}
type={type}
setType={setType}
/>;
};
}
const handleFollowButton = (e) => {
e.preventDefault();
e.stopPropagation();
call(![true, 'true'].includes(e.currentTarget.dataset.following) ? 'followMessage' : 'unfollowMessage', { mid: e.currentTarget.dataset.id });
};
export const normalizeThreadMessage = ({ ...message }) => {
if (message.msg) {
return renderMessageBody(message).replace(/<br\s?\\?>/g, ' ');
}
if (message.attachments) {
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
if (attachment && attachment.description) {
return s.escapeHTML(attachment.description);
}
if (attachment && attachment.title) {
return s.escapeHTML(attachment.title);
}
}
};
export function ThreadList({ total = 10, threads = [], room, unread = [], type, setType, loadMoreItems, loading, onClose, error, userId, text, setText }) {
const showRealNames = useSetting('UI_Use_Real_Name');
const threadsRef = useRef();
const t = useTranslation();
const user = useUser();
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const onClick = useCallback((e) => {
const { id: context } = e.currentTarget.dataset;
channelRoute.push({
tab: 'thread',
context,
rid: room._id,
name: room.name,
});
}, [room._id, room.name]);
const formatDate = useTimeAgo();
const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], []);
threadsRef.current = threads;
const rowRenderer = useCallback(React.memo(function rowRenderer({ data, index, style }) {
if (!data[index]) {
return <Skeleton style={style}/>;
}
const thread = data[index];
const msg = normalizeThreadMessage(thread);
const { name = thread.u.username } = thread.u;
return <Thread
{ ...thread }
name={showRealNames ? name : thread.u.username }
username={ thread.u.username }
style={style}
unread={unread.includes(thread._id)}
mention={thread.mentions && thread.mentions.includes(user.username)}
all={thread.mentions && thread.mentions.includes('all')}
following={thread.replies && thread.replies.includes(userId)}
data-id={thread._id}
msg={msg}
t={t}
formatDate={formatDate}
handleFollowButton={handleFollowButton} onClick={onClick}
/>;
}), [unread, showRealNames]);
const isItemLoaded = useCallback((index) => index < threadsRef.current.length, []);
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 750 } = {} } = useResizeObserver();
return <VerticalBar>
<VerticalBar.Header>
<Icon name='thread' size='x20'/>
<Box flexShrink={1} flexGrow={1} withTruncatedText mi='x8'><RawText>{t('Threads')}</RawText></Box>
<VerticalBar.Close onClick={onClose}/>
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0}>
<Box display='flex' flexDirection='row' p='x24' borderBlockEndWidth='x2' borderBlockEndStyle='solid' borderBlockEndColor='neutral-200'>
<Box display='flex' flexDirection='row' flexGrow={1} mi='neg-x8'>
<Margins inline='x8'>
<TextInput placeholder={t('Search_Messages')} value={text} onChange={setText} addon={<Icon name='magnifier' size='x20'/>}/>
<Select flexGrow={0} width='110px' onChange={setType} value={type} options={options} />
</Margins>
</Box>
</Box>
<Box flexGrow={1} flexShrink={1} ref={ref}>
{error && <Callout mi='x24' type='danger'>{error.toString()}</Callout>}
{total === 0 && <Box p='x24'>{t('No_Threads')}</Box>}
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={total}
loadMoreItems={ loading ? () => {} : loadMoreItems}
>
{({ onItemsRendered, ref }) => (<List
height={blockSize}
width={inlineSize}
itemCount={total}
itemData={threads}
itemSize={124}
ref={ref}
minimumBatchSize={LIST_SIZE}
onItemsRendered={onItemsRendered}
>{rowRenderer}</List>
)}
</InfiniteLoader>
</Box>
</VerticalBar.Content>
</VerticalBar>;
}
export default withData(ThreadList);

@ -0,0 +1,127 @@
import React from 'react';
import { Box, Margins, Button, Icon, Skeleton } from '@rocket.chat/fuselage';
import { css } from '@rocket.chat/css-in-js';
import UserAvatar from '../../../../client/components/basic/avatar/UserAvatar';
import RawText from '../../../../client/components/basic/RawText';
const borderRadius = css`
border-radius: 100%;
`;
export function NotificationStatus({ t = (e) => e, label, ...props }) {
return <Box width='x8' aria-label={t(label)} className={[borderRadius]} height='x8' {...props} />;
}
export function NotificationStatusAll(props) {
return <NotificationStatus label='mention-all' bg='#F38C39' {...props} />;
}
export function NotificationStatusMe(props) {
return <NotificationStatus label='Me' bg='danger-500' {...props} />;
}
export function NotificationStatusUnread(props) {
return <NotificationStatus label='Unread' bg='primary-500' {...props} />;
}
function isIterable(obj) {
// checks for null and undefined
if (obj == null) {
return false;
}
return typeof obj[Symbol.iterator] === 'function';
}
const followStyle = css`
& > .rcx-message__container > .rcx-contextual-message__follow {
opacity: 0;
}
.rcx-contextual-message__follow:focus,
&:hover > .rcx-message__container > .rcx-contextual-message__follow,
&:focus > .rcx-message__container > .rcx-contextual-message__follow {
opacity: 1
}
`;
export default function ThreadListMessage({ _id, msg, following, username, name, ts, replies, participants, handleFollowButton, unread, mention, all, t = (e) => e, formatDate = (e) => e, tlm, className = [], ...props }) {
const button = !following ? 'bell-off' : 'bell';
const actionLabel = t(!following ? 'Not_Following' : 'Following');
return <Box rcx-contextual-message pi='x20' pb='x16' pbs='x16' display='flex' {...props} className={[...isIterable(className) ? className : [className], !following && followStyle].filter(Boolean)}>
<Container mb='neg-x2'>
<UserAvatar username={username} rcx-message__avatar size='x36'/>
</Container>
<Container width='1px' mb='neg-x4' flexGrow={1}>
<Header>
<Username title={username}>{name}</Username>
<Timestamp ts={formatDate(ts)}/>
</Header>
<Body><RawText>{msg}</RawText></Body>
<Box mi='neg-x2' flexDirection='row' display='flex' alignItems='baseline' mbs='x8'>
<Margins inline='x2'>
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600'><Icon name='thread' size='x20' mi='x2'/> {replies} </Box>
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600'><Icon name='user' size='x20' mi='x2'/> {participants} </Box>
<Box display='flex' alignItems='center' is='span' fontSize='x12' color='neutral-700' fontWeight='600' withTruncatedText flexShrink={1}><Icon name='clock' size='x20' mi='x2' /> {formatDate(tlm)} </Box>
</Margins>
</Box>
</Container>
<Container alignItems='center'>
<Button rcx-contextual-message__follow small square flexShrink={0} ghost data-following={following} data-id={_id} onClick={handleFollowButton} aria-label={actionLabel}><Icon name={button} size='x20'/></Button>
{
(mention && <NotificationStatusMe t={t} mb='x24'/>)
|| (all && <NotificationStatusAll t={t} mb='x24'/>)
|| (unread && <NotificationStatusUnread t={t} mb='x24'/>)
}
</Container>
</Box>;
}
export function MessageSkeleton(props) {
return <Box rcx-message pi='x20' pb='x16' pbs='x16' display='flex' {...props}>
<Container mb='neg-x2'>
<Skeleton variant='rect' size='x36'/>
</Container>
<Container width='1px' mb='neg-x4' flexGrow={1}>
<Header>
<Skeleton width='100%'/>
</Header>
<Body><Skeleton /><Skeleton /></Body>
<Box mi='neg-x8' flexDirection='row' display='flex' alignItems='baseline' mb='x8'>
<Margins inline='x4'>
<Skeleton />
<Skeleton />
<Skeleton />
</Margins>
</Box>
</Container>
</Box>;
}
function Container({ children, ...props }) {
return <Box rcx-message__container display='flex' mi='x4' flexDirection='column' {...props}><Margins block='x2'>{children}</Margins></Box>;
}
function Header({ children }) {
return <Box rcx-message__header display='flex' flexGrow={0} flexShrink={1} withTruncatedText><Box mi='neg-x2' display='flex' flexDirection='row' alignItems='baseline' withTruncatedText flexGrow={1 } flexShrink={1}><Margins inline='x2'> {children} </Margins></Box></Box>;
}
function Username(props) {
return <Box rcx-message__username color='neutral-800' fontSize='x14' fontWeight='600' flexShrink={1} withTruncatedText {...props}/>;
}
function Timestamp({ ts }) {
return <Box rcx-message__time fontSize='c1' color='neutral-600' flexShrink={0} withTruncatedText>{ts.toDateString ? ts.toDateString() : ts }</Box>;
}
const style = {
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-all',
};
function Body(props) {
return <Box rcx-message__body flexShrink={1} style={style} lineHeight='1.45' minHeight='40px' {...props}/>;
}

@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.log('useLocalStorage Error ->', error);
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.log('useLocalStorage setValue Error ->', error);
}
};
useEffect(() => {
function handleEvent(e) {
if (e.key !== key) {
return;
}
setStoredValue(JSON.parse(e.newValue));
}
window.addEventListener('storage', handleEvent);
return () => window.removeEventListener('storage', handleEvent);
}, []);
return [storedValue, setValue];
}

@ -0,0 +1,4 @@
import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue';
import { Rooms } from '../../../../models/client';
export const useUserRoom = (rid, fields) => useReactiveValue(() => Rooms.findOne({ _id: rid }, { fields }), [rid, fields]);

@ -0,0 +1,4 @@
import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue';
import { Subscriptions } from '../../../../models/client';
export const useUserSubscription = (rid, fields) => useReactiveValue(() => Subscriptions.findOne({ rid }, { fields }), [rid, fields]);

@ -0,0 +1,5 @@
<template name="messageBoxFollow">
<button class="js-follow rc-button rc-button--primary rc-message-box__join-button">
{{_ "Follow"}}
</button>
</template>

@ -0,0 +1,11 @@
import { Template } from 'meteor/templating';
import './messageBoxFollow.html';
import { call } from '../../../ui-utils/client';
Template.messageBoxFollow.events({
'click .js-follow'() {
const { tmid } = this;
call('followMessage', { mid: tmid });
},
});

@ -1,16 +1,4 @@
<template name="thread">
<header class="contextual-bar__header">
<div class="contextual-bar__header-data">
{{> icon block="contextual-bar__header-icon" icon='thread'}}
<h2 class="contextual-bar__header-title">
<span class="message-body--unstyled">{{{threadTitle}}}</span>
{{!-- <sub class="contextual-bar__header-description">{{room.fname}}</sub> --}}
</h2>
</div>
<button class="contextual-bar__header-close js-close" aria-label="{{_ "Close"}}">
{{> icon block="contextual-bar__header-close-icon" icon="plus"}}
</button>
</header>
<section class="contextual-bar__content flex-tab threads dropzone {{dragAndDrop}} {{hideUsername}}">
<div class="dropzone-overlay {{isDropzoneDisabled}} background-transparent-darkest color-content-background-color">{{_ dragAndDropLabel}}</div>
<div class="thread-list js-scroll-thread">
@ -23,12 +11,18 @@
{{else}}
{{> message groupable=false hideRoles=true msg=mainMessage room=room subscription=subscription settings=settings customClass="thread-message" templatePrefix='thread-' customClass="thread-main" u=u ignored=false}}
{{#each msg in messages}}
{{> message hideRoles=true msg=msg room=room subscription=subscription settings=settings templatePrefix='thread-' u=u context="threads"}}
{{> message hideRoles=true msg=msg room=room shouldCollapseReplies=true subscription=subscription settings=settings templatePrefix='thread-' u=u context="threads"}}
{{/each}}
{{/if}}
{{/with}}
</ul>
</div>
{{> messageBox messageBoxData}}
<footer class="thread-footer">
{{# with checkboxData }}
{{> Checkbox }}
{{/with}}
<label for="sendAlso" class="thread-footer__text">{{ _ "Also_send_to_channel" }}</label>
</footer>
</section>
</template>

@ -2,21 +2,40 @@ import _ from 'underscore';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { Template } from 'meteor/templating';
import { HTML } from 'meteor/htmljs';
import { ReactiveDict } from 'meteor/reactive-dict';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { chatMessages, ChatMessages } from '../../../ui';
import { normalizeThreadMessage, call } from '../../../ui-utils/client';
import { call, keyCodes } from '../../../ui-utils/client';
import { messageContext } from '../../../ui-utils/client/lib/messageContext';
import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager';
import { Messages } from '../../../models';
import { fileUpload } from '../../../ui/client/lib/fileUpload';
import { createTemplateForComponent } from '../../../../client/reactAdapters';
import { dropzoneEvents, dropzoneHelpers } from '../../../ui/client/views/app/room';
import './thread.html';
import { getUserPreference } from '../../../utils';
import { settings } from '../../../settings/client';
import { callbacks } from '../../../callbacks/client';
import './messageBoxFollow';
createTemplateForComponent('Checkbox', async () => {
const { CheckBox } = await import('@rocket.chat/fuselage');
return { default: CheckBox };
}, {
// eslint-disable-next-line new-cap
renderContainerView: () => HTML.DIV({ class: 'rcx-checkbox', style: 'display: flex;' }),
});
const sort = { ts: 1 };
createTemplateForComponent('ThreadComponent', () => import('../components/ThreadComponent'), {
// eslint-disable-next-line new-cap
renderContainerView: () => HTML.DIV({ class: 'contextual-bar', style: 'display: flex; height: 100%;' }),
});
Template.thread.events({
...dropzoneEvents,
'click .js-close'(e) {
@ -27,11 +46,7 @@ Template.thread.events({
},
'scroll .js-scroll-thread': _.throttle(({ currentTarget: e }, i) => {
i.atBottom = e.scrollTop >= e.scrollHeight - e.clientHeight;
}, 50),
'load img'() {
const { atBottom } = this;
atBottom && this.sendToBottom();
},
}, 150),
'click .toggle-hidden'(e) {
const id = e.currentTarget.dataset.message;
document.querySelector(`#thread-${ id }`).classList.toggle('message--ignored');
@ -40,11 +55,10 @@ Template.thread.events({
Template.thread.helpers({
...dropzoneHelpers,
threadTitle() {
return normalizeThreadMessage(Template.currentData().mainMessage);
},
mainMessage() {
return Template.parentData().mainMessage;
const { Threads, state } = Template.instance();
const tmid = state.get('tmid');
return Threads.findOne({ _id: tmid });
},
isLoading() {
return Template.instance().state.get('loading') !== false;
@ -52,7 +66,8 @@ Template.thread.helpers({
messages() {
const { Threads, state } = Template.instance();
const tmid = state.get('tmid');
return Threads.find({ tmid }, { sort });
return Threads.find({ tmid, _id: { $ne: tmid } }, { sort });
},
messageContext() {
const result = messageContext.call(this, { rid: this.mainMessage.rid });
@ -67,44 +82,109 @@ Template.thread.helpers({
},
messageBoxData() {
const instance = Template.instance();
const { mainMessage: { rid, _id: tmid }, subscription } = this;
const { mainMessage: { rid, _id: tmid }, subscription } = Template.currentData();
const thread = Messages.findOne({ _id: tmid }, { fields: { replies: 1 } });
const following = thread?.replies?.includes(Meteor.userId());
const showFormattingTips = settings.get('Message_ShowFormattingTips');
return {
showFormattingTips,
tshow: instance.state.get('sendToChannel'),
subscription,
...!following && {
customAction: {
template: 'messageBoxFollow',
data: { tmid },
},
},
rid,
tmid,
onSend: (...args) => instance.chatMessages && instance.chatMessages.send.apply(instance.chatMessages, args),
onSend: (...args) => {
instance.sendToBottom();
instance.state.set('sendToChannel', false);
return instance.chatMessages && instance.chatMessages.send.apply(instance.chatMessages, args);
},
onKeyUp: (...args) => instance.chatMessages && instance.chatMessages.keyup.apply(instance.chatMessages, args),
onKeyDown: (...args) => instance.chatMessages && instance.chatMessages.keydown.apply(instance.chatMessages, args),
onKeyDown: (...args) => {
const result = instance.chatMessages && instance.chatMessages.keydown.apply(instance.chatMessages, args);
const [event] = args;
const { which: keyCode } = event;
if (keyCode === keyCodes.ESCAPE && !result && !event.target.value.trim()) {
const { route: { name }, params: { context, tab, ...params } } = FlowRouter.current();
FlowRouter.go(name, params);
}
},
};
},
hideUsername() {
return getUserPreference(Meteor.userId(), 'hideUsernames') ? 'hide-usernames' : undefined;
},
checkboxData() {
const instance = Template.instance();
const checked = instance.state.get('sendToChannel');
return {
id: 'sendAlso',
checked,
onChange: () => instance.state.set('sendToChannel', !checked),
};
},
});
Template.thread.onRendered(function() {
const rid = Tracker.nonreactive(() => this.state.get('rid'));
const tmid = Tracker.nonreactive(() => this.state.get('tmid'));
this.atBottom = true;
this.chatMessages = new ChatMessages();
this.chatMessages.initializeWrapper(this.find('.js-scroll-thread'));
this.chatMessages.initializeInput(this.find('.js-input-message'), { rid, tmid });
this.sendToBottom = _.throttle(() => {
this.atBottom = true;
this.chatMessages.wrapper.scrollTop = this.chatMessages.wrapper.scrollHeight;
}, 300);
this.sendToBottomIfNecessary = () => {
this.atBottom && this.sendToBottom();
};
const observer = new ResizeObserver(this.sendToBottomIfNecessary);
observer.observe(this.firstNode.querySelector('.js-scroll-thread ul'));
this.onFile = (filesToUpload) => {
fileUpload(filesToUpload, this.chatMessages.input, { rid: this.state.get('rid'), tmid: this.state.get('tmid') });
};
this.sendToBottom = _.throttle(() => {
this.chatMessages.wrapper.scrollTop = this.chatMessages.wrapper.scrollHeight;
}, 300);
this.autorun(() => {
const rid = this.state.get('rid');
const tmid = this.state.get('tmid');
if (!rid) {
return;
}
this.callbackRemove && this.callbackRemove();
this.callbackRemove = () => callbacks.remove('streamNewMessage', `thread-${ rid }`);
callbacks.add('streamNewMessage', _.debounce((msg) => {
if (rid !== msg.rid || msg.editedAt || msg.tmid !== tmid) {
return;
}
Meteor.call('readThreads', tmid);
}, 1000), callbacks.priority.MEDIUM, `thread-${ rid }`);
});
this.autorun(() => {
const tmid = this.state.get('tmid');
this.threadsObserve && this.threadsObserve.stop();
this.threadsObserve = Messages.find({ tmid, _hidden: { $ne: true } }, {
this.threadsObserve = Messages.find({ $or: [{ tmid }, { _id: tmid }], _hidden: { $ne: true } }, {
fields: {
collapsed: 0,
threadMsg: 0,
@ -112,14 +192,10 @@ Template.thread.onRendered(function() {
},
}).observe({
added: ({ _id, ...message }) => {
const { atBottom } = this;
this.Threads.upsert({ _id }, message);
atBottom && this.sendToBottom();
},
changed: ({ _id, ...message }) => {
const { atBottom } = this;
this.Threads.update({ _id }, message);
atBottom && this.sendToBottom();
},
removed: ({ _id }) => this.Threads.remove(_id),
});
@ -138,7 +214,9 @@ Template.thread.onRendered(function() {
this.autorun(() => {
const { mainMessage, jump } = Template.currentData();
FlowRouter.watchPathChange();
const jump = FlowRouter.getQueryParam('jump');
const { mainMessage } = Template.currentData();
this.state.set({
tmid: mainMessage._id,
rid: mainMessage.rid,
@ -175,15 +253,15 @@ Template.thread.onCreated(async function() {
this.Threads = new Mongo.Collection(null);
this.state = new ReactiveDict({
sendToChannel: !this.data.mainMessage.tcount,
});
this.loadMore = _.debounce(async () => {
if (this.state.get('loading') === true) {
this.loadMore = async () => {
const { tmid } = Tracker.nonreactive(() => this.state.all());
if (!tmid) {
return;
}
const { tmid } = Tracker.nonreactive(() => this.state.all());
this.state.set('loading', true);
const messages = await call('getThreadMessages', { tmid });
@ -193,11 +271,13 @@ Template.thread.onCreated(async function() {
Tracker.afterFlush(() => {
this.state.set('loading', false);
});
}, 500);
};
});
Template.thread.onDestroyed(function() {
const { Threads, threadsObserve } = this;
const { Threads, threadsObserve, callbackRemove } = this;
Threads.remove({});
threadsObserve && threadsObserve.stop();
callbackRemove && callbackRemove();
});

@ -8,6 +8,7 @@ Meteor.startup(function() {
return TabBar.addButton({
groups: ['channel', 'group', 'direct'],
id: 'thread',
full: true,
i18nTitle: 'Threads',
icon: 'thread',
template: 'threads',

@ -1,40 +1,3 @@
<template name="threads">
{{# with messageContext}}
{{#if hasNoThreads}}
<h2 class="thread-empty">{{_ "No_Threads"}}</h2>
{{/if}}
{{#unless doDotLoadThreads}}
<div class="thread-list js-scroll-threads">
<ul class="thread">
{{#each thread in threads}}
{{> message
groupable=false
msg=thread
room=room
hideRoles=true
subscription=subscription
customClass="thread-message"
settings=settings
templatePrefix='threads-'
u=u
showDateSeparator=false
context="threads"
timeAgo=true
ignored=false
}}
{{/each}}
</ul>
</div>
{{#if isLoading}}
<div class="load-more">
{{> loading}}
</div>
{{/if}}
{{/unless}}
{{#if msg}}
<div class="rc-user-info-container flex-nav">
{{> thread mainMessage=msg room=room subscription=subscription settings=settings close=close jump=jump }}
</div>
{{/if}}
{{/with}}
{{> ThreadsList threads=threads rid=rid onClose=close }}
</template>

@ -1,175 +1,24 @@
import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import { Template } from 'meteor/templating';
import { ReactiveDict } from 'meteor/reactive-dict';
import _ from 'underscore';
import { call } from '../../../ui-utils';
import { Messages, Subscriptions } from '../../../models';
import { messageContext } from '../../../ui-utils/client/lib/messageContext';
import { messageArgs } from '../../../ui-utils/client/lib/messageArgs';
import { getConfig } from '../../../ui-utils/client/config';
import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager';
import { Template } from 'meteor/templating';
import { HTML } from 'meteor/htmljs';
import './threads.html';
import '../threads.css';
import { createTemplateForComponent } from '../../../../client/reactAdapters';
const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 50;
const sort = { tlm: -1 };
Template.threads.events({
'click .js-open-thread'(e, instance) {
const { msg, jump } = messageArgs(this);
instance.state.set('mid', msg._id);
instance.state.set('jump', jump);
e.preventDefault();
e.stopPropagation();
return false;
},
'scroll .js-scroll-threads': _.throttle(({ currentTarget: e }, { incLimit }) => {
if (e.offsetHeight + e.scrollTop >= e.scrollHeight - 50) {
incLimit && incLimit();
}
}, 500),
createTemplateForComponent('ThreadsList', () => import('../components/ThreadList'), {
renderContainerView: () => HTML.DIV({ class: 'contextual-bar' }), // eslint-disable-line new-cap
});
Template.threads.helpers({
jump() {
return Template.instance().state.get('jump');
},
subscription() {
return Template.currentData().subscription;
},
doDotLoadThreads() {
return Template.instance().state.get('close');
rid() {
const { rid } = Template.instance().data;
return rid;
},
close() {
const { state, data } = Template.instance();
const { data } = Template.instance();
const { tabBar } = data;
return () => (state.get('close') ? tabBar.close() : state.set('mid', null));
},
msg() {
return Template.instance().state.get('thread');
},
isLoading() {
return Template.instance().state.get('loading');
return () => tabBar.close();
},
hasNoThreads() {
return !Template.instance().state.get('loading') && Template.instance().Threads.find({ rid: Template.instance().state.get('rid') }, { sort }).count() === 0;
},
threads() {
return Template.instance().Threads.find({ rid: Template.instance().state.get('rid') }, { sort, limit: Template.instance().state.get('limit') });
},
messageContext,
});
Template.threads.onCreated(async function() {
this.Threads = new Mongo.Collection(null);
const { rid, mid, msg } = this.data;
this.state = new ReactiveDict({
rid,
close: !!mid,
loading: true,
mid,
thread: msg,
});
this.rid = rid;
this.incLimit = () => {
const { rid, limit } = Tracker.nonreactive(() => this.state.all());
const count = this.Threads.find({ rid }).count();
if (limit > count) {
return;
}
this.state.set('limit', this.state.get('limit') + LIST_SIZE);
this.loadMore();
};
this.loadMore = _.debounce(async () => {
const { rid, limit } = Tracker.nonreactive(() => this.state.all());
if (this.state.get('loading') === rid) {
return;
}
this.state.set('loading', rid);
const messages = await call('getThreadsList', { rid, limit: LIST_SIZE, skip: limit - LIST_SIZE });
upsertMessageBulk({ msgs: messages }, this.Threads);
// threads.forEach(({ _id, ...msg }) => this.Threads.upsert({ _id }, msg));
this.state.set('loading', false);
}, 500);
Tracker.afterFlush(() => {
this.autorun(async () => {
const { rid, mid, jump } = Template.currentData();
this.state.set({
close: !!mid,
mid,
rid,
jump,
});
});
});
this.autorun(() => {
if (mid) {
return;
}
const rid = this.state.get('rid');
this.rid = rid;
this.state.set({
limit: LIST_SIZE,
});
this.loadMore();
});
this.autorun(() => {
const rid = this.state.get('rid');
this.threadsObserve && this.threadsObserve.stop();
this.threadsObserve = Messages.find({ rid, tcount: { $exists: true }, _hidden: { $ne: true } }).observe({
added: ({ _id, ...message }) => {
this.Threads.upsert({ _id }, message);
}, // Update message to re-render DOM
changed: ({ _id, ...message }) => {
this.Threads.update({ _id }, message);
}, // Update message to re-render DOM
removed: ({ _id }) => {
this.Threads.remove(_id);
const mid = this.state.get('mid');
if (_id === mid) {
this.state.set('mid', null);
}
},
});
const alert = 'Unread';
this.subscriptionObserve && this.subscriptionObserve.stop();
this.subscriptionObserve = Subscriptions.find({ rid }, { fields: { tunread: 1 } }).observeChanges({
added: (_id, { tunread }) => {
tunread && tunread.length && this.Threads.update({ tmid: { $in: tunread } }, { $set: { alert } }, { multi: true });
},
changed: (id, { tunread = [] }) => {
this.Threads.update({ alert, _id: { $nin: tunread } }, { $unset: { alert: 1 } }, { multi: true });
tunread && tunread.length && this.Threads.update({ _id: { $in: tunread } }, { $set: { alert } }, { multi: true });
},
});
});
this.autorun(async () => {
const mid = this.state.get('mid');
return this.state.set('thread', mid && (Messages.findOne({ _id: mid }, { fields: { tcount: 0, tlm: 0, replies: 0, _updatedAt: 0 } }) || this.Threads.findOne({ _id: mid }, { fields: { tcount: 0, tlm: 0, replies: 0, _updatedAt: 0 } })));
});
});
Template.threads.onDestroyed(function() {
const { Threads, threadsObserve, subscriptionObserve } = this;
Threads.remove({});
threadsObserve && threadsObserve.stop();
subscriptionObserve && subscriptionObserve.stop();
});

@ -1,7 +1,6 @@
import './flextab/threadlist';
import './flextab/thread';
import './flextab/threads';
import './threads.css';
import './messageAction/follow';
import './messageAction/unfollow';
import './messageAction/replyInThread';

@ -0,0 +1,42 @@
import { Meteor } from 'meteor/meteor';
import s from 'underscore.string';
import { filterMarkdown } from '../../../markdown/lib/markdown';
import { Users } from '../../../models/client';
import { settings } from '../../../settings/client';
import { MentionsParser } from '../../../mentions/lib/MentionsParser';
export const normalizeThreadTitle = ({ ...message }) => {
if (message.msg) {
const filteredMessage = filterMarkdown(message.msg);
if (!message.channels && !message.mentions) {
return filteredMessage;
}
const uid = Meteor.userId();
const me = uid && (Users.findOne(uid, { fields: { username: 1 } }) || {}).username;
const pattern = settings.get('UTF8_Names_Validation');
const useRealName = settings.get('UI_Use_Real_Name');
const instance = new MentionsParser({
pattern: () => pattern,
useRealName: () => useRealName,
me: () => me,
userTemplate: ({ label }) => `<strong> ${ label } </strong>`,
roomTemplate: ({ channel }) => `<strong> ${ channel } </strong>`,
});
return instance.parse({ ...message, msg: filteredMessage, html: filteredMessage }).html;
}
if (message.attachments) {
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
if (attachment && attachment.description) {
return s.escapeHTML(attachment.description);
}
if (attachment && attachment.title) {
return s.escapeHTML(attachment.title);
}
}
};

@ -1,11 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { settings } from '../../../settings/client';
import { MessageAction } from '../../../ui-utils/client';
import { messageArgs } from '../../../ui-utils/client/lib/messageArgs';
import { chatMessages } from '../../../ui/client';
import { addMessageToList } from '../../../ui-utils/client/lib/MessageAction';
Meteor.startup(function() {
Tracker.autorun(() => {
@ -19,16 +18,11 @@ Meteor.startup(function() {
context: ['message', 'message-mobile'],
action() {
const { msg: message } = messageArgs(this);
const { input } = chatMessages[message.rid];
const $input = $(input);
const messages = addMessageToList($input.data('reply') || [], message, input);
$(input)
.focus()
.data('mention-user', true)
.data('reply', messages)
.trigger('dataChange');
FlowRouter.setParams({
tab: 'thread',
context: message.tmid || message._id,
});
},
condition({ subscription }) {
return Boolean(subscription);

@ -1,8 +1,6 @@
.message.thread-main {
padding-top: var(--default-padding);
padding-bottom: var(--default-padding);
border-bottom: 1px solid var(--color-gray-light);
}
.message.sequential[data-tmid] > .thread-replied > .thumb,
@ -28,12 +26,37 @@
}
.thread-list {
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
word-wrap: break-word;
flex-grow: 1;
flex-shrink: 1;
}
.thread-footer {
display: flex;
padding: 0 1.5rem 1rem;
align-items: center;
}
@media (width < 500px) {
.thread-footer {
padding-right: var(--default-small-padding);
padding-left: var(--default-small-padding);
}
}
.thread-footer__text {
color: var(--color-gray);
font-size: 12px;
margin-inline-start: 0.5rem;
}
.message {
& .thread-replied {
@ -59,7 +82,6 @@
width: 20px;
&--thread {
position: absolute;
left: 40px;
width: 20px;
@ -78,11 +100,13 @@
}
}
& > .thread-quote {
& .thread-quote {
display: flex;
margin: calc((var(--default-padding) /2) - 2px) 0 2px 0;
margin: 0 -24px 8px;
font-size: 12px;
align-items: center;
}
@ -92,6 +116,8 @@
overflow: hidden;
margin: 0 0.25rem;
cursor: pointer;
white-space: nowrap;
@ -101,7 +127,7 @@
align-items: center;
> .message-body--unstyled {
& > .message-body--unstyled {
overflow: hidden;
text-overflow: ellipsis;
@ -109,11 +135,12 @@
}
& .thread-reply-preview {
display: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
}
&.collapsed .thread-reply-preview {
@ -130,7 +157,7 @@
margin-top: calc(var(--default-padding) / 2);
}
.message.collapsed > .thread-replied > .thumb {
.message > .thread-replied > .thumb {
bottom: 0;
left: 40px;
@ -140,9 +167,9 @@
margin-bottom: auto;
margin-left: 0;
& .avatar {
width: 20px;
height: 20px;
& > .avatar {
width: 20px !important;
height: 20px !important;
}
}

@ -1,11 +1,21 @@
import { Messages, Subscriptions } from '../../models/server';
import { getMentions } from '../../lib/server/lib/notifyUsersOnMessage';
export const reply = ({ tmid }, { rid, ts, u, editedAt }, parentMessage) => {
export const reply = ({ tmid }, message, parentMessage, followers) => {
const { rid, ts, u, editedAt } = message;
if (!tmid || editedAt) {
return false;
}
const addToReplies = Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id];
const { mentionIds } = getMentions(message);
const addToReplies = [
...new Set([
...followers,
...mentionIds,
...Array.isArray(parentMessage.replies) && parentMessage.replies.length ? [u._id] : [parentMessage.u._id, u._id],
]),
];
Messages.updateRepliesByThreadId(tmid, addToReplies, ts);

@ -1,19 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../callbacks/server';
import { settings } from '../../../settings';
import { readAllThreads } from '../functions';
const readThreads = (rid, { userId }) => {
readAllThreads(rid, userId);
};
Meteor.startup(function() {
settings.get('Threads_enabled', function(key, value) {
if (!value) {
callbacks.remove('afterReadMessages', 'threads-after-read-messages');
return;
}
callbacks.add('afterReadMessages', readThreads, callbacks.priority.LOW, 'threads-after-read-messages');
});
});

@ -1,30 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../callbacks/server';
import { settings } from '../../../settings/server';
import { Messages } from '../../../models/server';
import { undoReply } from '../functions';
Meteor.startup(function() {
const fn = function(message) {
// is a reply from a thread
if (message.tmid) {
undoReply(message);
}
// is a thread
if (message.tcount) {
Messages.removeThreadRefByThreadId(message._id);
}
return message;
};
settings.get('Threads_enabled', function(key, value) {
if (!value) {
callbacks.remove('afterDeleteMessage', 'threads-after-delete-message');
return;
}
callbacks.add('afterDeleteMessage', fn, callbacks.priority.LOW, 'threads-after-delete-message');
});
});

@ -4,7 +4,7 @@ import { Messages } from '../../../models/server';
import { callbacks } from '../../../callbacks/server';
import { settings } from '../../../settings/server';
import { reply } from '../functions';
import { updateUsersSubscriptions } from '../../../lib/server/lib/notifyUsersOnMessage';
import { updateThreadUsersSubscriptions, getMentions } from '../../../lib/server/lib/notifyUsersOnMessage';
import { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage';
function notifyUsersOnReply(message, replies, room) {
@ -13,13 +13,13 @@ function notifyUsersOnReply(message, replies, room) {
return message;
}
updateUsersSubscriptions(message, room, replies);
updateThreadUsersSubscriptions(message, room, replies);
return message;
}
const metaData = (message, parentMessage) => {
reply({ tmid: message.tmid }, message, parentMessage);
const metaData = (message, parentMessage, followers) => {
reply({ tmid: message.tmid }, message, parentMessage, followers);
return message;
};
@ -36,7 +36,7 @@ const notification = (message, room, replies) => {
return message;
};
const processThreads = (message, room) => {
export const processThreads = (message, room) => {
if (!message.tmid) {
return message;
}
@ -46,12 +46,18 @@ const processThreads = (message, room) => {
return message;
}
const { mentionIds } = getMentions(message);
const replies = [
...parentMessage.replies || [],
...new Set([
...(!parentMessage.tcount ? [parentMessage.u._id] : parentMessage.replies) || [],
...!parentMessage.tcount && room.t === 'd' ? room.uids : [],
...mentionIds,
]),
].filter((userId) => userId !== message.u._id);
notifyUsersOnReply(message, replies, room);
metaData(message, parentMessage);
metaData(message, parentMessage, replies);
notification(message, room, replies);
return message;

@ -1,3 +1 @@
import './afterdeletemessage';
import './afterReadMessages';
import './aftersavemessage';

@ -156,6 +156,13 @@
<label><input type="radio" name="unreadAlert" value="false" checked="{{checked 'unreadAlert' false}}"/> {{_ "Off"}}</label>
</div>
</div>
<div class="input-line double-col" id="showMessageInMainThread">
<label class="setting-label">{{_ "Show_Message_In_Main_Thread"}}</label>
<div class="setting-field">
<label><input type="radio" name="showMessageInMainThread" value="true" checked="{{checked 'showMessageInMainThread' true}}" /> {{_ "True"}}</label>
<label><input type="radio" name="showMessageInMainThread" value="false" checked="{{checked 'showMessageInMainThread' false}}" /> {{_ "False"}}</label>
</div>
</div>
<div class="input-line double-col" id="clockMode">
<label class="setting-label">{{_ "Message_TimeFormat"}}</label>
<div class="setting-field">

@ -165,6 +165,7 @@ Template.accountPreferences.onCreated(function() {
data.muteFocusedConversations = JSON.parse($('#muteFocusedConversations').find('input:checked').val());
data.hideUsernames = JSON.parse($('#hideUsernames').find('input:checked').val());
data.messageViewMode = parseInt($('#messageViewMode').find('select').val());
data.showMessageInMainThread = JSON.parse($('#showMessageInMainThread').find('input:checked').val());
data.hideFlexTab = JSON.parse($('#hideFlexTab').find('input:checked').val());
data.hideAvatars = JSON.parse($('#hideAvatars').find('input:checked').val());
data.sidebarHideAvatar = JSON.parse($('#sidebarHideAvatar').find('input:checked').val());

@ -129,7 +129,7 @@ export class CachedCollection extends EventEmitter {
userRelated = true,
listenChangesForLoggedUsersOnly = false,
useSync = true,
version = 11,
version = 12,
maxCacheTime = 60 * 60 * 24 * 30,
onSyncData = (/* action, record */) => {},
}) {

@ -71,6 +71,11 @@
{{> icon icon="check" block="rc-checkbox__icon"}}
<span class="rc-checkbox__text rc-text__small">{{_ "RetentionPolicy_DoNotExcludeDiscussion"}}</span>
</label>
<label class="rc-checkbox">
<input type="checkbox" name="ignoreThreads" class="rc-checkbox__input">
{{> icon icon="check" block="rc-checkbox__icon"}}
<span class="rc-checkbox__text rc-text__small">{{_ "RetentionPolicy_DoNotExcludeThreads"}}</span>
</label>
<label class="rc-checkbox">
<input type="checkbox" name="filesOnly" class="rc-checkbox__input">
{{> icon icon="check" block="rc-checkbox__icon"}}

@ -23,7 +23,7 @@ const getRoomName = function() {
return t('conversation_with_s', roomTypes.getRoomName(room.t, room));
};
const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers) {
const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePinned, ignoreDiscussion, filesOnly, fromUsers, ignoreThreads) {
return call('cleanRoomHistory', {
roomId,
latest,
@ -34,6 +34,7 @@ const purgeWorker = function(roomId, oldest, latest, inclusive, limit, excludePi
ignoreDiscussion,
filesOnly,
fromUsers,
ignoreThreads,
});
};
@ -121,7 +122,7 @@ Template.cleanHistory.onCreated(function() {
this.cleanHistoryFilesOnly = new ReactiveVar(false);
this.ignoreDiscussion = new ReactiveVar(false);
this.ignoreThreads = new ReactiveVar(false);
this.cleanHistoryBusy = new ReactiveVar(false);
this.cleanHistoryFinished = new ReactiveVar(false);
@ -257,6 +258,9 @@ Template.cleanHistory.events({
'change [name=ignoreDiscussion]'(e, instance) {
instance.ignoreDiscussion.set(e.target.checked);
},
'change [name=ignoreThreads]'(e, instance) {
instance.ignoreThreads.set(e.target.checked);
},
'click .js-prune'(e, instance) {
modal.open({
title: t('Are_you_sure'),
@ -279,6 +283,7 @@ Template.cleanHistory.events({
const metaCleanHistoryExcludePinned = instance.cleanHistoryExcludePinned.get();
const metaCleanHistoryFilesOnly = instance.cleanHistoryFilesOnly.get();
const ignoreDiscussion = instance.ignoreDiscussion.get();
const ignoreThreads = instance.ignoreThreads.get();
let fromDate = new Date('0001-01-01T00:00:00Z');
let toDate = new Date('9999-12-31T23:59:59Z');
@ -297,7 +302,7 @@ Template.cleanHistory.events({
let count = 0;
let result;
do {
result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, ignoreDiscussion, metaCleanHistoryFilesOnly, users); // eslint-disable-line no-await-in-loop
result = await purgeWorker(roomId, fromDate, toDate, metaCleanHistoryInclusive, limit, metaCleanHistoryExcludePinned, ignoreDiscussion, metaCleanHistoryFilesOnly, users, ignoreThreads); // eslint-disable-line no-await-in-loop
count += result;
} while (result === limit);

@ -81,7 +81,6 @@ Template.flexTabBar.helpers({
template() {
return Template.instance().tabBar.getTemplate();
},
flexData() {
return Object.assign(Template.currentData().data || {}, {
tabBar: Template.instance().tabBar,
@ -110,6 +109,7 @@ const commonEvents = {
$flexTab.attr('template', this.template);
t.tabBar.setData({
...this,
label: this.i18nTitle,
icon: this.icon,
});

@ -1,8 +1,17 @@
<template name="message">
<li id="{{templatePrefix}}{{msg._id}}" data-id="{{msg._id}}" data-context={{actionContext}} class="message {{ignoredClass}} {{system}} {{t}} {{own}} {{isTemp}} {{chatops}} {{collapsed}} {{customClass}}" data-username="{{msg.u.username}}" data-tmid="{{msg.tmid}}" data-groupable="{{isGroupable}}" data-date="{{date}}" data-timestamp="{{timestamp}}" data-alias="{{msg.alias}}">
{{#if isThreadReply}}
{{> messageThread parentMessage=parentMessage threadMessage=threadMessage following=msg.following avatar=msg.u.username msg=msg body=body class=bodyClass}}
{{else}}
{{> messageThread parentMessage=parentMessage msg=msg tmid=msg.tmid class=bodyClass following=msg.following}}
<div class="thread-replied js-open-thread">
<button aria-label="{{msg.u.username}}" class="thumb user-card-message" data-username="{{msg.u.username}}">
{{> avatar username=msg.u.username}}
</button>
<span role="button" class="js-toggle-thread-reply thread-reply-preview color-primary-font-color">
<span class="message-body--unstyled">{{{threadMessage}}}</span>
</span>
</div>
{{/if}}
{{#unless shouldHideBody}}
{{#if msg.avatar}}
{{#if avatarFromUsername}}
<button aria-label="{{msg.u.username}}" class="thumb user-card-message" data-username="{{msg.u.username}}">
@ -37,9 +46,11 @@
<button class="message-custom-status rc-tooltip rc-tooltip--up rc-tooltip--start" aria-label="{{getStatus}}">💬</button>
{{/if}}
<span class="info border-component-color color-info-font-color"></span>
{{#each role in roleTags}}
<span class="role-tag color-secondary-color border-component-color" data-role="{{role.description}}">{{role.description}}</span>
{{/each}}
{{# if showRoles }}
{{#each role in roleTags}}
<span class="role-tag color-secondary-color border-component-color" data-role="{{role.description}}">{{role.description}}</span>
{{/each}}
{{/if}}
{{#if isBot}}
<span class="is-bot color-secondary-color border-component-color">{{_ "Bot"}}</span>
{{/if}}
@ -90,11 +101,7 @@
{{> Blocks blocks=msg.blocks rid=msg.rid mid=msg._id}}
</div>
{{else}}
{{#if isDecrypting}}
<span>******</span>
{{else}}
{{{body}}}
{{/if}}
{{{body}}}
{{/if}}
</div>
</div>
@ -127,6 +134,9 @@
</button>
{{/if}}
<span class="discussion-reply-lm">{{ formatDateAndTime msg.dlm }}</span>
{{# if unread }}
<div aria-label="{{_ 'Unread' }}" class="rc-tooltip message-unread"></div>
{{/if}}
</div>
{{/if}}
@ -137,6 +147,9 @@
<span>{{{_ 'reply_counter' counter=i18nReplyCounter count=msg.tcount }}}</span>
</button>
<span class="discussion-reply-lm">{{ formatDateAndTime msg.tlm}}</span>
{{# if unread }}
<div aria-label="{{_ 'Unread' }}" class="rc-tooltip message-unread"></div>
{{/if}}
</div>
{{/if}}
@ -179,9 +192,11 @@
</ul>
{{/unless}}
{{#if broadcast}}
<button type="button" class="js-reply-broadcast rc-button rc-button--primary rc-button--small rc-button--outline" name="button">
{{> icon icon="reply"}} {{_'Reply'}}
</button>
{{#with msg}}
<button type="button" class="js-reply-broadcast rc-button rc-button--primary rc-button--small rc-button--outline" name="button">
{{> icon icon="reply"}} {{_'Reply'}}
</button>
{{/with}}
{{/if}}
{{#unless hideReactions}}
<ul class="reactions">
@ -199,6 +214,6 @@
</li>
</ul>
{{/unless}}
{{/if}}
{{/unless}}
</li>
</template>

@ -15,9 +15,10 @@ import { Markdown } from '../../markdown/client';
import { t, roomTypes } from '../../utils';
import { upsertMessage } from '../../ui-utils/client/lib/RoomHistoryManager';
import './message.html';
import './messageThread.html';
import './messageThread';
import { AutoTranslate } from '../../autotranslate/client';
const renderBody = (msg, settings) => {
const searchedText = msg.searchedText ? msg.searchedText : '';
const isSystemMessage = MessageTypes.isSystemMessage(msg);
@ -49,6 +50,39 @@ const renderBody = (msg, settings) => {
return msg;
};
const findParentMessage = (() => {
const waiting = [];
const uid = Tracker.nonreactive(() => Meteor.userId());
const getMessages = _.debounce(async function() {
const _tmp = [...waiting];
waiting.length = 0;
(await call('getMessages', _tmp)).map((msg) => Messages.findOne({ _id: msg._id }) || upsertMessage({ msg: { ...msg, _hidden: true }, uid }));
}, 500);
return (tmid) => {
if (waiting.indexOf(tmid) > -1) {
return;
}
const message = Messages.findOne({ _id: tmid });
if (!message) {
waiting.push(tmid);
return getMessages();
}
return Messages.update(
{ tmid, repliesCount: { $exists: 0 } },
{
$set: {
following: message.replies && message.replies.indexOf(uid) > -1,
threadMsg: normalizeThreadMessage(message),
repliesCount: message.tcount,
},
},
{ multi: true },
);
};
})();
Template.message.helpers({
body() {
const { msg, settings } = this;
@ -200,6 +234,14 @@ Template.message.helpers({
return 'system';
}
},
unread() {
const { msg, subscription } = this;
if (!subscription) {
return false;
}
return subscription.tunread?.includes(msg._id);
},
showTranslated() {
const { msg, subscription, settings, u } = this;
if (settings.AutoTranslate_Enabled && msg.u && msg.u._id !== u._id && !MessageTypes.isSystemMessage(msg)) {
@ -373,6 +415,10 @@ Template.message.helpers({
const { groupable, msg: { tmid, t, groupable: _groupable }, settings: { showreply } } = this;
return !(groupable === true || _groupable === true) && !!(tmid && showreply && (!t || t === 'e2e'));
},
shouldHideBody() {
const { msg: { tmid }, settings: { showreply } } = this;
return showreply && tmid;
},
collapsed() {
const { msg: { tmid, collapsed }, settings: { showreply }, shouldCollapseReplies } = this;
const isCollapsedThreadReply = shouldCollapseReplies && tmid && showreply && collapsed !== false;
@ -395,39 +441,6 @@ Template.message.helpers({
});
const findParentMessage = (() => {
const waiting = [];
const uid = Tracker.nonreactive(() => Meteor.userId());
const getMessages = _.debounce(async function() {
const _tmp = [...waiting];
waiting.length = 0;
(await call('getMessages', _tmp)).map((msg) => Messages.findOne({ _id: msg._id }) || upsertMessage({ msg: { ...msg, _hidden: true }, uid }));
}, 500);
return (tmid) => {
if (waiting.indexOf(tmid) > -1) {
return;
}
const message = Messages.findOne({ _id: tmid });
if (!message) {
waiting.push(tmid);
return getMessages();
}
return Messages.update(
{ tmid, repliesCount: { $exists: 0 } },
{
$set: {
following: message.replies && message.replies.indexOf(uid) > -1,
threadMsg: normalizeThreadMessage(message),
repliesCount: message.tcount,
},
},
{ multi: true },
);
};
})();
Template.message.onCreated(function() {
const { msg, shouldCollapseReplies } = Template.currentData();
if (shouldCollapseReplies && msg.tmid && !msg.threadMsg) {
@ -489,7 +502,7 @@ const isSequential = (currentNode, previousNode, forceDate, period, showDateSepa
return false;
}
if (shouldCollapseReplies && currentDataset.tmid) {
if (!shouldCollapseReplies && currentDataset.tmid) {
return previousDataset.id === currentDataset.tmid || previousDataset.tmid === currentDataset.tmid;
}

@ -30,19 +30,24 @@
{{> icon block="rc-input__icon-svg" icon="send"}}
</span>
{{else}}
{{#if canSend}}
{{> messageBoxAudioMessage rid=rid tmid=tmid}}
<span class="rc-message-box__action-menu js-action-menu" data-desktop aria-haspopup="true">
{{#if actions}}
<span class="rc-message-box__icon">
{{> icon block="rc-input__icon-svg" icon="plus"}}
</span>
{{/if}}
</span>
{{else}}
<button class="js-join rc-button rc-button--primary rc-message-box__join-button">
{{_ "join"}}
</button>
{{# if customAction }}
{{> Template.dynamic template=customAction.template data=customAction.data }}
{{ else }}
{{#if canSend}}
{{> messageBoxAudioMessage rid=rid tmid=tmid}}
<span class="rc-message-box__action-menu js-action-menu" data-desktop aria-haspopup="true">
{{#if actions}}
<span class="rc-message-box__icon">
{{> icon block="rc-input__icon-svg" icon="plus"}}
</span>
{{/if}}
</span>
{{else}}
<button class="js-join rc-button rc-button--primary rc-message-box__join-button">
{{_ "join"}}
</button>
{{/if}}
{{/if}}
{{/if}}

@ -90,7 +90,7 @@ Template.messageBox.onCreated(function() {
return;
}
const { autogrow, data: { rid, tmid, onSend } } = this;
const { autogrow, data: { rid, tmid, onSend, tshow } } = this;
const { value } = input;
this.set('');
@ -98,7 +98,7 @@ Template.messageBox.onCreated(function() {
return;
}
onSend.call(this.data, event, { rid, tmid, value }, () => {
onSend.call(this.data, event, { rid, tmid, value, tshow }, () => {
autogrow.update();
input.focus();
});

@ -1,7 +1,6 @@
<template name="messageThread">
<q role="button" aria-label="{{_ " Open_thread "}}" class="thread-quote">
<div class="thread-quote__message js-open-thread"><span class="message-body--unstyled">{{{parentMessage}}}</span> {{> icon icon="thread" block="thread-icons"}}</div>
<q role="button" aria-label="{{_ " Open_thread "}}" class="thread-quote js-open-thread">
{{> icon icon="thread" block="thread-icons"}} <div class="thread-quote__message "><span class="message-body--unstyled">{{{parentMessage}}}</span></div>
{{# if following }}
<div role="button" class="rc-tooltip js-unfollow-thread" aria-label="{{_ "Following"}}">
{{> icon icon="bell" block="thread-icons"}}
@ -12,17 +11,4 @@
</div>
{{/if}}
</q>
<div class="thread-replied js-open-thread">
<button aria-label="{{msg.u.username}}" class="thumb user-card-message" data-username="{{msg.u.username}}">
{{> avatar username=avatar}}
</button>
<span role="button" class="js-toggle-thread-reply thread-reply-preview color-primary-font-color">
{{# if threadMessage}}
<span class="message-body--unstyled">{{{threadMessage}}}</span>
{{else}}
<div class="{{class}}" dir="auto">{{{body}}}</div>
{{/if}}
</span>
</div>
</template>

@ -0,0 +1,61 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import _ from 'underscore';
import { call, normalizeThreadMessage } from '../../ui-utils/client';
import { Messages } from '../../models/client';
import './messageThread.html';
const findParentMessage = (() => {
const waiting = [];
let resolve;
let pending = new Promise((r) => { resolve = r; });
const getMessages = _.debounce(async function() {
const _tmp = [...waiting];
waiting.length = 0;
resolve(call('getMessages', _tmp));
pending = new Promise((r) => { resolve = r; });
}, 500);
const get = async (tmid) => {
getMessages();
const messages = await pending;
return normalizeThreadMessage(messages.find(({ _id }) => _id === tmid));
};
return async (tmid) => {
const message = Messages.findOne({ _id: tmid });
if (message) {
return normalizeThreadMessage(message);
}
if (waiting.indexOf(tmid) === -1) {
waiting.push(tmid);
}
return get(tmid);
};
})();
Template.messageThread.helpers({
parentMessage() {
const { parentMessage } = Template.instance();
if (parentMessage) {
return parentMessage.get();
}
},
});
Template.messageThread.onCreated(function() {
this.parentMessage = new ReactiveVar();
this.autorun(async () => {
const { parentMessage, tmid } = Template.currentData();
if (parentMessage) {
return this.parentMessage.set(parentMessage);
}
this.parentMessage.set(await findParentMessage(tmid));
});
});

@ -142,9 +142,16 @@ const getLowerCaseNames = (room, nameDefault = '', fnameDefault = '') => {
};
const mergeSubRoom = (subscription) => {
const room = Rooms.findOne(subscription.rid) || { _updatedAt: subscription.ts };
const options = {
fields: {
lm: 1,
lastMessage: 1,
streamingOptions: 1,
},
};
const room = Rooms.findOne({ _id: subscription.rid }, options) || { lm: subscription.ts };
subscription.lastMessage = room.lastMessage;
subscription.lm = room._updatedAt;
subscription.lm = subscription.lr ? new Date(Math.max(subscription.lr, room.lm)) : room.lm;
subscription.streamingOptions = room.streamingOptions;
return Object.assign(subscription, getLowerCaseNames(subscription));
};
@ -160,12 +167,20 @@ const mergeRoomSub = (room) => {
}, {
$set: {
lastMessage: room.lastMessage,
lm: room._updatedAt,
streamingOptions: room.streamingOptions,
...getLowerCaseNames(room, sub.name, sub.fname),
},
});
Subscriptions.update({
rid: room._id,
lm: { $lt: room.lm },
}, {
$set: {
lm: room.lm,
},
});
return room;
};

@ -36,25 +36,28 @@ Template.sidebarItem.helpers({
showUnread() {
return this.unread > 0 || (!this.hideUnreadStatus && this.alert);
},
unread() {
const { unread = 0, tunread = [] } = this;
return unread + tunread.length;
},
badgeClass() {
const { t, unread, userMentions, groupMentions } = this;
const { unread, userMentions, groupMentions, tunread = [] } = this;
const badges = ['badge'];
if (userMentions) {
return 'badge badge--user-mentions';
}
if (unread) {
badges.push('badge--unread');
if (t === 'd') {
badges.push('badge--dm');
}
if (groupMentions) {
return 'badge badge--group-mentions';
}
if (userMentions) {
badges.push('badge--user-mentions');
} else if (groupMentions) {
badges.push('badge--group-mentions');
if (tunread.length) {
return 'badge badge--thread';
}
return badges.join(' ');
if (unread) {
return 'badge';
}
},
});
@ -87,7 +90,7 @@ Template.sidebarItem.onCreated(function() {
return;
}
setLastMessageTs(this, currentData.lastMessage.ts);
setLastMessageTs(this, currentData.lm || currentData.lastMessage.ts);
if (currentData.lastMessage.t === 'e2e' && currentData.lastMessage.e2e !== 'done') {
this.renderedMessage = '******';

@ -149,15 +149,9 @@ Meteor.startup(async function() {
const { chatMessages } = await import('../../../ui');
const getChatMessagesFrom = (msg) => {
const { rid, tmid } = msg;
const { rid = Session.get('openedRoom'), tmid = msg._id } = msg;
if (rid) {
if (tmid) {
return chatMessages[`${ rid }-${ tmid }`];
}
return chatMessages[rid];
}
return chatMessages[Session.get('openedRoom')];
return chatMessages[`${ rid }-${ tmid }`] || chatMessages[rid];
};
MessageAction.addButton({
@ -192,7 +186,7 @@ Meteor.startup(async function() {
context: ['message', 'message-mobile', 'threads'],
action() {
const { msg: message } = messageArgs(this);
const { input } = chatMessages[message.rid + (message.tmid ? `-${ message.tmid }` : '')];
const { input } = getChatMessagesFrom(message);
const $input = $(input);
let messages = $input.data('reply') || [];
@ -262,7 +256,8 @@ Meteor.startup(async function() {
icon: 'edit',
label: 'Edit',
context: ['message', 'message-mobile', 'threads'],
action() {
action(event) {
console.log(event);
const { msg } = messageArgs(this);
getChatMessagesFrom(msg).edit(document.getElementById(msg.tmid ? `thread-${ msg._id }` : msg._id));
},

@ -1,5 +1,6 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { TabBar } from './TabBar';
@ -58,12 +59,16 @@ export class RocketChatTabBar {
$('.contextual-bar__container').scrollTop(0).find('input[type=text]:first').focus();
});
const current = FlowRouter.current();
FlowRouter.go(current.route.name, { ...current.params, tab: null, context: null });
if (!button) {
return;
}
if (typeof button !== 'object' || !button.id) {
button = TabBar.getButton(button);
}
$('.flex-tab, .contextual-bar').css('width', button.width ? `${ button.width }px` : '');
this.template.set(button.template);
this.id.set(button.id);

@ -11,9 +11,12 @@ import { renderMessageBody } from './renderMessageBody';
import { getConfig } from '../config';
import { ChatMessage, ChatSubscription, ChatRoom } from '../../../models';
import { call } from './callMethod';
import { filterMarkdown } from '../../../markdown/lib/markdown';
export const normalizeThreadMessage = (message) => {
export const normalizeThreadMessage = ({ ...message }) => {
if (message.msg) {
message.msg = filterMarkdown(message.msg);
delete message.mentions;
return renderMessageBody(message).replace(/<br\s?\\?>/g, ' ');
}

@ -7,7 +7,7 @@ import { settings } from '../../../settings/client';
import { getUserPreference } from '../../../utils/client';
import { AutoTranslate } from '../../../autotranslate/client';
const fields = { name: 1, username: 1, 'settings.preferences.autoImageLoad': 1, 'settings.preferences.saveMobileBandwidth': 1, 'settings.preferences.collapseMediaByDefault': 1, 'settings.preferences.hideRoles': 1 };
const fields = { name: 1, username: 1, 'settings.preferences.showMessageInMainThread': 1, 'settings.preferences.autoImageLoad': 1, 'settings.preferences.saveMobileBandwidth': 1, 'settings.preferences.collapseMediaByDefault': 1, 'settings.preferences.hideRoles': 1 };
export function messageContext({ rid } = Template.instance()) {
const uid = Meteor.userId();
@ -26,10 +26,12 @@ export function messageContext({ rid } = Template.instance()) {
name: 1,
autoTranslate: 1,
rid: 1,
tunread: 1,
},
}),
settings: {
translateLanguage: AutoTranslate.getLanguage(rid),
showMessageInMainThread: getUserPreference(user, 'showMessageInMainThread'),
autoImageLoad: getUserPreference(user, 'autoImageLoad'),
saveMobileBandwidth: Meteor.Device.isPhone() && getUserPreference(user, 'saveMobileBandwidth'),
collapseMediaByDefault: getUserPreference(user, 'collapseMediaByDefault'),

@ -8,7 +8,7 @@ import { Session } from 'meteor/session';
import mem from 'mem';
import _ from 'underscore';
import { ChatSubscription, Rooms } from '../../../models';
import { Messages, ChatSubscription, Rooms } from '../../../models';
import { settings } from '../../../settings';
import { callbacks } from '../../../callbacks';
import { roomTypes } from '../../../utils';
@ -38,7 +38,9 @@ function replaceCenterDomBy(dom) {
for (const child of Array.from(mainNode.children)) {
if (child) { mainNode.removeChild(child); }
}
mainNode.appendChild(dom);
const roomNode = dom();
mainNode.appendChild(roomNode);
return resolve([mainNode, roomNode]);
}
resolve(mainNode);
}, 1);
@ -72,7 +74,7 @@ export const openRoom = async function(type, name) {
if (settings.get('Accounts_AllowAnonymousRead')) {
BlazeLayout.render('main');
}
await replaceCenterDomBy(getDomOfLoading());
await replaceCenterDomBy(() => getDomOfLoading());
return;
}
@ -87,8 +89,7 @@ export const openRoom = async function(type, name) {
return FlowRouter.go('direct', { rid: room._id }, FlowRouter.current().queryParams);
}
const roomDom = RoomManager.getDomOfRoom(type + name, room._id, roomTypes.getConfig(type).mainTemplate);
const mainNode = await replaceCenterDomBy(roomDom);
const [mainNode, roomDom] = await replaceCenterDomBy(() => RoomManager.getDomOfRoom(type + name, room._id, roomTypes.getConfig(type).mainTemplate));
if (mainNode) {
if (roomDom.classList.contains('room-container')) {
@ -110,7 +111,15 @@ export const openRoom = async function(type, name) {
}
if (FlowRouter.getQueryParam('msg')) {
const msg = { _id: FlowRouter.getQueryParam('msg'), rid: room._id };
const messageId = FlowRouter.getQueryParam('msg');
const msg = { _id: messageId, rid: room._id };
const message = Messages.findOne({ ss_id: msg._id }) || (await call('getMessages', [msg._id]))[0];
if (message && (message.tmid || message.tcount)) {
return FlowRouter.setParams({ tab: 'thread', context: message.tmid || message._id });
}
RoomHistoryManager.getSurroundingMessages(msg);
}

@ -1,36 +1,35 @@
import s from 'underscore.string';
import md5 from 'md5';
import { callbacks } from '../../../callbacks';
const generateKeyDefault = (...args) => args.map((item) => JSON.stringify(item)).join('-');
// const generateKeyDefault = (...args) => args.map((item) => JSON.stringify(item)).join('-');
const mem = (fn, tm = 500, generateKey = generateKeyDefault) => {
const cache = {};
const timeout = {};
// const mem = (fn, tm = 500, generateKey = generateKeyDefault) => {
// const cache = {};
// const timeout = {};
const invalidateCache = (key) => delete cache[key];
return (...args) => {
const key = generateKey(...args);
if (!key) {
return fn(...args);
}
if (!cache[key]) {
cache[key] = fn(...args);
}
if (timeout[key]) {
clearTimeout(timeout[key]);
}
timeout[key] = setTimeout(invalidateCache, tm, key);
return cache[key];
};
};
// const invalidateCache = (key) => delete cache[key];
// return (...args) => {
// const key = generateKey(...args);
// if (!key) {
// return fn(...args);
// }
// if (!cache[key]) {
// cache[key] = fn(...args);
// }
// if (timeout[key]) {
// clearTimeout(timeout[key]);
// }
// timeout[key] = setTimeout(invalidateCache, tm, key);
// return cache[key];
// };
// };
export const renderMessageBody = mem((message) => {
export const renderMessageBody = (message) => {
message.html = s.trim(message.msg) ? s.escapeHTML(message.msg) : '';
const { tokens, html } = callbacks.run('renderMessage', message);
return (Array.isArray(tokens) ? tokens.reverse() : [])
.reduce((html, { token, text }) => html.replace(token, () => text), html);
}, 500, (message) => md5(JSON.stringify(message)));
};

@ -1,24 +1,28 @@
<template name="contextualBar">
{{#if template}}
<div class="contextual-bar">
<div class="contextual-bar-wrap">
<header class="contextual-bar__header">
{{#with headerData}}
<div class="contextual-bar__header-data">
{{> icon block="contextual-bar__header-icon" icon=icon}}
<h1 class="contextual-bar__header-title">{{_ label}}
<sub class="contextual-bar__header-description">{{ description }}</sub>
</h1>
</div>
{{/with}}
<button class="contextual-bar__header-close js-close" aria-label="{{_ "Close"}}">
{{> icon block="contextual-bar__header-close-icon" icon="plus"}}
</button>
</header>
<section class="contextual-bar__content flex-tab {{id}}">
{{> Template.dynamic template=template data=flexData}}
</section>
{{#if full}}
{{> Template.dynamic template=template data=flexData}}
{{else}}
<div class="contextual-bar">
<div class="contextual-bar-wrap">
<header class="contextual-bar__header">
{{#with headerData}}
<div class="contextual-bar__header-data">
{{> icon block="contextual-bar__header-icon" icon=icon}}
<h1 class="contextual-bar__header-title">{{_ label}}
<sub class="contextual-bar__header-description">{{ description }}</sub>
</h1>
</div>
{{/with}}
<button class="contextual-bar__header-close js-close" aria-label="{{_ "Close"}}">
{{> icon block="contextual-bar__header-close-icon" icon="plus"}}
</button>
</header>
<section class="contextual-bar__content flex-tab {{id}}">
{{> Template.dynamic template=template data=flexData}}
</section>
</div>
</div>
</div>
{{/if}}
{{/if}}
</template>

@ -127,7 +127,9 @@ export class ChatMessages {
}
clearCurrentDraft() {
const hasValue = this.records[this.editing.id];
delete this.records[this.editing.id];
return !!hasValue;
}
resetToDraft(id) {
@ -243,7 +245,7 @@ export class ChatMessages {
this.$input.setCursorPosition(cursorPosition);
}
async send(event, { rid, tmid, value }, done = () => {}) {
async send(event, { rid, tmid, value, tshow }, done = () => {}) {
const threadsEnabled = settings.get('Threads_enabled');
MsgTyping.stop(rid);
@ -267,6 +269,13 @@ export class ChatMessages {
}
}
// don't add tmid or tshow if the message isn't part of a thread (it can happen if editing the main message of a thread)
const originalMessage = ChatMessage.findOne({ _id: this.editing.id }, { fields: { tmid: 1 }, reactive: false });
if (originalMessage && tmid && !originalMessage.tmid) {
tmid = undefined;
tshow = undefined;
}
if (msg) {
readMessage.readNow(rid);
readMessage.refreshUnreadMark(rid);
@ -274,6 +283,7 @@ export class ChatMessages {
const message = await promises.run('onClientBeforeSendMessage', {
_id: Random.id(),
rid,
tshow,
tmid,
msg,
});
@ -510,13 +520,15 @@ export class ChatMessages {
const { currentTarget: input, which: keyCode } = event;
if (keyCode === keyCodes.ESCAPE && this.editing.element) {
event.preventDefault();
event.stopPropagation();
if (!this.resetToDraft(this.editing.id)) {
this.clearCurrentDraft();
this.clearEditing();
return true;
}
event.preventDefault();
event.stopPropagation();
return;
}

@ -65,11 +65,11 @@ export const KonchatNotification = {
window.focus();
switch (notification.payload.type) {
case 'd':
return FlowRouter.go('direct', { rid: notification.payload.rid }, FlowRouter.current().queryParams);
return FlowRouter.go('direct', { rid: notification.payload.rid, ...notification.payload.tmid && { tab: 'thread', context: notification.payload.tmid } }, { ...FlowRouter.current().queryParams, jump: notification.payload._id });
case 'c':
return FlowRouter.go('channel', { name: notification.payload.name }, FlowRouter.current().queryParams);
return FlowRouter.go('channel', { name: notification.payload.name, ...notification.payload.tmid && { tab: 'thread', context: notification.payload.tmid } }, { ...FlowRouter.current().queryParams, jump: notification.payload._id });
case 'p':
return FlowRouter.go('group', { name: notification.payload.name }, FlowRouter.current().queryParams);
return FlowRouter.go('group', { name: notification.payload.name, ...notification.payload.tmid && { tab: 'thread', context: notification.payload.tmid } }, { ...FlowRouter.current().queryParams, jump: notification.payload._id });
}
};
}

@ -128,7 +128,7 @@
{{/if}}
{{# with messageContext}}
{{#each msg in messagesHistory}}{{> message index=@index shouldCollapseReplies=true msg=msg room=room subscription=subscription settings=settings u=u}}{{/each}}
{{#each msg in messagesHistory}}{{> message showRoles=true index=@index shouldCollapseReplies=false msg=msg room=room subscription=subscription settings=settings u=u}}{{/each}}
{{/with}}
{{#if hasMoreNext}}
@ -145,9 +145,14 @@
{{> messageBox messageboxData}}
</footer>
</div>
{{#with flexData}}
{{> contextualBar}}
{{# with openedThread}}
{{> ThreadComponent}}
{{/with}}
{{# unless shouldCloseFlexTab}}
{{#with flexData}}
{{> contextualBar}}
{{/with}}
{{/unless}}
</div>
</section>
</div>

@ -28,7 +28,6 @@ import {
import { messageContext } from '../../../../ui-utils/client/lib/messageContext';
import { renderMessageBody } from '../../../../ui-utils/client/lib/renderMessageBody';
import { messageArgs } from '../../../../ui-utils/client/lib/messageArgs';
import { getConfig } from '../../../../ui-utils/client/config';
import { call } from '../../../../ui-utils/client/lib/callMethod';
import { settings } from '../../../../settings';
import { callbacks } from '../../../../callbacks';
@ -68,7 +67,8 @@ const openProfileTab = (e, instance, username) => {
}
instance.groupDetail.set(null);
instance.tabBar.setTemplate('membersList');
instance.tabBar.open();
instance.tabBar.setData({});
instance.tabBar.open('members-list');
};
const openProfileTabOrOpenDM = (e, instance, username) => {
@ -253,8 +253,6 @@ function addToInput(text) {
callbacks.add('enter-room', wipeFailedUploads);
const ignoreReplies = getConfig('ignoreReplies') === 'true';
export const dropzoneHelpers = {
dragAndDrop() {
return settings.get('FileUpload_Enabled') && 'dropzone--disabled';
@ -295,18 +293,21 @@ Template.room.helpers({
return state.get('subscribed');
},
messagesHistory() {
const showInMainThread = getUserPreference(Meteor.userId(), 'showMessageInMainThread', false);
const { rid } = Template.instance();
const room = Rooms.findOne(rid, { fields: { sysMes: 1 } });
const hideSettings = settings.collection.findOne('Hide_System_Messages') || {};
const settingValues = Array.isArray(room.sysMes) ? room.sysMes : hideSettings.value || [];
const hideMessagesOfType = new Set(settingValues.reduce((array, value) => [...array, ...value === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [value]], []));
const modes = ['', 'cozy', 'compact'];
const viewMode = getUserPreference(Meteor.userId(), 'messageViewMode');
const query = {
rid,
_hidden: { $ne: true },
...(ignoreReplies || modes[viewMode] === 'compact') && { tmid: { $exists: 0 } },
...!showInMainThread && {
$or: [
{ tmid: { $exists: 0 } },
{ tshow: { $eq: true } },
],
},
};
if (hideMessagesOfType.size) {
@ -450,8 +451,8 @@ Template.room.helpers({
groupDetail: Template.instance().groupDetail.get(),
clearUserDetail: Template.instance().clearUserDetail,
},
...Template.instance().tabBar.getData(),
};
return flexData;
},
@ -540,6 +541,41 @@ Template.room.helpers({
return moment.duration(roomMaxAge(room) * 1000 * 60 * 60 * 24).humanize();
},
messageContext,
shouldCloseFlexTab() {
FlowRouter.watchPathChange();
const tab = FlowRouter.getParam('tab');
const { tabBar } = Template.instance();
if (tab === 'thread' && tabBar.template.get() !== 'threads') {
return true;
}
},
openedThread() {
FlowRouter.watchPathChange();
const tab = FlowRouter.getParam('tab');
const mid = FlowRouter.getParam('context');
const rid = Template.currentData()._id;
const jump = FlowRouter.getQueryParam('jump');
if (tab !== 'thread' || !mid || rid !== Session.get('openedRoom')) {
return;
}
const room = Rooms.findOne({ _id: rid }, {
fields: {
t: 1,
usernames: 1,
uids: 1,
name: 1,
},
});
return {
rid,
mid,
room,
jump,
};
},
});
let isSocialSharingOpen = false;
@ -637,11 +673,15 @@ Template.room.events({
button.action.call(this, event, template);
}
},
'click .js-follow-thread'() {
'click .js-follow-thread'(e) {
e.preventDefault();
e.stopPropagation();
const { msg } = messageArgs(this);
call('followMessage', { mid: msg._id });
},
'click .js-unfollow-thread'() {
'click .js-unfollow-thread'(e) {
e.preventDefault();
e.stopPropagation();
const { msg } = messageArgs(this);
call('unfollowMessage', { mid: msg._id });
},
@ -649,23 +689,17 @@ Template.room.events({
event.preventDefault();
event.stopPropagation();
const { tabBar, subscription } = Template.instance();
const { msg, msg: { rid, _id, tmid } } = messageArgs(this);
const $flexTab = $('.flex-tab-container .flex-tab');
$flexTab.attr('template', 'thread');
const { msg: { rid, _id, tmid } } = messageArgs(this);
const room = Rooms.findOne({ _id: rid });
tabBar.setData({
subscription: subscription.get(),
msg,
FlowRouter.go(FlowRouter.getRouteName(), {
rid,
jump: tmid && tmid !== _id && _id,
mid: tmid || _id,
label: 'Threads',
icon: 'thread',
name: room.name,
tab: 'thread',
context: tmid || _id,
}, {
jump: tmid && tmid !== _id && _id && _id,
});
tabBar.open('thread');
},
'click .js-reply-broadcast'() {
const { msg } = messageArgs(this);
@ -1181,6 +1215,8 @@ Template.room.onDestroyed(function() {
const chatMessage = chatMessages[this.data._id];
chatMessage.onDestroyed && chatMessage.onDestroyed(this.data._id);
callbacks.remove('streamNewMessage', this.data._id);
});
Template.room.onRendered(function() {
@ -1360,7 +1396,7 @@ Template.room.onRendered(function() {
});
}
callbacks.add('streamNewMessage', (msg) => {
if (rid !== msg.rid || msg.editedAt) {
if (rid !== msg.rid || msg.editedAt || msg.tmid) {
return;
}
@ -1371,7 +1407,7 @@ Template.room.onRendered(function() {
if (!template.isAtBottom()) {
newMessage.classList.remove('not');
}
});
}, callbacks.priority.MEDIUM, rid);
this.autorun(function() {
if (template.data._id !== RoomManager.openedRoom) {

@ -56,7 +56,7 @@ export function Section({ children, groupId, hasReset = true, help, sectionName,
{help && <Box is='p' color='hint' fontScale='p1'>{help}</Box>}
<FieldGroup>
{editableSettings.map((setting) => <Setting key={setting} settingId={setting._id} sectionChanged={changed} />)}
{editableSettings.map((setting) => <Setting key={setting._id} settingId={setting._id} sectionChanged={changed} />)}
{hasReset && canReset && <Button
children={t('Reset_section_settings')}

@ -30,6 +30,7 @@ export const MemoizedSetting = memo(function MemoizedSetting({
editor,
onChangeValue = () => {},
onChangeEditor = () => {},
className,
...inputProps
}) {
const InputComponent = {
@ -49,7 +50,7 @@ export const MemoizedSetting = memo(function MemoizedSetting({
roomPick: RoomPickSettingInput,
}[type] || GenericSettingInput;
return <Field>
return <Field className={className}>
<InputComponent
value={value}
editor={editor}
@ -64,7 +65,7 @@ export const MemoizedSetting = memo(function MemoizedSetting({
</Field>;
});
export function Setting({ settingId, sectionChanged }) {
export function Setting({ className, settingId, sectionChanged }) {
const setting = useEditableSetting(settingId);
const persistedSetting = useSettingStructure(settingId);
@ -136,6 +137,7 @@ export function Setting({ settingId, sectionChanged }) {
const hasResetButton = !disableReset && !readonly && type !== 'asset' && (JSON.stringify(packageEditor) !== JSON.stringify(editor) || JSON.stringify(value) !== JSON.stringify(packageValue)) && !disabled;
return <MemoizedSetting
className={className}
type={type}
label={label}
hint={hint}

@ -51,16 +51,5 @@ export function SelectSettingInput({
])}
/>
</Field.Row>
{/* <Select
data-qa-setting-id={_id}
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
onChange={handleChange}
option={values.map(({ key, i18nLabel }) => [key, t(i18nLabel)],
)}
/> */}
</>;
}

@ -57,8 +57,9 @@ function PageHeader({ children, title, ...props }) {
</Box>;
}
function PageContent(props) {
const PageContent = React.forwardRef(function PageContent(props, ref) {
return <Box
ref={ref}
paddingInline='x24'
display='flex'
flexDirection='column'
@ -66,7 +67,7 @@ function PageContent(props) {
height='full'
{...props}
/>;
}
});
function PageScrollableContent({ onScrollContent, ...props }) {
return <Scrollable onScrollContent={onScrollContent} >

@ -1,14 +1,16 @@
import { Box, Tile, Button, Icon, Margins } from '@rocket.chat/fuselage';
import { Box, Button, Icon, Margins, Skeleton } from '@rocket.chat/fuselage';
import { useDebouncedValue, useMediaQuery } from '@rocket.chat/fuselage-hooks';
import React from 'react';
import Page from './Page';
import RawText from './RawText';
function VerticalBar({ children, ...props }) {
const mobile = useDebouncedValue(useMediaQuery('(max-width: 420px)'), 50);
const mobile = useDebouncedValue(useMediaQuery('(max-width: 500px)'), 50);
const small = useDebouncedValue(useMediaQuery('(max-width: 780px)'), 50);
return <Box
rcx-vertical-bar
display='flex'
flexDirection='column'
flexShrink={0}
@ -16,54 +18,79 @@ function VerticalBar({ children, ...props }) {
height='full'
position={small ? 'absolute' : undefined}
insetInlineEnd={small ? 'none' : undefined}
backgroundColor='white'
{...props}
>
<Tile
margin='none'
padding='none'
display='flex'
flexDirection='column'
flexGrow={1}
flexShrink={1}
withTruncatedText
>
{children}
</Tile>
{children}
</Box>;
}
function VerticalBarHeader(props) {
function VerticalBarHeader({ children, ...props }) {
return <Box
paddingBlock='x32'
paddingInline='x24'
pb='x24'
pi='x24'
height='64px'
display='flex'
alignItems='center'
justifyContent='space-between'
backgroundColor='neutral-200'
backgroundColor='white'
borderBlockColor='neutral-200'
borderBlockEndWidth='x2'
fontScale='s2'
{...props}
/>;
>
<Margins inline='x4'>{children}</Margins>
</Box>;
}
function VerticalBarClose(props) {
return <Button small flexShrink={0} ghost square {...props}>
<Icon name='cross' size='x20' />
</Button>;
function VerticalBarIcon(props) {
return <Icon {...props} size='x20'/>;
}
function VerticalBarContent(props) {
return <Page.Content {...props} />;
function VerticalBarClose(props) {
return <VerticalBarAction {...props} name='cross' />;
}
function VerticalBarScrollableContent({ children, ...props }) {
return <Page.ScrollableContent padding='x24' mi='neg-x24' {...props}>
const VerticalBarContent = React.forwardRef(function VerticalBarContent(props, ref) {
return <Page.Content {...props} ref={ref}/>;
});
const VerticalBarScrollableContent = React.forwardRef(function VerticalBarScrollableContent({ children, props }, ref) {
return <Page.ScrollableContent padding='x24' mi='neg-x24' {...props} ref={ref}>
<Margins blockEnd='x16'>{children}</Margins>
</Page.ScrollableContent>;
});
function VerticalBarButton(props) {
return <Button small square flexShrink={0} ghost {...props}/>;
}
function VerticalBarAction({ name, ...props }) {
return <VerticalBarButton small square flexShrink={0} ghost {...props}><VerticalBarIcon name={name}/></VerticalBarButton>;
}
function VerticalBarSkeleton(props) {
return <VerticalBar { ...props }>
<VerticalBarHeader><Skeleton width='100%'/></VerticalBarHeader>
<Box p='x24'>
<Skeleton width='32px' height='32px' variant='rect'/> <Skeleton />
{Array(5).fill().map((_, index) => <Skeleton key={index}/>)}
</Box>
</VerticalBar>;
}
function VerticalBarText({ children, ...props }) {
return <Box flexShrink={1} flexGrow={1} withTruncatedText {...props}><RawText>{children}</RawText></Box>;
}
VerticalBar.Header = VerticalBarHeader;
VerticalBar.Close = VerticalBarClose;
VerticalBar.Content = VerticalBarContent;
VerticalBar.ScrollableContent = VerticalBarScrollableContent;
VerticalBar.Icon = React.memo(VerticalBarIcon);
VerticalBar.Text = React.memo(VerticalBarText);
VerticalBar.Action = React.memo(VerticalBarAction);
VerticalBar.Header = React.memo(VerticalBarHeader);
VerticalBar.Close = React.memo(VerticalBarClose);
VerticalBar.Content = React.memo(VerticalBarContent);
VerticalBar.ScrollableContent = React.memo(VerticalBarScrollableContent);
VerticalBar.Skeleton = React.memo(VerticalBarSkeleton);
VerticalBar.Button = React.memo(VerticalBarButton);
export default VerticalBar;

@ -33,16 +33,21 @@ export const useEndpointDataExperimental = (endpoint, params = {}, { delayTimeou
setData(defaultState);
const data = await getData(params);
if (!mounted) {
return;
}
if (!data.success) {
throw new Error(data.status);
}
setData({ data, state: ENDPOINT_STATES.DONE });
} catch (error) {
if (!mounted) {
return;
}
setData({ data, state: ENDPOINT_STATES.DONE });
} catch (error) {
setData({ error, state: ENDPOINT_STATES.ERROR });
dispatchToastMessage({ type: 'error', message: error });
} finally {

@ -0,0 +1,4 @@
import { useCallback } from 'react';
import moment from 'moment';
export const useTimeAgo = () => useCallback((time) => moment(time).calendar(null, { sameDay: 'LT', lastWeek: 'dddd LT', sameElse: 'LL' }), []);

@ -1,29 +1,40 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { t } from '../../../app/utils';
import { modal, MessageAction } from '../../../app/ui-utils';
import { messageArgs } from '../../../app/ui-utils/client/lib/messageArgs';
import { settings } from '../../../app/settings';
MessageAction.addButton({
id: 'receipt-detail',
icon: 'info-circled',
label: 'Info',
context: ['starred', 'message', 'message-mobile'],
action() {
const { msg: message } = messageArgs(this);
modal.open({
title: t('Info'),
content: 'readReceipts',
data: {
messageId: message._id,
Meteor.startup(() => {
Tracker.autorun(() => {
const enabled = settings.get('Message_Read_Receipt_Store_Users');
if (!enabled) {
return MessageAction.removeButton('receipt-detail');
}
MessageAction.addButton({
id: 'receipt-detail',
icon: 'info-circled',
label: 'Info',
context: ['starred', 'message', 'message-mobile', 'threads'],
action() {
const { msg: message } = messageArgs(this);
modal.open({
title: t('Info'),
content: 'readReceipts',
data: {
messageId: message._id,
},
showConfirmButton: true,
showCancelButton: false,
confirmButtonText: t('Close'),
});
},
showConfirmButton: true,
showCancelButton: false,
confirmButtonText: t('Close'),
order: 10,
group: 'menu',
});
},
condition() {
return settings.get('Message_Read_Receipt_Store_Users');
},
order: 10,
group: 'menu',
});
});

88
package-lock.json generated

@ -2400,9 +2400,9 @@
"dev": true
},
"@juggle/resize-observer": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.1.3.tgz",
"integrity": "sha512-y7qc6SzZBlSpx8hEDfV0S9Cx6goROX/vBhS2Ru1Q78Jp1FlCMbxp7UcAN90rLgB3X8DSMBgDFxcmoG/VfdAhFA=="
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.2.0.tgz",
"integrity": "sha512-fsLxt0CHx2HCV9EL8lDoVkwHffsA0snUpddYjdLyXcG5E41xaamn9ZyQqOE9TUJdrRlH8/hjIf+UdOdDeKCUgg=="
},
"@kossnocorp/desvg": {
"version": "0.2.0",
@ -2733,9 +2733,9 @@
}
},
"@rocket.chat/css-in-js": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.9.0.tgz",
"integrity": "sha512-CK7WAPAG+Vi8yX73cPIGNU5j/zRMbbXqKYrz7qrAY66qXlsFtz+K1Cj2ntownzi9R1wz4wxHmM89/6wN23WKAg==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/css-in-js/-/css-in-js-0.10.0.tgz",
"integrity": "sha512-Zo/18kmiRtuGGzFtCYJz1DGs1+j6MBTKWM/n6+bmgw3ubCuSsG1t12KtJl87BBwVnJwC7W+oxqesM8llUF5Odw==",
"requires": {
"@emotion/hash": "^0.8.0",
"@emotion/stylis": "^0.8.5"
@ -2751,25 +2751,25 @@
}
},
"@rocket.chat/fuselage": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.9.0.tgz",
"integrity": "sha512-BRmKElhuf0NwOz3vJdDIwOoGPYm0FZF978ve9kYidCJQevPVY4Th9ptOfD3YCtZjWPWGVqqpbtwbocabPQjurw==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage/-/fuselage-0.10.0.tgz",
"integrity": "sha512-P2LO5g6pQvUfE1Or1AjgFZWV45v+6htGejwRovPtZRbRAngHT9dJcTfDfykjncCEptZi6xBvEh+pkCgcZ1fCng==",
"requires": {
"@rocket.chat/css-in-js": "^0.9.0",
"@rocket.chat/fuselage-tokens": "^0.9.0",
"@rocket.chat/icons": "^0.9.0",
"invariant": "^2.2.4"
"@rocket.chat/css-in-js": "^0.10.0",
"@rocket.chat/fuselage-tokens": "^0.10.0",
"invariant": "^2.2.4",
"react-keyed-flatten-children": "^1.2.0"
}
},
"@rocket.chat/fuselage-hooks": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.9.0.tgz",
"integrity": "sha512-15uw1Z63Q8XLr6Or4YoJdReyYozbp6NFYTIDSe3Dz44gp5oDmXzS+Uwmn2iQPxF3qEbxPEMnt/CcVux/Pv3MNw=="
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-hooks/-/fuselage-hooks-0.10.0.tgz",
"integrity": "sha512-oU3MgNakM32yqwXVS7lXnOvYjwNtkOpext1NMsAsirjfvhFz+CwdFEqLgn6uGVohoiR3KvV4xJSN/ZaF5WhRCQ=="
},
"@rocket.chat/fuselage-polyfills": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.9.0.tgz",
"integrity": "sha512-or8zQQAZg9bXWpQD9074PuqtiC1Xoy41Z3syS8ABU0NeDS4llJ2EScSt/ksZz+2y7uh3c3MxrZIZLUAMrbup0g==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-polyfills/-/fuselage-polyfills-0.10.0.tgz",
"integrity": "sha512-WmWLWXed4vKySWtvDr9umFBcZ7tk0MWL7gFGsDV0tSuQpBjRGW3gfOBkGnvay0DG+UzJbw9pLUWXAbArBNDCNw==",
"requires": {
"@juggle/resize-observer": "^3.1.2",
"clipboard-polyfill": "^2.8.6",
@ -2777,29 +2777,22 @@
}
},
"@rocket.chat/fuselage-tokens": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.9.0.tgz",
"integrity": "sha512-ZiCIDtNsPj7icJLr9iK/Dn0vN+mrUy/blQlrFn0pKznET2ARPAvjWPsAAk/JJu3rlCnD7u4Fd15oI6BJllh+4A=="
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-tokens/-/fuselage-tokens-0.10.0.tgz",
"integrity": "sha512-q7LHbeGHqOt+6g05FNAPkR8hEknxHrhvIp4K4DlLWaPrLCySdPp1lmXZuSO+AoGl69Y/e8BdWmK46K2PAhzwGw=="
},
"@rocket.chat/fuselage-ui-kit": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.9.0.tgz",
"integrity": "sha512-rVp+gaR3L9tJEBx5onjajPi9aNw8cdqUBtuRxgTwpHUPGxZVxadgJo9OXvFGfB0bt8d9DcXMW+ouRJp+LRfLtg==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/fuselage-ui-kit/-/fuselage-ui-kit-0.10.0.tgz",
"integrity": "sha512-2wrJjcKUN0fOPUhllMA/+nJkJLlz7GZ5NI+eG2snUjuaEyi8LUWGqdd7Xqx4hZOxm4wG24hFJaxc4WQb4kCO2Q==",
"requires": {
"@rocket.chat/ui-kit": "^0.9.0"
},
"dependencies": {
"@rocket.chat/ui-kit": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.9.0.tgz",
"integrity": "sha512-BBqLT1vbZjUzG4xzUMo56O0EsnH0sLNaESmBy0YYIAzBYkpxwQcVLY0XWq3w7gcLYEUhFvLVvKCis5zPHkZrqQ=="
}
"@rocket.chat/ui-kit": "^0.10.0"
}
},
"@rocket.chat/icons": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.9.0.tgz",
"integrity": "sha512-Yfzz19+LoD6OpP57Y7W6E4ctiBP1RfG1LvTC+9/FNPpvWRnlQDySZHpUVn+TGrfuvBQ5YH5XqeHIKWZw+YiWEg=="
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/icons/-/icons-0.10.0.tgz",
"integrity": "sha512-gtXIIQf8pCLVLcUtd7cbt/OukM9XlnmK9+du4utc5LIvuNHJaaekSFv9Bg3URy41P/8Ss9dc6FmDnlPna4F19g=="
},
"@rocket.chat/livechat": {
"version": "1.5.0",
@ -2919,9 +2912,9 @@
}
},
"@rocket.chat/ui-kit": {
"version": "0.6.3-dev.23",
"resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.6.3-dev.23.tgz",
"integrity": "sha512-fMBwA4wyyEPa0FlXp8w2807U2Ro8pTsA9dMEuDt1Qh2cZ8hZ8y4sNMiHOULttG3W3zAjHINTPjxLQnJrExoacw=="
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@rocket.chat/ui-kit/-/ui-kit-0.10.0.tgz",
"integrity": "sha512-zCMDsxdmUvKD9zzyskiV8kThIcsAaXvZT1LzFk4yLq2Wh5meTJ+TClgt9Zn3pBdO/2mwdjBueiGc+k+vqNIqRg=="
},
"@samverschueren/stream-to-observable": {
"version": "0.3.0",
@ -6214,8 +6207,7 @@
"@types/underscore": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.9.4.tgz",
"integrity": "sha512-CjHWEMECc2/UxOZh0kpiz3lEyX2Px3rQS9HzD20lxMvx571ivOBQKeLnqEjxUY0BMgp6WJWo/pQLRBwMW5v4WQ==",
"dev": true
"integrity": "sha512-CjHWEMECc2/UxOZh0kpiz3lEyX2Px3rQS9HzD20lxMvx571ivOBQKeLnqEjxUY0BMgp6WJWo/pQLRBwMW5v4WQ=="
},
"@types/underscore.string": {
"version": "0.0.38",
@ -26512,6 +26504,20 @@
"prop-types": "^15.6.2"
}
},
"react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
}
},
"react-window-infinite-loader": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.5.tgz",
"integrity": "sha512-IcPIq8lADK3zsAcqoLqQGyduicqR6jWkiK2VUX5sKSI9X/rou6OWlOEexnGyujdNTG7hSG8OVBFEhLSDs4qrxg=="
},
"reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save