diff --git a/app/api/server/v1/chat.js b/app/api/server/v1/chat.js index 666725f3bb322..815a85f1757ad 100644 --- a/app/api/server/v1/chat.js +++ b/app/api/server/v1/chat.js @@ -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, diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index 86711691782d1..2cc915acf22c9 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.js @@ -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, })); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 4eebd8bc7bbe1..6d3c380bbe7e2 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -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), diff --git a/app/channel-settings/client/views/channelSettings.html b/app/channel-settings/client/views/channelSettings.html index 174bea859f9e1..bae94547b5c72 100644 --- a/app/channel-settings/client/views/channelSettings.html +++ b/app/channel-settings/client/views/channelSettings.html @@ -324,6 +324,21 @@ {{/with}} + {{#with settings.retentionKeepThreads}} +
+
+ +
+
+ {{/with}} {{/if}} {{/if}} diff --git a/app/channel-settings/server/methods/saveRoomSettings.js b/app/channel-settings/server/methods/saveRoomSettings.js index 49dae9c9ab664..ae86dbfeca04e 100644 --- a/app/channel-settings/server/methods/saveRoomSettings.js +++ b/app/channel-settings/server/methods/saveRoomSettings.js @@ -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; diff --git a/app/discussion/client/public/stylesheets/discussion.css b/app/discussion/client/public/stylesheets/discussion.css index 93c6d4e8e20ff..b4dc9737f02a1 100644 --- a/app/discussion/client/public/stylesheets/discussion.css +++ b/app/discussion/client/public/stylesheets/discussion.css @@ -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 { diff --git a/app/discussion/server/config.js b/app/discussion/server/config.js index abdcaac9a5828..d5ed8ee0a2a5d 100644 --- a/app/discussion/server/config.js +++ b/app/discussion/server/config.js @@ -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, + }); }); diff --git a/app/emoji-emojione/client/emojione-sprites.css b/app/emoji-emojione/client/emojione-sprites.css index 6aa6a6eb1add2..a2466c0a7d34b 100644 --- a/app/emoji-emojione/client/emojione-sprites.css +++ b/app/emoji-emojione/client/emojione-sprites.css @@ -29,7 +29,7 @@ image-rendering: optimizeQuality; } -.emojione.big { +.message .emojione.big { width: 44px; height: 44px; } diff --git a/app/federation/server/endpoints/dispatch.js b/app/federation/server/endpoints/dispatch.js index c909d0f62b3ab..c732700924960 100644 --- a/app/federation/server/endpoints/dispatch.js +++ b/app/federation/server/endpoints/dispatch.js @@ -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); diff --git a/app/lib/lib/roomTypes/direct.js b/app/lib/lib/roomTypes/direct.js index e54022b5f2ebe..02863802f806e 100644 --- a/app/lib/lib/roomTypes/direct.js +++ b/app/lib/lib/roomTypes/direct.js @@ -13,7 +13,7 @@ export class DirectMessageRoomRoute extends RoomTypeRouteConfig { constructor() { super({ name: 'direct', - path: '/direct/:rid', + path: '/direct/:rid/:tab?/:context?', }); } diff --git a/app/lib/lib/roomTypes/private.js b/app/lib/lib/roomTypes/private.js index 54f669d3c0968..881219b0a6148 100644 --- a/app/lib/lib/roomTypes/private.js +++ b/app/lib/lib/roomTypes/private.js @@ -13,7 +13,7 @@ export class PrivateRoomRoute extends RoomTypeRouteConfig { constructor() { super({ name: 'group', - path: '/group/:name', + path: '/group/:name/:tab?/:context?', }); } diff --git a/app/lib/lib/roomTypes/public.js b/app/lib/lib/roomTypes/public.js index e7b935805cec3..95057de473302 100644 --- a/app/lib/lib/roomTypes/public.js +++ b/app/lib/lib/roomTypes/public.js @@ -11,7 +11,7 @@ export class PublicRoomRoute extends RoomTypeRouteConfig { constructor() { super({ name: 'channel', - path: '/channel/:name', + path: '/channel/:name/:tab?/:context?', }); } diff --git a/app/lib/server/functions/cleanRoomHistory.js b/app/lib/server/functions/cleanRoomHistory.js index 3b58d3885d9a9..593fdaff09a48 100644 --- a/app/lib/server/functions/cleanRoomHistory.js +++ b/app/lib/server/functions/cleanRoomHistory.js @@ -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)); } diff --git a/app/lib/server/functions/deleteMessage.js b/app/lib/server/functions/deleteMessage.js index aeae4c11caa4b..34563d22a0404 100644 --- a/app/lib/server/functions/deleteMessage.js +++ b/app/lib/server/functions/deleteMessage.js @@ -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)); diff --git a/app/lib/server/functions/notifications/audio.js b/app/lib/server/functions/notifications/audio.js index 0c237507fd15c..38d84f82211e4 100644 --- a/app/lib/server/functions/notifications/audio.js +++ b/app/lib/server/functions/notifications/audio.js @@ -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) { diff --git a/app/lib/server/functions/notifications/desktop.js b/app/lib/server/functions/notifications/desktop.js index 880a503e459ed..a952ea8c99048 100644 --- a/app/lib/server/functions/notifications/desktop.js +++ b/app/lib/server/functions/notifications/desktop.js @@ -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); } diff --git a/app/lib/server/functions/notifications/email.js b/app/lib/server/functions/notifications/email.js index 75928a5bbbba1..040e64a8347b3 100644 --- a/app/lib/server/functions/notifications/email.js +++ b/app/lib/server/functions/notifications/email.js @@ -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); } diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index fdc5dda7b4df4..5853ba21dc02a 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -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); } diff --git a/app/lib/server/functions/sendMessage.js b/app/lib/server/functions/sendMessage.js index 5b7d3c281c103..9c2deb8ae9f33 100644 --- a/app/lib/server/functions/sendMessage.js +++ b/app/lib/server/functions/sendMessage.js @@ -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; } }; diff --git a/app/lib/server/lib/notifyUsersOnMessage.js b/app/lib/server/lib/notifyUsersOnMessage.js index f1d041f3e31ad..7ec7ec41efef4 100644 --- a/app/lib/server/lib/notifyUsersOnMessage.js +++ b/app/lib/server/lib/notifyUsersOnMessage.js @@ -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; diff --git a/app/lib/server/lib/sendNotificationsOnMessage.js b/app/lib/server/lib/sendNotificationsOnMessage.js index 9d328ab373135..0c15db9518334 100644 --- a/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/app/lib/server/lib/sendNotificationsOnMessage.js @@ -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) { diff --git a/app/lib/server/methods/cleanRoomHistory.js b/app/lib/server/methods/cleanRoomHistory.js index 37dfb105c0ec6..029147774b0f1 100644 --- a/app/lib/server/methods/cleanRoomHistory.js +++ b/app/lib/server/methods/cleanRoomHistory.js @@ -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 }); }, }); diff --git a/app/lib/server/methods/sendMessage.js b/app/lib/server/methods/sendMessage.js index ae59502e151b8..22d30108fcb47 100644 --- a/app/lib/server/methods/sendMessage.js +++ b/app/lib/server/methods/sendMessage.js @@ -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', diff --git a/app/lib/server/methods/updateMessage.js b/app/lib/server/methods/updateMessage.js index 49d7720ec73c9..5360a387e30ba 100644 --- a/app/lib/server/methods/updateMessage.js +++ b/app/lib/server/methods/updateMessage.js @@ -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(); diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index d735523473d6a..9a063480c7e14 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -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: [ diff --git a/app/markdown/lib/markdown.js b/app/markdown/lib/markdown.js index c7163d0ee24d6..4fa7a1766309f 100644 --- a/app/markdown/lib/markdown.js +++ b/app/markdown/lib/markdown.js @@ -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'); diff --git a/app/mentions-flextab/client/actionButton.js b/app/mentions-flextab/client/actionButton.js index 1e68f74d7ed50..ee06deeec97af 100644 --- a/app/mentions-flextab/client/actionButton.js +++ b/app/mentions-flextab/client/actionButton.js @@ -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, diff --git a/app/mentions/client/client.js b/app/mentions/client/client.js index 910c8a1f2f026..b933014c7c35d 100644 --- a/app/mentions/client/client.js +++ b/app/mentions/client/client.js @@ -18,7 +18,7 @@ Meteor.startup(() => Tracker.autorun(() => { })); -const instance = new MentionsParser({ +export const instance = new MentionsParser({ pattern: () => pattern, useRealName: () => useRealName, me: () => me, diff --git a/app/mentions/client/mentionLink.css b/app/mentions/client/mentionLink.css index 70c1be80838d2..bc22435ac50c9 100644 --- a/app/mentions/client/mentionLink.css +++ b/app/mentions/client/mentionLink.css @@ -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; diff --git a/app/mentions/lib/MentionsParser.js b/app/mentions/lib/MentionsParser.js index 72e7b0da0b00a..d79207e4a641f 100644 --- a/app/mentions/lib/MentionsParser.js +++ b/app/mentions/lib/MentionsParser.js @@ -1,10 +1,14 @@ import s from 'underscore.string'; +const userTemplateDefault = ({ prefix, className, mention, title, label, type = 'username' }) => `${ prefix }${ label }`; +const roomTemplateDefault = ({ prefix, reference, mention }) => `${ prefix }${ `#${ mention }` }`; 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 }${ mention }`; + return this.userTemplate({ prefix, className, mention, label: mention, type: 'group' }); } const label = temp @@ -73,7 +77,7 @@ export class MentionsParser { return match; } - return `${ prefix }${ label }`; + 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 }${ `#${ mention }` }`; + return this.roomTemplate({ prefix, reference, channel, mention }); }) getUserMentions(str) { diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js index 1b1ad9798c389..c0844340fc074 100644 --- a/app/message-pin/client/actionButton.js +++ b/app/message-pin/client/actionButton.js @@ -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 }) { diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js index bdb86a5530b77..4fbd878c91d7f 100644 --- a/app/message-star/client/actionButton.js +++ b/app/message-star/client/actionButton.js @@ -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 }) { diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index 24962bee0a7d5..8a378643968aa 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -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 }; } diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 9214d38a67f1f..3b13f1a30c716 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -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 }; diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index 5607a1bbfe0d2..460d6b20bdaa1 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -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, diff --git a/app/retention-policy/server/cronPruneMessages.js b/app/retention-policy/server/cronPruneMessages.js index ae96f940b97ee..ad374d9e4767b 100644 --- a/app/retention-policy/server/cronPruneMessages.js +++ b/app/retention-policy/server/cronPruneMessages.js @@ -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 }); }); } diff --git a/app/search/client/provider/result.js b/app/search/client/provider/result.js index 6809f2915874c..18bb48c0bf2a4 100644 --- a/app/search/client/provider/result.js +++ b/app/search/client/provider/result.js @@ -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); } diff --git a/app/theme/client/imports/components/badge.css b/app/theme/client/imports/components/badge.css index e8ab4257a6b31..cbfac82b1a9b5 100644 --- a/app/theme/client/imports/components/badge.css +++ b/app/theme/client/imports/components/badge.css @@ -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; diff --git a/app/theme/client/imports/components/contextual-bar.css b/app/theme/client/imports/components/contextual-bar.css index 9c53099942d43..083d40f9ebd3a 100644 --- a/app/theme/client/imports/components/contextual-bar.css +++ b/app/theme/client/imports/components/contextual-bar.css @@ -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; diff --git a/app/theme/client/imports/components/header.css b/app/theme/client/imports/components/header.css index 3dfa4f441492a..e2217d6c715c9 100644 --- a/app/theme/client/imports/components/header.css +++ b/app/theme/client/imports/components/header.css @@ -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; diff --git a/app/theme/client/imports/components/messages.css b/app/theme/client/imports/components/messages.css index ffb10d16430db..45fb6d80fa38f 100644 --- a/app/theme/client/imports/components/messages.css +++ b/app/theme/client/imports/components/messages.css @@ -205,7 +205,7 @@ .message { &:hover, &.active { - background-color: rgba(15, 34, 0, 0.05); + background-color: #f7f8fa; & .message-actions { display: flex; diff --git a/app/theme/client/imports/components/tooltip.css b/app/theme/client/imports/components/tooltip.css index 5c08d8be25168..24cf4790bd673 100644 --- a/app/theme/client/imports/components/tooltip.css +++ b/app/theme/client/imports/components/tooltip.css @@ -5,7 +5,7 @@ &::before, &::after { position: absolute; - z-index: 10; + z-index: 1000; bottom: 100%; left: 50%; diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index b43977f603740..b7876262b494f 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -2111,6 +2111,8 @@ &-unread { display: inline-block; + float: right; + width: 10px; height: 10px; margin: 0 0.25rem; diff --git a/app/theme/client/imports/general/variables.css b/app/theme/client/imports/general/variables.css index fb317250592e5..c11d7895def1b 100644 --- a/app/theme/client/imports/general/variables.css +++ b/app/theme/client/imports/general/variables.css @@ -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 diff --git a/app/threads/client/components/ThreadComponent.js b/app/threads/client/components/ThreadComponent.js new file mode 100644 index 0000000000000..20d2881e4274b --- /dev/null +++ b/app/threads/client/components/ThreadComponent.js @@ -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 && } + + + + ; + } + + return <> + {expanded && } + + + + + + {headerTitle} + + + + + + + + ; +} diff --git a/app/threads/client/components/ThreadList.js b/app/threads/client/components/ThreadList.js new file mode 100644 index 0000000000000..6f7a136f86085 --- /dev/null +++ b/app/threads/client/components/ThreadList.js @@ -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) => ; +} + +function mapProps(WrappedComponent) { + return ({ msg, username, replies, tcount, 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 ; + }; +} + +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(//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 ; + } + const thread = data[index]; + const msg = normalizeThreadMessage(thread); + + const { name = thread.u.username } = thread.u; + + return ; + }), [unread, showRealNames]); + + const isItemLoaded = useCallback((index) => index < threadsRef.current.length, []); + const { ref, contentBoxSize: { inlineSize = 378, blockSize = 750 } = {} } = useResizeObserver(); + + return + + + {t('Threads')} + + + + + + + }/> + {{_ "Off"}} +
+ +
+ + +
+
diff --git a/app/ui-account/client/accountPreferences.js b/app/ui-account/client/accountPreferences.js index 2d7080102c3ff..3219a57984a00 100644 --- a/app/ui-account/client/accountPreferences.js +++ b/app/ui-account/client/accountPreferences.js @@ -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()); diff --git a/app/ui-cached-collection/client/models/CachedCollection.js b/app/ui-cached-collection/client/models/CachedCollection.js index 26dece4ccc93a..f9c9e45107792 100644 --- a/app/ui-cached-collection/client/models/CachedCollection.js +++ b/app/ui-cached-collection/client/models/CachedCollection.js @@ -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 */) => {}, }) { diff --git a/app/ui-clean-history/client/views/cleanHistory.html b/app/ui-clean-history/client/views/cleanHistory.html index fc27ec5ab472c..de5cec6b2c0f7 100644 --- a/app/ui-clean-history/client/views/cleanHistory.html +++ b/app/ui-clean-history/client/views/cleanHistory.html @@ -71,6 +71,11 @@ {{> icon icon="check" block="rc-checkbox__icon"}} {{_ "RetentionPolicy_DoNotExcludeDiscussion"}} +
- {{#with flexData}} - {{> contextualBar}} + {{# with openedThread}} + {{> ThreadComponent}} {{/with}} + {{# unless shouldCloseFlexTab}} + {{#with flexData}} + {{> contextualBar}} + {{/with}} + {{/unless}}
diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index 73dd6094d8ca3..3f0e7248020c0 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -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) { diff --git a/client/admin/settings/Section.js b/client/admin/settings/Section.js index 09379b7f795f6..8f7cc4ee8c0cc 100644 --- a/client/admin/settings/Section.js +++ b/client/admin/settings/Section.js @@ -56,7 +56,7 @@ export function Section({ children, groupId, hasReset = true, help, sectionName, {help && {help}} - {editableSettings.map((setting) => )} + {editableSettings.map((setting) => )} {hasReset && canReset &&