From 14b79fe1acd1c73e13603a882d5f7f64fcc23688 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Sun, 7 Apr 2019 17:11:41 -0300 Subject: [PATCH] [NEW] Threads V 1.0 (#13996) * first commit * empty reply method * permissions and settings * hooks * canSendMessage server function * follow unfollow methods * message tmid index * removed useless permissons * Notification and Hooks * remove edit-room-title * flextab threads and thread view * improved message render * open threads on click * group message * Save unread threads on subscription * group thread messages * useless css * follow unfollow methods * Fix unread threads * follow unfollow actions and badge on flextab * unread button * fix multiple getThreadMessages * Add notifications * Move thread queries to models * Move lib file to server folder * Fix notifications for users in thread * small changes * Remove stub thread reply * Normalize thread files * message template * Fix notification on first reply * Fix follow/unfollow * Fix removing a thread on last message delete * fix open flextab * getmessages instead getSinglemessage * Fix remove thread message * Fix delete thread * fix open multiple threads * Fix removing threads * Add more tests to todo * fix * fix * icons and i18n * Fix thread title on replies * Fix async * onViewRendered * fix reactions and removed css code * fix blaze variable * threads tab order * thread replies button * i18n * fix test * fix tests and css * removed limits to thread list * fix grouping time * fix load message * style changes * fix unread badge * fix role description * clear read thread * ajust badge * time ago threads * jump to messages * mention link * tick mention * fix reloading threadlist after reply * Pass rid and showFormattingTips as parameters to messageBox template * Remove references to RoomManager in messageBox template * Remove some invalid references * Remove some invalid references * Reduce messageBox coupling * Add small fixes * Extract more parameters from messageBox * Fix emoji picker button * Remove all references to chatMessages in messageBox * Change focus handling * Refactor autogrow plugin * Fix calling modal.open() on modal confirm callback * Disable message reply action for same user * Refactor ChatMessages * Pass rid to messagePopupConfig * Fix attachment description update * Move RTL change logic to messageBox * Pass rid to fileUpload helper * Don't use openedRoom session variable in room template * Add tmid support * Rename mountReply helper as prependReplies * scroll at bottom thread * Simplify messageBox events * Refactor ChatMessages.send * Fix messagePopupConfig for emojis * Split chatMessages initialization * Revert "Disable message reply action for same user" This reverts commit f9dc0b486e8fe8aa52012fa6e21bc4961ad5d674. * Set outline style for open thread buttons * Test atBottom condition on thread template before request scroll * Update join button * Protect messageBox from rid absence * Embed messageBox into thread template * Wait thread update before request scroll * Increase font-weight for rc-button --- app/action-links/client/init.js | 13 +- .../server/functions/canSendMessage.js | 22 + app/authorization/server/index.js | 2 + app/autotranslate/client/lib/actionButton.js | 3 +- app/channel-settings/client/startup/tabBar.js | 2 +- .../client/createDiscussionMessageAction.js | 3 +- .../client/views/DiscussionTabbar.html | 6 +- .../client/views/DiscussionTabbar.js | 23 +- .../server/methods/sendFileMessage.js | 1 + app/lib/client/defaultTabBars.js | 2 +- app/lib/client/lib/formatDate.js | 13 +- app/lib/server/functions/deleteMessage.js | 4 +- .../server/functions/notifications/audio.js | 5 +- .../server/functions/notifications/desktop.js | 5 +- .../server/functions/notifications/email.js | 5 +- .../server/functions/notifications/mobile.js | 5 +- app/lib/server/index.js | 1 + app/lib/server/lib/notifyUsersOnMessage.js | 64 +- .../server/lib/sendNotificationsOnMessage.js | 70 +- app/lib/server/methods/getMessages.js | 31 + app/lib/server/methods/sendMessage.js | 69 +- app/mentions-flextab/client/actionButton.js | 8 +- .../client/views/mentionsFlexTab.html | 6 +- .../client/views/mentionsFlexTab.js | 26 +- .../client/actionButton.js | 5 +- app/message-pin/client/actionButton.js | 9 +- .../client/views/pinnedMessages.html | 6 +- .../client/views/pinnedMessages.js | 32 +- app/message-snippet/client/actionButton.js | 3 +- .../tabBar/views/snippetedMessages.html | 6 +- .../client/tabBar/views/snippetedMessages.js | 17 +- app/message-star/client/actionButton.js | 10 +- .../client/views/starredMessages.html | 6 +- .../client/views/starredMessages.js | 32 +- app/models/server/models/Messages.js | 113 +++ app/models/server/models/Subscriptions.js | 53 ++ app/nrr/client/nrr.js | 27 +- app/oembed/client/oembedYoutubeWidget.html | 2 +- app/reactions/client/init.js | 15 +- app/search/client/provider/result.html | 6 +- app/search/client/provider/result.js | 11 +- app/theme/client/imports/components/badge.css | 8 +- .../imports/components/contextual-bar.css | 23 +- .../client/imports/components/header.css | 31 + .../client/imports/components/messages.css | 2 +- .../client/imports/components/userInfo.css | 2 + app/theme/client/imports/forms/button.css | 31 +- app/theme/client/imports/general/base.css | 19 +- app/theme/client/imports/general/base_old.css | 44 +- app/theme/client/imports/general/rtl.css | 5 - .../client/imports/general/variables.css | 8 +- app/theme/server/variables.js | 3 +- app/threads/README.md | 17 + app/threads/client/flextab/thread.html | 26 + app/threads/client/flextab/thread.js | 157 ++++ app/threads/client/flextab/threadlist.js | 21 + app/threads/client/flextab/threads.html | 34 + app/threads/client/flextab/threads.js | 163 ++++ app/threads/client/index.js | 6 + app/threads/client/messageAction/follow.js | 36 + app/threads/client/messageAction/unfollow.js | 36 + app/threads/client/threads.css | 58 ++ app/threads/client/upsert.js | 10 + app/threads/server/functions.js | 49 ++ app/threads/server/hooks/afterReadMessages.js | 19 + .../server/hooks/afterdeletemessage.js | 33 + app/threads/server/hooks/aftersavemessage.js | 131 +++ app/threads/server/hooks/index.js | 3 + app/threads/server/index.js | 3 + app/threads/server/methods/followMessage.js | 39 + .../server/methods/getThreadMessages.js | 39 + app/threads/server/methods/getThreadsList.js | 29 + app/threads/server/methods/index.js | 4 + app/threads/server/methods/unfollowMessage.js | 39 + app/threads/server/settings.js | 13 + app/ui-flextab/client/flexTabBar.html | 3 + app/ui-master/client/main.js | 8 +- app/ui-message/client/message.html | 74 +- app/ui-message/client/message.js | 474 ++++++----- app/ui-message/client/messageBox.html | 66 +- app/ui-message/client/messageBox.js | 465 ++++++----- .../client/messageBoxAudioMessage.js | 13 +- app/ui-message/client/messageBoxFormatting.js | 107 +++ .../client/messageBoxNotSubscribed.html | 4 +- .../client/messageBoxNotSubscribed.js | 21 +- .../client/messageBoxReplyPreview.html | 4 +- .../client/messageBoxReplyPreview.js | 16 +- .../client/popup/messagePopupConfig.html | 8 +- .../client/popup/messagePopupConfig.js | 428 +++++----- .../popup/messagePopupSlashCommandPreview.js | 10 +- app/ui-message/client/startup/index.js | 1 - .../client/startup/messageBoxActions.js | 9 +- app/ui-message/client/startup/restoreText.js | 22 - app/ui-sidenav/client/sidebarItem.js | 12 +- app/ui-utils/client/index.js | 5 +- app/ui-utils/client/lib/MessageAction.js | 19 +- app/ui-utils/client/lib/RocketChatTabBar.js | 4 + app/ui-utils/client/lib/RoomHistoryManager.js | 65 +- app/ui-utils/client/lib/RoomManager.js | 41 +- app/ui-utils/client/lib/keyCodes.js | 36 + app/ui-utils/client/lib/messageArgs.js | 1 + app/ui-utils/client/lib/messageBox.js | 21 +- app/ui-utils/client/lib/messageContext.js | 42 + app/ui-utils/client/lib/modal.js | 13 +- app/ui-utils/client/lib/popover.js | 4 +- app/ui-utils/client/lib/prependReplies.js | 22 + app/ui-utils/client/lib/readMessages.js | 26 +- app/ui-utils/client/lib/rtl.js | 7 + app/ui-vrecord/client/VRecDialog.js | 12 +- app/ui-vrecord/client/vrecord.js | 3 +- app/ui/client/components/contextualBar.html | 12 +- app/ui/client/components/contextualBar.js | 9 +- app/ui/client/components/header/headerRoom.js | 11 - app/ui/client/index.js | 2 +- app/ui/client/lib/chatMessages.js | 749 ++++++++---------- app/ui/client/lib/fileUpload.js | 20 +- app/ui/client/lib/textarea-autogrow.js | 226 +++--- app/ui/client/lib/textarea-cursor.js | 19 + .../textarea-cursor/set-cursor-position.js | 17 - app/ui/client/views/app/room.html | 6 +- app/ui/client/views/app/room.js | 229 +++--- app/webdav/client/actionButton.js | 4 +- client/importPackages.js | 1 + client/methods/updateMessage.js | 2 +- .../message-read-receipt/client/message.js | 2 +- imports/message-read-receipt/client/room.js | 3 +- package-lock.json | 12 +- packages/rocketchat-i18n/i18n/en.i18n.json | 5 + private/client/imports/general/variables.css | 9 +- private/server/colors.less | 21 +- server/importPackages.js | 1 + server/methods/canAccessRoom.js | 30 +- server/publications/subscription.js | 1 + tests/end-to-end/ui/06-messaging.js | 4 +- tests/pageobjects/main-content.page.js | 1 + 135 files changed, 3407 insertions(+), 1938 deletions(-) create mode 100644 app/authorization/server/functions/canSendMessage.js create mode 100644 app/lib/server/methods/getMessages.js create mode 100644 app/threads/README.md create mode 100644 app/threads/client/flextab/thread.html create mode 100644 app/threads/client/flextab/thread.js create mode 100644 app/threads/client/flextab/threadlist.js create mode 100644 app/threads/client/flextab/threads.html create mode 100644 app/threads/client/flextab/threads.js create mode 100644 app/threads/client/index.js create mode 100644 app/threads/client/messageAction/follow.js create mode 100644 app/threads/client/messageAction/unfollow.js create mode 100644 app/threads/client/threads.css create mode 100644 app/threads/client/upsert.js create mode 100644 app/threads/server/functions.js create mode 100644 app/threads/server/hooks/afterReadMessages.js create mode 100644 app/threads/server/hooks/afterdeletemessage.js create mode 100644 app/threads/server/hooks/aftersavemessage.js create mode 100644 app/threads/server/hooks/index.js create mode 100644 app/threads/server/index.js create mode 100644 app/threads/server/methods/followMessage.js create mode 100644 app/threads/server/methods/getThreadMessages.js create mode 100644 app/threads/server/methods/getThreadsList.js create mode 100644 app/threads/server/methods/index.js create mode 100644 app/threads/server/methods/unfollowMessage.js create mode 100644 app/threads/server/settings.js create mode 100644 app/ui-message/client/messageBoxFormatting.js delete mode 100644 app/ui-message/client/startup/restoreText.js create mode 100644 app/ui-utils/client/lib/keyCodes.js create mode 100644 app/ui-utils/client/lib/messageArgs.js create mode 100644 app/ui-utils/client/lib/messageContext.js create mode 100644 app/ui-utils/client/lib/prependReplies.js create mode 100644 app/ui-utils/client/lib/rtl.js create mode 100644 app/ui/client/lib/textarea-cursor.js delete mode 100644 app/ui/client/lib/textarea-cursor/set-cursor-position.js diff --git a/app/action-links/client/init.js b/app/action-links/client/init.js index 5d687707a082..4f85d99d9d53 100644 --- a/app/action-links/client/init.js +++ b/app/action-links/client/init.js @@ -2,25 +2,28 @@ import { Blaze } from 'meteor/blaze'; import { Template } from 'meteor/templating'; import { handleError } from '../../utils'; import { fireGlobalEvent, Layout } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import { actionLinks } from '../both/lib/actionLinks'; + + Template.room.events({ 'click .action-link'(event, instance) { event.preventDefault(); event.stopPropagation(); const data = Blaze.getData(event.currentTarget); - + const { msg } = messageArgs(data); if (Layout.isEmbedded()) { return fireGlobalEvent('click-action-link', { actionlink: $(event.currentTarget).data('actionlink'), - value: data._arguments[1]._id, - message: data._arguments[1], + value: msg._id, + message: msg, }); } - if (data && data._arguments && data._arguments[1] && data._arguments[1]._id) { - actionLinks.run($(event.currentTarget).data('actionlink'), data._arguments[1]._id, instance, (err) => { + if (msg._id) { + actionLinks.run($(event.currentTarget).data('actionlink'), msg._id, instance, (err) => { if (err) { handleError(err); } diff --git a/app/authorization/server/functions/canSendMessage.js b/app/authorization/server/functions/canSendMessage.js new file mode 100644 index 000000000000..5bff36f09e40 --- /dev/null +++ b/app/authorization/server/functions/canSendMessage.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import { Rooms, Subscriptions } from '../../../models'; +import { canAccessRoom } from './canAccessRoom'; + +export const canSendMessage = (rid, { uid, username }, extraData) => { + const room = Rooms.findOneById(rid); + + if (!canAccessRoom.call(this, room, { _id: uid, username }, extraData)) { + throw new Meteor.Error('error-not-allowed'); + } + + const subscription = Subscriptions.findOneByRoomIdAndUserId(rid, uid); + if (subscription && (subscription.blocked || subscription.blocker)) { + throw new Meteor.Error('room_is_blocked'); + } + + if ((room.muted || []).includes(username)) { + throw new Meteor.Error('You_have_been_muted'); + } + + return room; +}; diff --git a/app/authorization/server/index.js b/app/authorization/server/index.js index 00a143d0ba69..a4d74beba167 100644 --- a/app/authorization/server/index.js +++ b/app/authorization/server/index.js @@ -1,5 +1,6 @@ import { addUserRoles } from './functions/addUserRoles'; import { addRoomAccessValidator, canAccessRoom, roomAccessValidators } from './functions/canAccessRoom'; +import { canSendMessage } from './functions/canSendMessage'; import { getRoles } from './functions/getRoles'; import { getUsersInRole } from './functions/getUsersInRole'; import { hasAllPermission, hasAtLeastOnePermission, hasPermission } from './functions/hasPermission'; @@ -25,6 +26,7 @@ export { hasRole, removeUserFromRoles, canAccessRoom, + canSendMessage, addRoomAccessValidator, roomAccessValidators, addUserRoles, diff --git a/app/autotranslate/client/lib/actionButton.js b/app/autotranslate/client/lib/actionButton.js index 8bc7f46cc8fa..2c1069095deb 100644 --- a/app/autotranslate/client/lib/actionButton.js +++ b/app/autotranslate/client/lib/actionButton.js @@ -3,6 +3,7 @@ import { Tracker } from 'meteor/tracker'; import { settings } from '../../../settings'; import { hasAtLeastOnePermission } from '../../../authorization'; import { MessageAction } from '../../../ui-utils'; +import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; import { Messages } from '../../../models'; import { AutoTranslate } from './autotranslate'; @@ -18,7 +19,7 @@ Meteor.startup(function() { 'message-mobile', ], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); const language = AutoTranslate.getLanguage(message.rid); if ((!message.translations || !message.translations[language])) { // } && !_.find(message.attachments, attachment => { return attachment.translations && attachment.translations[language]; })) { AutoTranslate.messageIdsToWait[message._id] = true; diff --git a/app/channel-settings/client/startup/tabBar.js b/app/channel-settings/client/startup/tabBar.js index 89e032c8a65f..798dff511e36 100644 --- a/app/channel-settings/client/startup/tabBar.js +++ b/app/channel-settings/client/startup/tabBar.js @@ -9,6 +9,6 @@ Meteor.startup(() => { i18nTitle: 'Room_Info', icon: 'info-circled', template: 'channelSettings', - order: 0, + order: 1, }); }); diff --git a/app/discussion/client/createDiscussionMessageAction.js b/app/discussion/client/createDiscussionMessageAction.js index eebf5853d009..cd3a43ea9b28 100644 --- a/app/discussion/client/createDiscussionMessageAction.js +++ b/app/discussion/client/createDiscussionMessageAction.js @@ -5,6 +5,7 @@ import { Subscriptions } from '../../models/client'; import { settings } from '../../settings/client'; import { hasPermission } from '../../authorization/client'; import { MessageAction, modal } from '../../ui-utils/client'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import { t } from '../../utils/client'; const condition = (rid, uid) => { @@ -26,7 +27,7 @@ Meteor.startup(function() { label: 'Discussion_start', context: ['message', 'message-mobile'], async action() { - const [, message] = this._arguments; + const { msg: message } = messageArgs(this); modal.open({ title: t('Discussion_title'), diff --git a/app/discussion/client/views/DiscussionTabbar.html b/app/discussion/client/views/DiscussionTabbar.html index e13803e67551..e0b930246220 100644 --- a/app/discussion/client/views/DiscussionTabbar.html +++ b/app/discussion/client/views/DiscussionTabbar.html @@ -8,9 +8,9 @@

{{_ "No_discussions_yet"}}

{{/if}}
{{#if hasMore}} diff --git a/app/discussion/client/views/DiscussionTabbar.js b/app/discussion/client/views/DiscussionTabbar.js index 5b048fd0367a..4ade9d4cc72e 100644 --- a/app/discussion/client/views/DiscussionTabbar.js +++ b/app/discussion/client/views/DiscussionTabbar.js @@ -1,25 +1,17 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; - +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; import { DiscussionOfRoom } from '../lib/discussionsOfRoom'; import './DiscussionTabbar.html'; Template.discussionsTabbar.helpers({ hasMessages() { - return DiscussionOfRoom.find({ - rid: this.rid, - }).count() > 0; + return Template.instance().cursor > 0; }, messages() { - return DiscussionOfRoom.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }); + Template.instance().cursor; }, message() { return _.extend(this, { customClass: 'pinned', actionContext: 'pinned' }); @@ -27,9 +19,18 @@ Template.discussionsTabbar.helpers({ hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.discussionsTabbar.onCreated(function() { + this.rid = this.data.rid; + this.cursor = DiscussionOfRoom.find({ + rid: this.rid, + }, { + sort: { + ts: -1, + }, + }); this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); return this.autorun(() => { diff --git a/app/file-upload/server/methods/sendFileMessage.js b/app/file-upload/server/methods/sendFileMessage.js index 84072c62525e..2bd882a95ed5 100644 --- a/app/file-upload/server/methods/sendFileMessage.js +++ b/app/file-upload/server/methods/sendFileMessage.js @@ -24,6 +24,7 @@ Meteor.methods({ alias: Match.Optional(String), groupable: Match.Optional(Boolean), msg: Match.Optional(String), + tmid: Match.Optional(String), }); Uploads.updateFileComplete(file._id, Meteor.userId(), _.omit(file, '_id')); diff --git a/app/lib/client/defaultTabBars.js b/app/lib/client/defaultTabBars.js index 4093f772fa7f..d8071d009e2a 100644 --- a/app/lib/client/defaultTabBars.js +++ b/app/lib/client/defaultTabBars.js @@ -9,7 +9,7 @@ TabBar.addButton({ i18nTitle: 'Search_Messages', icon: 'magnifier', template: 'RocketSearch', - order: 1, + order: 2, }); TabBar.addButton({ diff --git a/app/lib/client/lib/formatDate.js b/app/lib/client/lib/formatDate.js index ec39f8d77c72..2d2bd13b785a 100644 --- a/app/lib/client/lib/formatDate.js +++ b/app/lib/client/lib/formatDate.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { getUserPreference } from '../../../utils'; +import { getUserPreference, t } from '../../../utils'; import { settings } from '../../../settings'; import moment from 'moment'; @@ -25,4 +25,15 @@ export const formatDateAndTime = (time) => { } }; +export const timeAgo = (time) => { + const now = new Date(); + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + + return ( + (now.getDate() === time.getDate() && moment(time).format('LT')) || + (yesterday.getDate() === time.getDate() && t('yesterday')) || + moment(time).format('L') + ); +}; + export const formatDate = (time) => moment(time).format(settings.get('Message_DateFormat')); diff --git a/app/lib/server/functions/deleteMessage.js b/app/lib/server/functions/deleteMessage.js index 36f56f8753da..61e9ea823119 100644 --- a/app/lib/server/functions/deleteMessage.js +++ b/app/lib/server/functions/deleteMessage.js @@ -39,9 +39,7 @@ export const deleteMessage = function(message, user) { } const room = Rooms.findOneById(message.rid, { fields: { lastMessage: 1, prid: 1, mid: 1 } }); - Meteor.defer(function() { - callbacks.run('afterDeleteMessage', deletedMsg); - }); + callbacks.run('afterDeleteMessage', deletedMsg, room, user); // update last message if (settings.get('Store_Last_Message')) { diff --git a/app/lib/server/functions/notifications/audio.js b/app/lib/server/functions/notifications/audio.js index b52f0f2f78bb..a943e7dbd032 100644 --- a/app/lib/server/functions/notifications/audio.js +++ b/app/lib/server/functions/notifications/audio.js @@ -11,9 +11,10 @@ export function shouldNotifyAudio({ hasMentionToHere, isHighlighted, hasMentionToUser, + hasReplyToThread, roomType, }) { - if (disableAllMessageNotifications && audioNotifications == null) { + if (disableAllMessageNotifications && audioNotifications == null && !hasReplyToThread) { return false; } @@ -25,7 +26,7 @@ export function shouldNotifyAudio({ return true; } - return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || audioNotifications === 'all' || hasMentionToUser; + return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || audioNotifications === 'all' || hasMentionToUser || 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 d4aefee34b48..880a503e459e 100644 --- a/app/lib/server/functions/notifications/desktop.js +++ b/app/lib/server/functions/notifications/desktop.js @@ -50,9 +50,10 @@ export function shouldNotifyDesktop({ hasMentionToHere, isHighlighted, hasMentionToUser, + hasReplyToThread, roomType, }) { - if (disableAllMessageNotifications && desktopNotifications == null && !isHighlighted && !hasMentionToUser) { + if (disableAllMessageNotifications && desktopNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { return false; } @@ -69,5 +70,5 @@ export function shouldNotifyDesktop({ } } - return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || desktopNotifications === 'all' || hasMentionToUser; + return roomType === 'd' || (!disableAllMessageNotifications && (hasMentionToAll || hasMentionToHere)) || isHighlighted || desktopNotifications === 'all' || hasMentionToUser || hasReplyToThread; } diff --git a/app/lib/server/functions/notifications/email.js b/app/lib/server/functions/notifications/email.js index 2aeb62629413..26f24e4d8d90 100644 --- a/app/lib/server/functions/notifications/email.js +++ b/app/lib/server/functions/notifications/email.js @@ -134,6 +134,7 @@ export function shouldNotifyEmail({ isHighlighted, hasMentionToUser, hasMentionToAll, + hasReplyToThread, roomType, }) { @@ -149,7 +150,7 @@ export function shouldNotifyEmail({ // no user or room preference if (emailNotifications == null) { - if (disableAllMessageNotifications && !isHighlighted && !hasMentionToUser) { + if (disableAllMessageNotifications && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { return false; } @@ -159,5 +160,5 @@ export function shouldNotifyEmail({ } } - return roomType === 'd' || isHighlighted || emailNotifications === 'all' || hasMentionToUser || (!disableAllMessageNotifications && hasMentionToAll); + return roomType === 'd' || isHighlighted || emailNotifications === 'all' || hasMentionToUser || hasReplyToThread || (!disableAllMessageNotifications && hasMentionToAll); } diff --git a/app/lib/server/functions/notifications/mobile.js b/app/lib/server/functions/notifications/mobile.js index c57eae48f697..de4fe7f286ff 100644 --- a/app/lib/server/functions/notifications/mobile.js +++ b/app/lib/server/functions/notifications/mobile.js @@ -70,10 +70,11 @@ export function shouldNotifyMobile({ hasMentionToAll, isHighlighted, hasMentionToUser, + hasReplyToThread, statusConnection, roomType, }) { - if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser) { + if (disableAllMessageNotifications && mobilePushNotifications == null && !isHighlighted && !hasMentionToUser && !hasReplyToThread) { return false; } @@ -94,5 +95,5 @@ export function shouldNotifyMobile({ } } - return roomType === 'd' || (!disableAllMessageNotifications && hasMentionToAll) || isHighlighted || mobilePushNotifications === 'all' || hasMentionToUser; + return roomType === 'd' || (!disableAllMessageNotifications && hasMentionToAll) || isHighlighted || mobilePushNotifications === 'all' || hasMentionToUser || hasReplyToThread; } diff --git a/app/lib/server/index.js b/app/lib/server/index.js index 32753a449ea6..2ca58a15f606 100644 --- a/app/lib/server/index.js +++ b/app/lib/server/index.js @@ -44,6 +44,7 @@ import './methods/getRoomJoinCode'; import './methods/getRoomRoles'; import './methods/getServerInfo'; import './methods/getSingleMessage'; +import './methods/getMessages'; import './methods/getSlashCommandPreviews'; import './methods/getUsernameSuggestion'; import './methods/getUserRoles'; diff --git a/app/lib/server/lib/notifyUsersOnMessage.js b/app/lib/server/lib/notifyUsersOnMessage.js index 1b857853266c..0abfb6de46b0 100644 --- a/app/lib/server/lib/notifyUsersOnMessage.js +++ b/app/lib/server/lib/notifyUsersOnMessage.js @@ -23,32 +23,21 @@ export function messageContainsHighlight(message, highlights) { }); } -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; - } else if (message.editedAt) { - - // 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)) { - Rooms.setLastMessageById(message.rid, message); - } - return message; - } - - if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { - Rooms.incMsgCountById(message.rid, 1); - return message; - } - +export function updateUsersSubscriptions(message, room, users) { if (room != null) { let toAll = false; let toHere = false; const mentionIds = []; const highlightsIds = []; - const highlights = Subscriptions.findByRoomWithUserHighlights(room._id, { fields: { userHighlights: 1, 'u._id': 1 } }).fetch(); + + const highlightOptions = { fields: { userHighlights: 1, 'u._id': 1 } }; + + const highlights = users ? + Subscriptions.findByRoomAndUsersWithUserHighlights(room._id, users, highlightOptions).fetch() : + Subscriptions.findByRoomWithUserHighlights(room._id, highlightOptions).fetch(); + + // const usersToNotify = users || [message.u._id]; + if (message.mentions != null) { message.mentions.forEach(function(mention) { if (!toAll && mention._id === 'all') { @@ -91,12 +80,12 @@ function notifyUsersOnMessage(message, room) { } Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, incUnread); - } else if ((mentionIds && mentionIds.length > 0) || (highlightsIds && highlightsIds.length > 0)) { + } else if (users || (mentionIds && mentionIds.length > 0) || (highlightsIds && highlightsIds.length > 0)) { let incUnread = 0; - if (['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount)) { + if (users || ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount)) { incUnread = 1; } - Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, _.compact(_.unique(mentionIds.concat(highlightsIds))), 1, incUnread); + Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, _.compact(_.unique(mentionIds.concat(highlightsIds, users))), 1, incUnread); } else if (unreadCount === 'all_messages') { Subscriptions.incUnreadForRoomIdExcludingUserId(room._id, message.u._id); } @@ -111,6 +100,33 @@ function notifyUsersOnMessage(message, room) { // 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); +} + +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; + } else if (message.editedAt) { + + // 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)) { + Rooms.setLastMessageById(message.rid, message); + } + return message; + } + + if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { + Rooms.incMsgCountById(message.rid, 1); + return message; + } + + if (message.tmid) { + return message; + } + + updateUsersSubscriptions(message, room); return message; } diff --git a/app/lib/server/lib/sendNotificationsOnMessage.js b/app/lib/server/lib/sendNotificationsOnMessage.js index 311fb2d753b3..0915e4505904 100644 --- a/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/app/lib/server/lib/sendNotificationsOnMessage.js @@ -11,9 +11,10 @@ import { sendSinglePush, shouldNotifyMobile } from '../functions/notifications/m import { notifyDesktopUser, shouldNotifyDesktop } from '../functions/notifications/desktop'; import { notifyAudioUser, shouldNotifyAudio } from '../functions/notifications/audio'; -const sendNotification = async ({ +export const sendNotification = async ({ subscription, sender, + hasReplyToThread, hasMentionToAll, hasMentionToHere, message, @@ -31,7 +32,7 @@ const sendNotification = async ({ const hasMentionToUser = mentionIds.includes(subscription.u._id); // mute group notifications (@here and @all) if not directly mentioned as well - if (!hasMentionToUser && subscription.muteGroupMentions && (hasMentionToAll || hasMentionToHere)) { + if (!hasMentionToUser && !hasReplyToThread && subscription.muteGroupMentions && (hasMentionToAll || hasMentionToHere)) { return; } @@ -64,6 +65,7 @@ const sendNotification = async ({ hasMentionToHere, isHighlighted, hasMentionToUser, + hasReplyToThread, roomType, })) { notifyAudioUser(subscription.u._id, message, room); @@ -79,6 +81,7 @@ const sendNotification = async ({ hasMentionToHere, isHighlighted, hasMentionToUser, + hasReplyToThread, roomType, })) { notifyDesktopUser({ @@ -97,6 +100,7 @@ const sendNotification = async ({ hasMentionToAll, isHighlighted, hasMentionToUser, + hasReplyToThread, statusConnection: receiver.statusConnection, roomType, })) { @@ -118,6 +122,7 @@ const sendNotification = async ({ isHighlighted, hasMentionToUser, hasMentionToAll, + hasReplyToThread, roomType, })) { receiver.emails.some((email) => { @@ -166,27 +171,13 @@ const lookup = { }, }; -async function sendAllNotifications(message, room) { - - // skips this callback if the message was edited - if (message.editedAt) { - return message; - } - - if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { - return message; - } - - if (!room || room.t == null) { - return message; - } - +export async function sendMessageNotifications(message, room, usersInThread = []) { const sender = roomTypes.getConfig(room.t).getMsgSender(message.u._id); if (!sender) { return message; } - const mentionIds = (message.mentions || []).map(({ _id }) => _id); + const mentionIds = (message.mentions || []).map(({ _id }) => _id).concat(usersInThread); // add users in thread to mentions array because they follow the same rules const mentionIdsWithoutGroups = mentionIds.filter((_id) => _id !== 'all' && _id !== 'here'); const hasMentionToAll = mentionIds.includes('all'); const hasMentionToHere = mentionIds.includes('here'); @@ -202,6 +193,7 @@ async function sendAllNotifications(message, room) { const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0; const query = { + ...(usersInThread && { 'u._id': { $in: usersInThread } }), rid: room._id, ignored: { $ne: sender._id }, disableNotifications: { $ne: true }, @@ -238,7 +230,7 @@ async function sendAllNotifications(message, room) { query.$or.push({ [notificationField]: { $exists: false }, }); - } else if (serverPreference === 'mentions' && mentionIdsWithoutGroups.length) { + } else if (serverPreference === 'mentions' && mentionIdsWithoutGroups.length > 0) { query.$or.push({ [notificationField]: { $exists: false }, 'u._id': { $in: mentionIdsWithoutGroups }, @@ -266,8 +258,46 @@ async function sendAllNotifications(message, room) { room, mentionIds, disableAllMessageNotifications, + hasReplyToThread: usersInThread && usersInThread.includes(subscription.u._id), })); + return { + sender, + hasMentionToAll, + hasMentionToHere, + notificationMessage, + mentionIds, + mentionIdsWithoutGroups, + }; +} + +async function sendAllNotifications(message, room) { + // threads + if (message.tmid) { + return message; + } + // skips this callback if the message was edited + if (message.editedAt) { + return message; + } + + if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { + return message; + } + + if (!room || room.t == null) { + return message; + } + + const { + sender, + hasMentionToAll, + hasMentionToHere, + notificationMessage, + mentionIds, + mentionIdsWithoutGroups, + } = await sendMessageNotifications(message, room); + // on public channels, if a mentioned user is not member of the channel yet, he will first join the channel and then be notified based on his preferences. if (room.t === 'c') { // get subscriptions from users already in room (to not send them a notification) @@ -309,5 +339,3 @@ async function sendAllNotifications(message, room) { } callbacks.add('afterSaveMessage', (message, room) => Promise.await(sendAllNotifications(message, room)), callbacks.priority.LOW, 'sendNotificationsOnMessage'); - -export { sendNotification, sendAllNotifications }; diff --git a/app/lib/server/methods/getMessages.js b/app/lib/server/methods/getMessages.js new file mode 100644 index 000000000000..6f950748a1ae --- /dev/null +++ b/app/lib/server/methods/getMessages.js @@ -0,0 +1,31 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Messages } from '../../../models'; + +Meteor.methods({ + getMessages(messages) { + check(messages, [String]); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getSingleMessage' }); + } + + const cache = {}; + + return messages.map((msgId) => { + const msg = Messages.findOneById(msgId); + + if (!msg || !msg.rid) { + return undefined; + } + + cache[msg.rid] = cache[msg.rid] || Meteor.call('canAccessRoom', msg.rid, Meteor.userId()); + + if (!cache[msg.rid]) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getSingleMessage' }); + } + + return msg; + }); + }, +}); diff --git a/app/lib/server/methods/sendMessage.js b/app/lib/server/methods/sendMessage.js index eece8bc79009..0fd071583281 100644 --- a/app/lib/server/methods/sendMessage.js +++ b/app/lib/server/methods/sendMessage.js @@ -2,28 +2,34 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import { Random } from 'meteor/random'; import { TAPi18n } from 'meteor/tap:i18n'; + +import moment from 'moment'; + import { hasPermission } from '../../../authorization'; import { metrics } from '../../../metrics'; import { settings } from '../../../settings'; import { Notifications } from '../../../notifications'; import { messageProperties } from '../../../ui-utils'; -import { Subscriptions, Users } from '../../../models'; +import { Users, Messages } from '../../../models'; import { sendMessage } from '../functions'; import { RateLimiter } from '../lib'; -import moment from 'moment'; +import { canSendMessage } from '../../../authorization/server'; Meteor.methods({ sendMessage(message) { check(message, Object); - if (!Meteor.userId()) { + const uid = Meteor.userId(); + if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'sendMessage', }); } - if (!message.rid) { - throw new Error('The \'rid\' property on the message object is missing.'); + if (message.tmid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { + method: 'sendMessage', + }); } if (message.ts) { @@ -51,45 +57,46 @@ Meteor.methods({ } } - const user = Users.findOneById(Meteor.userId(), { + const user = Users.findOneById(uid, { fields: { username: 1, - name: 1, + ...(!!settings.get('Message_SetNameToAliasEnabled') && { name: 1 }), }, }); + let { rid } = message; - const room = Meteor.call('canAccessRoom', message.rid, user._id); - if (!room) { - return false; + // do not allow nested threads + if (message.tmid) { + const parentMessage = Messages.findOneById(message.tmid); + message.tmid = parentMessage.tmid || message.tmid; + rid = parentMessage.rid; } - const subscription = Subscriptions.findOneByRoomIdAndUserId(message.rid, Meteor.userId()); - if (subscription && (subscription.blocked || subscription.blocker)) { - Notifications.notifyUser(Meteor.userId(), 'message', { - _id: Random.id(), - rid: room._id, - ts: new Date, - msg: TAPi18n.__('room_is_blocked', {}, user.language), - }); - throw new Meteor.Error('You can\'t send messages because you are blocked'); + if (!rid) { + throw new Error('The \'rid\' property on the message object is missing.'); } - if ((room.muted || []).includes(user.username)) { - Notifications.notifyUser(Meteor.userId(), 'message', { + try { + const room = canSendMessage(rid, { uid, username: user.username }); + if (message.alias == null && settings.get('Message_SetNameToAliasEnabled')) { + message.alias = user.name; + } + + metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 + return sendMessage(user, message, room); + + } catch (error) { + if (error === 'error-not-allowed') { + throw new Meteor.Error('error-not-allowed'); + } + + Notifications.notifyUser(uid, 'message', { _id: Random.id(), - rid: room._id, + rid: message.rid, ts: new Date, - msg: TAPi18n.__('You_have_been_muted', {}, user.language), + msg: TAPi18n.__(error, {}, user.language), }); - throw new Meteor.Error('You can\'t send messages because you have been muted'); - } - - if (message.alias == null && settings.get('Message_SetNameToAliasEnabled')) { - message.alias = user.name; } - - metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 - return sendMessage(user, message, room); }, }); // Limit a user, who does not have the "bot" role, to sending 5 msgs/second diff --git a/app/mentions-flextab/client/actionButton.js b/app/mentions-flextab/client/actionButton.js index a9e585d29567..63eca8abccb6 100644 --- a/app/mentions-flextab/client/actionButton.js +++ b/app/mentions-flextab/client/actionButton.js @@ -1,23 +1,21 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { MessageAction, RoomHistoryManager } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; Meteor.startup(function() { MessageAction.addButton({ id: 'jump-to-message', icon: 'jump', label: 'Jump_to_message', - context: ['mentions'], + context: ['mentions', 'threads'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { Template.instance().tabBar.close(); } RoomHistoryManager.getSurroundingMessages(message, 50); }, - condition(message) { - return message.mentionedList === true; - }, order: 100, group: 'menu', }); diff --git a/app/mentions-flextab/client/views/mentionsFlexTab.html b/app/mentions-flextab/client/views/mentionsFlexTab.html index 24091e777d91..bf8413811dff 100644 --- a/app/mentions-flextab/client/views/mentionsFlexTab.html +++ b/app/mentions-flextab/client/views/mentionsFlexTab.html @@ -8,9 +8,9 @@

{{_ "No_mentions_found"}}

{{/if}}
    - {{#each messages}} - {{#nrr nrrargs 'message' message}}{{/nrr}} - {{/each}} + {{# with messageContext}} + {{#each msg in messages}}{{#nrr nrrargs 'message' msg=msg room=room subscription=subscription settings=settings u=u}}{{/nrr}}{{/each}} + {{/with}}
{{#if hasMore}}
diff --git a/app/mentions-flextab/client/views/mentionsFlexTab.js b/app/mentions-flextab/client/views/mentionsFlexTab.js index 0cd0b4c8fe17..24268b57ac94 100644 --- a/app/mentions-flextab/client/views/mentionsFlexTab.js +++ b/app/mentions-flextab/client/views/mentionsFlexTab.js @@ -2,25 +2,13 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import { MentionedMessage } from '../lib/MentionedMessage'; - +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; Template.mentionsFlexTab.helpers({ hasMessages() { - return MentionedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }).count() > 0; + return Template.instance().cursor.count() > 0; }, messages() { - return MentionedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }); + return Template.instance().cursor; }, message() { return _.extend(this, { customClass: 'mentions', actionContext: 'mentions' }); @@ -28,9 +16,17 @@ Template.mentionsFlexTab.helpers({ hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.mentionsFlexTab.onCreated(function() { + this.cursor = MentionedMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); return this.autorun(() => { diff --git a/app/message-mark-as-unread/client/actionButton.js b/app/message-mark-as-unread/client/actionButton.js index 88a6c72b1d81..ae6afef256bc 100644 --- a/app/message-mark-as-unread/client/actionButton.js +++ b/app/message-mark-as-unread/client/actionButton.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { RoomManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import { handleError } from '../../utils'; import { ChatSubscription } from '../../models'; @@ -11,7 +12,7 @@ Meteor.startup(() => { label: 'Mark_as_unread', context: ['message', 'message-mobile'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); return Meteor.call('unreadMessages', message, function(error) { if (error) { return handleError(error); @@ -27,7 +28,7 @@ Meteor.startup(() => { }); }, condition(message) { - return message.u._id !== Meteor.user()._id; + return Meteor.userId() && message.u._id !== Meteor.userId(); }, order: 22, group: 'menu', diff --git a/app/message-pin/client/actionButton.js b/app/message-pin/client/actionButton.js index fddb91165c55..38fb313ac9af 100644 --- a/app/message-pin/client/actionButton.js +++ b/app/message-pin/client/actionButton.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Template } from 'meteor/templating'; import { TAPi18n } from 'meteor/tap:i18n'; import { RoomHistoryManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import { handleError } from '../../utils'; import { settings } from '../../settings'; import { Subscriptions } from '../../models'; @@ -15,7 +16,7 @@ Meteor.startup(function() { label: 'Pin_Message', context: ['pinned', 'message', 'message-mobile'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); message.pinned = true; Meteor.call('pinMessage', message, function(error) { if (error) { @@ -40,7 +41,7 @@ Meteor.startup(function() { label: 'Unpin_Message', context: ['pinned', 'message', 'message-mobile'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); message.pinned = false; Meteor.call('unpinMessage', message, function(error) { if (error) { @@ -65,7 +66,7 @@ Meteor.startup(function() { label: 'Jump_to_message', context: ['pinned'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { Template.instance().tabBar.close(); } @@ -88,7 +89,7 @@ Meteor.startup(function() { classes: 'clipboard', context: ['pinned'], async action(event) { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); $(event.currentTarget).attr('data-clipboard-text', await MessageAction.getPermaLink(message._id)); toastr.success(TAPi18n.__('Copied')); }, diff --git a/app/message-pin/client/views/pinnedMessages.html b/app/message-pin/client/views/pinnedMessages.html index de4e7766165a..7ad911de4ab0 100644 --- a/app/message-pin/client/views/pinnedMessages.html +++ b/app/message-pin/client/views/pinnedMessages.html @@ -8,9 +8,9 @@

{{_ "No_pinned_messages"}}

{{/if}}
    - {{#each messages}} - {{#nrr nrrargs 'message' message}}{{/nrr}} - {{/each}} + {{# with messageContext}} + {{#each msg in messages}}{{#nrr nrrargs 'message' msg=msg room=room subscription=subscription settings=settings u=u}}{{/nrr}}{{/each}} + {{/with}}
{{#if hasMore}} diff --git a/app/message-pin/client/views/pinnedMessages.js b/app/message-pin/client/views/pinnedMessages.js index f02237566deb..dc4bc2ee11c1 100644 --- a/app/message-pin/client/views/pinnedMessages.js +++ b/app/message-pin/client/views/pinnedMessages.js @@ -2,25 +2,14 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import { PinnedMessage } from '../lib/PinnedMessage'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; Template.pinnedMessages.helpers({ hasMessages() { - return PinnedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }).count() > 0; + return Template.instance().cursor.count() > 0; }, messages() { - return PinnedMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }); + return Template.instance().cursor; }, message() { return _.extend(this, { customClass: 'pinned', actionContext: 'pinned' }); @@ -28,17 +17,26 @@ Template.pinnedMessages.helpers({ hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.pinnedMessages.onCreated(function() { + this.rid = this.data.rid; + + this.cursor = PinnedMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); + this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); return this.autorun(() => { const data = Template.currentData(); return this.subscribe('pinnedMessages', data.rid, this.limit.get(), () => { - if (PinnedMessage.find({ - rid: data.rid, - }).count() < this.limit.get()) { + if (this.cursor.count() < this.limit.get()) { return this.hasMore.set(false); } }); diff --git a/app/message-snippet/client/actionButton.js b/app/message-snippet/client/actionButton.js index fb61e73d3ab8..e50c89c767ca 100644 --- a/app/message-snippet/client/actionButton.js +++ b/app/message-snippet/client/actionButton.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { MessageAction, modal } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import { t, handleError } from '../../utils'; import { settings } from '../../settings'; import { Subscriptions } from '../../models'; @@ -18,7 +19,7 @@ Meteor.startup(function() { order: 10, group: 'menu', action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); modal.open({ title: 'Create a Snippet', diff --git a/app/message-snippet/client/tabBar/views/snippetedMessages.html b/app/message-snippet/client/tabBar/views/snippetedMessages.html index fe36358e7012..539dabf6e0f7 100644 --- a/app/message-snippet/client/tabBar/views/snippetedMessages.html +++ b/app/message-snippet/client/tabBar/views/snippetedMessages.html @@ -11,9 +11,9 @@

{{_ "No_snippet_messages"}}

    - {{#each messages}} - {{#nrr nrrargs 'message' message}}{{/nrr}} - {{/each}} + {{# with messageContext}} + {{#each msg in messages}}{{#nrr nrrargs 'message' msg=msg room=room subscription=subscription settings=settings u=u}}{{/nrr}}{{/each}} + {{/with}}
{{#if hasMore}}
diff --git a/app/message-snippet/client/tabBar/views/snippetedMessages.js b/app/message-snippet/client/tabBar/views/snippetedMessages.js index c764e0894542..660c08769f96 100644 --- a/app/message-snippet/client/tabBar/views/snippetedMessages.js +++ b/app/message-snippet/client/tabBar/views/snippetedMessages.js @@ -2,13 +2,14 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import { SnippetedMessages } from '../../lib/collections'; +import { messageContext } from '../../../../ui-utils/client/lib/messageContext'; Template.snippetedMessages.helpers({ hasMessages() { - return SnippetedMessages.find({ snippeted:true, rid: this.rid }, { sort: { ts: -1 } }).count() > 0; + return Template.instance().cursor.count() > 0; }, messages() { - return SnippetedMessages.find({ snippeted: true, rid: this.rid }, { sort: { ts: -1 } }); + return Template.instance().cursor; }, message() { return _.extend(this, { customClass: 'snippeted', actionContext: 'snippeted' }); @@ -16,17 +17,19 @@ Template.snippetedMessages.helpers({ hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.snippetedMessages.onCreated(function() { + this.rid = this.data.rid; + this.cursor = SnippetedMessages.find({ snippeted:true, rid: this.data.rid }, { sort: { ts: -1 } }); this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); - const self = this; - this.autorun(function() { + this.autorun(() => { const data = Template.currentData(); - self.subscribe('snippetedMessages', data.rid, self.limit.get(), function() { - if (SnippetedMessages.find({ snippeted: true, rid: data.rid }).count() < self.limit.get()) { - return self.hasMore.set(false); + this.subscribe('snippetedMessages', data.rid, this.limit.get(), function() { + if (this.cursor.count() < this.limit.get()) { + return this.hasMore.set(false); } }); }); diff --git a/app/message-star/client/actionButton.js b/app/message-star/client/actionButton.js index 5ea9b8c6720c..bb6d6c50329e 100644 --- a/app/message-star/client/actionButton.js +++ b/app/message-star/client/actionButton.js @@ -5,8 +5,8 @@ import { handleError } from '../../utils'; import { Subscriptions } from '../../models'; import { settings } from '../../settings'; import { RoomHistoryManager, MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; import toastr from 'toastr'; - Meteor.startup(function() { MessageAction.addButton({ id: 'star-message', @@ -14,7 +14,7 @@ Meteor.startup(function() { label: 'Star_Message', context: ['starred', 'message', 'message-mobile'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); message.starred = Meteor.userId(); Meteor.call('starMessage', message, function(error) { if (error) { @@ -39,7 +39,7 @@ Meteor.startup(function() { label: 'Unstar_Message', context: ['starred', 'message', 'message-mobile'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); message.starred = false; Meteor.call('starMessage', message, function(error) { if (error) { @@ -64,7 +64,7 @@ Meteor.startup(function() { label: 'Jump_to_message', context: ['starred'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); if (window.matchMedia('(max-width: 500px)').matches) { Template.instance().tabBar.close(); } @@ -87,7 +87,7 @@ Meteor.startup(function() { classes: 'clipboard', context: ['starred'], async action(event) { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); $(event.currentTarget).attr('data-clipboard-text', await MessageAction.getPermaLink(message._id)); toastr.success(TAPi18n.__('Copied')); }, diff --git a/app/message-star/client/views/starredMessages.html b/app/message-star/client/views/starredMessages.html index 554ba66ba0af..89a8e8c7159d 100644 --- a/app/message-star/client/views/starredMessages.html +++ b/app/message-star/client/views/starredMessages.html @@ -8,9 +8,9 @@

{{_ "No_starred_messages"}}

{{/if}}
    - {{#each messages}} - {{#nrr nrrargs 'message' message}}{{/nrr}} - {{/each}} + {{# with messageContext}} + {{#each msg in messages}}{{#nrr nrrargs 'message' msg=msg room=room subscription=subscription settings=settings u=u}}{{/nrr}}{{/each}} + {{/with}}
{{#if hasMore}}
diff --git a/app/message-star/client/views/starredMessages.js b/app/message-star/client/views/starredMessages.js index 17da2652de03..0326db20f72c 100644 --- a/app/message-star/client/views/starredMessages.js +++ b/app/message-star/client/views/starredMessages.js @@ -2,42 +2,40 @@ import _ from 'underscore'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import { StarredMessage } from '../lib/StarredMessage'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; Template.starredMessages.helpers({ hasMessages() { - return StarredMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }).count() > 0; + return Template.instance().cursor.count() > 0; }, messages() { - return StarredMessage.find({ - rid: this.rid, - }, { - sort: { - ts: -1, - }, - }); + return Template.instance().cursor; }, message() { - return _.extend(this, { customClass: 'starred', actionContext: 'starred' }); + return _.extend(this, { actionContext: 'starred' }); }, hasMore() { return Template.instance().hasMore.get(); }, + messageContext, }); Template.starredMessages.onCreated(function() { + this.rid = this.data.rid; + + this.cursor = StarredMessage.find({ + rid: this.data.rid, + }, { + sort: { + ts: -1, + }, + }); this.hasMore = new ReactiveVar(true); this.limit = new ReactiveVar(50); this.autorun(() => { const sub = this.subscribe('starredMessages', this.data.rid, this.limit.get()); - const findStarredMessage = StarredMessage.find({ rid: this.data.rid }); if (sub.ready()) { - if (findStarredMessage.count() < this.limit.get()) { + if (this.cursor.count() < this.limit.get()) { return this.hasMore.set(false); } } diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index ec617d87db65..48b785bfc674 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -29,6 +29,10 @@ export class Messages extends Base { // discussions this.tryEnsureIndex({ drid: 1 }, { sparse: true }); + // threads + this.tryEnsureIndex({ tmid: 1 }, { sparse: true }); + this.tryEnsureIndex({ tcount: 1, tlm: 1 }, { sparse: true }); + } setReactions(messageId, reactions) { @@ -990,6 +994,115 @@ export class Messages extends Base { }, }, { multi: 1 }); } + + // ////////////////////////////////////////////////////////////////// + // threads + + removeThreadRefByThreadId(tmid) { + const query = { tmid }; + const update = { + $unset: { + tmid: 1, + }, + }; + return this.update(query, update, { multi: true }); + } + + updateRepliesByThreadId(tmid, replies, ts) { + const query = { + _id: tmid, + }; + + const update = { + $addToSet: { + replies: { + $each: replies, + }, + }, + $set: { + tlm: ts, + }, + $inc: { + tcount: 1, + }, + }; + + return this.update(query, update); + } + + getThreadFollowsByThreadId(tmid) { + const msg = this.findOneById(tmid, { fields: { replies: 1 } }); + return msg && msg.replies; + } + + getFirstReplyTsByThreadId(tmid) { + return this.findOne({ tmid }, { fields: { ts: 1 }, sort: { ts: 1 } }); + } + + unsetThreadByThreadId(tmid) { + const query = { + _id: tmid, + }; + + const update = { + $unset: { + tcount: 1, + tlm: 1, + replies: 1, + }, + }; + + return this.update(query, update); + } + + updateThreadLastMessageAndCountByThreadId(tmid, tlm, tcount) { + const query = { + _id: tmid, + }; + + const update = { + $set: { + tlm, + }, + $inc: { + tcount, + }, + }; + + return this.update(query, update); + } + + addThreadFollowerByThreadId(tmid, userId) { + const query = { + _id: tmid, + }; + + const update = { + $addToSet: { + replies: userId, + }, + }; + + return this.update(query, update); + } + + removeThreadFollowerByThreadId(tmid, userId) { + const query = { + _id: tmid, + }; + + const update = { + $pull: { + replies: userId, + }, + }; + + return this.update(query, update); + } + + findThreadsByRoomId(rid, skip, limit) { + return this.find({ rid, tcount: { $exists: true } }, { sort: { tlm: -1 }, skip, limit }); + } } export default new Messages(); diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index 9df9201cef20..9b915999d28c 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -567,6 +567,16 @@ 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, @@ -1286,6 +1296,49 @@ export class Subscriptions extends Base { return result; } + + // ////////////////////////////////////////////////////////////////// + // threads + + addUnreadThreadByRoomIdAndUserIds(rid, users, tmid) { + if (!users) { + return; + } + return this.update({ + 'u._id': { $in: users }, + rid, + }, { + $addToSet: { + tunread: tmid, + }, + }, { multi: true }); + } + + removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid) { + return this.update({ + 'u._id': userId, + rid, + }, { + $pull: { + tunread: tmid, + }, + }); + } + + removeAllUnreadThreadsByRoomIdAndUserId(rid, userId) { + const query = { + rid, + 'u._id': userId, + }; + + const update = { + $unset: { + tunread: 1, + }, + }; + + return this.update(query, update); + } } export default new Subscriptions('subscription', true); diff --git a/app/nrr/client/nrr.js b/app/nrr/client/nrr.js index 8376a6425869..00c1cdba2d95 100644 --- a/app/nrr/client/nrr.js +++ b/app/nrr/client/nrr.js @@ -7,27 +7,24 @@ import { HTML } from 'meteor/htmljs'; import { Spacebars } from 'meteor/spacebars'; import { Tracker } from 'meteor/tracker'; +const makeCursorReactive = function(obj) { + if (obj instanceof Meteor.Collection.Cursor) { + return obj._depend({ + added: true, + removed: true, + changed: true, + }); + } +}; + Blaze.toHTMLWithDataNonReactive = function(content, data) { - const makeCursorReactive = function(obj) { - if (obj instanceof Meteor.Collection.Cursor) { - return obj._depend({ - added: true, - removed: true, - changed: true, - }); - } - }; makeCursorReactive(data); if (data instanceof Spacebars.kw && Object.keys(data.hash).length > 0) { - Object.keys(data.hash).forEach((key) => { - makeCursorReactive(data.hash[key]); - }); - - data = data.hash; + Object.entries(data.hash).forEach(([, value]) => makeCursorReactive(value)); + return Tracker.nonreactive(() => Blaze.toHTMLWithData(content, data.hash)); } - return Tracker.nonreactive(() => Blaze.toHTMLWithData(content, data)); }; diff --git a/app/oembed/client/oembedYoutubeWidget.html b/app/oembed/client/oembedYoutubeWidget.html index 0c7cbbb5bfe3..a496f14a31a9 100644 --- a/app/oembed/client/oembedYoutubeWidget.html +++ b/app/oembed/client/oembedYoutubeWidget.html @@ -6,7 +6,7 @@
{{else}}
-
+
{{{meta.description}}} {{/if}} diff --git a/app/reactions/client/init.js b/app/reactions/client/init.js index c798994399b2..7eb39bcd4c8a 100644 --- a/app/reactions/client/init.js +++ b/app/reactions/client/init.js @@ -3,6 +3,8 @@ import { Blaze } from 'meteor/blaze'; import { Template } from 'meteor/templating'; import { Rooms, Subscriptions } from '../../models'; import { MessageAction } from '../../ui-utils'; +import { messageArgs } from '../../ui-utils/client/lib/messageArgs'; + import { EmojiPicker } from '../../emoji'; import { tooltip } from '../../tooltip'; @@ -11,23 +13,25 @@ Template.room.events({ event.preventDefault(); event.stopPropagation(); const data = Blaze.getData(event.currentTarget); - + const { msg:{ rid, _id: mid } } = messageArgs(data); const user = Meteor.user(); - const room = Rooms.findOne({ _id: data._arguments[1].rid }); + const room = Rooms.findOne({ _id: rid }); if (Array.isArray(room.muted) && room.muted.indexOf(user.username) !== -1 && !room.reactWhenReadOnly) { return false; } EmojiPicker.open(event.currentTarget, (emoji) => { - Meteor.call('setReaction', `:${ emoji }:`, data._arguments[1]._id); + Meteor.call('setReaction', `:${ emoji }:`, mid); }); }, 'click .reactions > li:not(.add-reaction)'(event) { event.preventDefault(); + const data = Blaze.getData(event.currentTarget); - Meteor.call('setReaction', $(event.currentTarget).data('emoji'), data._arguments[1]._id, () => { + const { msg:{ _id: mid } } = messageArgs(data); + Meteor.call('setReaction', $(event.currentTarget).data('emoji'), mid, () => { tooltip.hide(); }); }, @@ -54,7 +58,8 @@ Meteor.startup(function() { ], action(event) { event.stopPropagation(); - EmojiPicker.open(event.currentTarget, (emoji) => Meteor.call('setReaction', `:${ emoji }:`, this._arguments[1]._id)); + const { msg } = messageArgs(this); + EmojiPicker.open(event.currentTarget, (emoji) => Meteor.call('setReaction', `:${ emoji }:`, msg._id)); }, condition(message) { const room = Rooms.findOne({ _id: message.rid }); diff --git a/app/search/client/provider/result.html b/app/search/client/provider/result.html index 40123414769b..566738ff0c73 100644 --- a/app/search/client/provider/result.html +++ b/app/search/client/provider/result.html @@ -13,9 +13,9 @@ {{#if $and result.message result.message.docs}}
    - {{#each result.message.docs}} - {{#nrr nrrargs 'message' message}}{{/nrr}} - {{/each}} + {{# with messageContext}} + {{#each msg in result.message.docs}}{{#nrr nrrargs 'message' msg=(message msg) room=room subscription=subscription settings=settings u=u}}{{/nrr}}{{/each}} + {{/with}}
{{else}} diff --git a/app/search/client/provider/result.js b/app/search/client/provider/result.js index bcdae9104023..5a477adb1792 100644 --- a/app/search/client/provider/result.js +++ b/app/search/client/provider/result.js @@ -3,7 +3,11 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; + +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; import { MessageAction, RoomHistoryManager } from '../../../ui-utils'; + +import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; import _ from 'underscore'; Meteor.startup(function() { @@ -13,7 +17,7 @@ Meteor.startup(function() { label: 'Jump_to_message', context: ['search'], action() { - const message = this._arguments[1]; + const { msg: message } = messageArgs(this); if (Session.get('openedRoom') === message.rid) { return RoomHistoryManager.getSurroundingMessages(message, 50); } @@ -83,7 +87,8 @@ Template.DefaultSearchResultTemplate.helpers({ hasMore() { return Template.instance().hasMore.get(); }, - message() { - return { customClass: 'search', actionContext: 'search', ...this }; + message(msg) { + return { customClass: 'search', actionContext: 'search', ...msg }; }, + messageContext, }); diff --git a/app/theme/client/imports/components/badge.css b/app/theme/client/imports/components/badge.css index 5c59fe1edd5c..a7332ce81dc5 100644 --- a/app/theme/client/imports/components/badge.css +++ b/app/theme/client/imports/components/badge.css @@ -1,7 +1,10 @@ .badge { - display: inline-block; - padding: 3px 6px; + display: flex; + + min-width: 20px; + + padding: 2px 6px; color: var(--badge-text-color); @@ -10,6 +13,7 @@ background-color: var(--badge-background); font-size: var(--badge-text-size); + justify-content: center; &--unread { margin: 0 3px; diff --git a/app/theme/client/imports/components/contextual-bar.css b/app/theme/client/imports/components/contextual-bar.css index 0596b63742ca..7d45730a97df 100644 --- a/app/theme/client/imports/components/contextual-bar.css +++ b/app/theme/client/imports/components/contextual-bar.css @@ -40,6 +40,11 @@ padding: var(--default-padding); justify-content: space-between; + &--no-padding { + padding-right: 0; + padding-left: 0; + } + & .section:not(:last-child) { margin-bottom: 2rem; } @@ -51,7 +56,11 @@ padding: var(--default-padding); - align-items: flex-end; + border-bottom: solid 1px var(--color-gray-light); + + background: var(--color-gray-lightest); + + align-items: center; justify-content: flex-end; &-data { @@ -79,6 +88,17 @@ font-weight: 400; } + &-description { + + display: block; + flex: 1; + + color: var(--color-gray); + + font-size: 0.85rem; + font-weight: 400; + } + &-close-icon { transform: rotate(45deg); @@ -111,7 +131,6 @@ top: 0; width: 100%; - max-width: var(--flex-tab-width); animation: dropup-show 0.3s cubic-bezier(0.45, 0.05, 0.55, 0.95); } diff --git a/app/theme/client/imports/components/header.css b/app/theme/client/imports/components/header.css index d531340325f9..7294aa84ad53 100644 --- a/app/theme/client/imports/components/header.css +++ b/app/theme/client/imports/components/header.css @@ -1,8 +1,37 @@ .rc-header { + + z-index: 10; + padding: 0.5rem; font-size: var(--text-heading-size); + .rc-badge { + position: absolute; + z-index: 1; + top: -2px; + left: var(--badge-size); + + display: flex; + + min-width: var(--badge-size); + height: var(--badge-size); + + padding: 0 5px; + + text-align: center; + + color: white; + border-radius: calc(4 * var(--badge-font-size)); + background: var(--rc-color-button-primary); + box-shadow: 0 0 0 2px #ffffff; + + font-size: var(--badge-font-size); + font-weight: 600; + align-items: center; + justify-content: center; + } + &__first-icon { display: flex; @@ -276,6 +305,8 @@ &__action, &__more-action { + position: relative; + display: flex; flex-direction: column; diff --git a/app/theme/client/imports/components/messages.css b/app/theme/client/imports/components/messages.css index 24a3abe87b60..57ef05552666 100644 --- a/app/theme/client/imports/components/messages.css +++ b/app/theme/client/imports/components/messages.css @@ -114,7 +114,7 @@ .message { &:hover, &.active { - background-color: rgba(0, 0, 0, 0.05); + background-color: rgba(15, 34, 0, 0.05); & .message-actions { display: flex; diff --git a/app/theme/client/imports/components/userInfo.css b/app/theme/client/imports/components/userInfo.css index 878ba63af14b..aa902614dfc9 100644 --- a/app/theme/client/imports/components/userInfo.css +++ b/app/theme/client/imports/components/userInfo.css @@ -19,6 +19,8 @@ background-color: var(--color-white); + will-change: transform; + &.animated { transition: transform 0.45s cubic-bezier(0.5, 0, 0, 1), opacity 0.125s ease-out 0.1s, -webkit-transform 0.45s cubic-bezier(0.5, 0, 0, 1); } diff --git a/app/theme/client/imports/forms/button.css b/app/theme/client/imports/forms/button.css index 4c6a156a4fd0..85effdc6de2e 100644 --- a/app/theme/client/imports/forms/button.css +++ b/app/theme/client/imports/forms/button.css @@ -36,6 +36,7 @@ background-color: transparent; font-size: var(--button-text-size); + font-weight: 600; align-items: center; justify-content: center; @@ -75,11 +76,6 @@ background-color: var(--button-disabled-background); } - &--nude { - border: none; - background: inherit; - } - &--invisible { visibility: hidden; } @@ -90,6 +86,17 @@ background-color: var(--button-primary-background); } + &--nude { + border: none; + background-color: inherit; + + font-weight: 400; + } + + &--primary.rc-button--nude { + color: var(--button-primary-background); + } + &--primary.rc-button--outline { color: var(--button-primary-background); border-color: var(--button-primary-background); @@ -146,6 +153,11 @@ width: 100%; } + &--no-padding { + padding-right: 0; + padding-left: 0; + } + &.loading { position: relative; @@ -172,15 +184,6 @@ } } - &-broadcast { - margin: 10px 0; - padding: 0 1rem; - - &__icon { - margin: 0 5px; - } - } - &__group { display: flex; diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index 08fc983f2094..3363bca3dca8 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -104,6 +104,8 @@ button { .flex-tab-bar { & .tab-button { + position: relative; + cursor: pointer; } @@ -158,7 +160,6 @@ button { .first-unread .body { &::before { position: absolute; - z-index: 1; top: 0; left: 0; @@ -198,22 +199,6 @@ button { } } -.message.new-day.first-unread { - &::after { - border-color: var(--rc-color-error); - } - - & .body { - &::before { - display: none; - } - - &::after { - top: -26px; - } - } -} - .hidden { display: none; } diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index 64649b5b307f..437dab356b90 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -1975,12 +1975,6 @@ rc-old select, font-size: 14px; } - & .edit-room-title { - margin-left: 4px; - - font-size: 16px; - } - & .wrapper { position: absolute; top: 0; @@ -2634,6 +2628,23 @@ rc-old select, line-height: 20px; + &-unread { + display: inline-block; + + width: 10px; + height: 10px; + margin: 0 0.25rem; + + cursor: pointer; + + border-radius: 50%; + background: var(--rc-color-button-primary); + } + + &.highlighted { + background: #ffff99; + } + &.highlight { animation: highlight 3s; } @@ -2642,6 +2653,10 @@ rc-old select, margin-top: 0; } + & .time { + white-space: nowrap; + } + &.new-day { margin-top: 40px; @@ -2656,10 +2671,14 @@ rc-old select, padding: 0 10px; content: attr(data-date); + + transition: all 0.3s; text-align: center; pointer-events: none; + border-radius: 2px; + font-size: 12px; font-weight: 600; } @@ -2668,11 +2687,12 @@ rc-old select, position: absolute; z-index: -1; top: -20px; + right: 0; left: 0; display: block; - width: 100%; + margin: 0 var(--default-padding); content: " "; @@ -2879,7 +2899,7 @@ rc-old select, } & .body { - transition: opacity 1s linear; + transition: opacity 0.3s linear; opacity: 1; @@ -4448,9 +4468,9 @@ rc-old select, .rc-old .mention-link { - padding: 0 4px 2px; + padding: 0 6px 2px; - border-radius: var(--border-radius); + border-radius: 10px; font-weight: bold; } @@ -4458,7 +4478,7 @@ rc-old select, .rc-old .highlight-text { padding: 2px; - border-radius: var(--border-radius); + border-radius: 15px; } .rc-old .avatar-suggestions { @@ -5340,7 +5360,7 @@ rc-old select, text-decoration: none; color: #555555; - border: 1px solid #eaeaea; + border: 1px solid var(--color-gray-light); border-radius: var(--border-radius); font-family: arial; diff --git a/app/theme/client/imports/general/rtl.css b/app/theme/client/imports/general/rtl.css index ad7f6419af90..c199f52eab25 100644 --- a/app/theme/client/imports/general/rtl.css +++ b/app/theme/client/imports/general/rtl.css @@ -87,11 +87,6 @@ right: 0; left: auto; - & .edit-room-title { - margin-right: 4px; - margin-left: auto; - } - & .wrapper { right: 0; left: auto; diff --git a/app/theme/client/imports/general/variables.css b/app/theme/client/imports/general/variables.css index 53ad5e5e1bf0..46df876897b8 100644 --- a/app/theme/client/imports/general/variables.css +++ b/app/theme/client/imports/general/variables.css @@ -253,7 +253,7 @@ * Badge */ --badge-text-color: var(--color-white); - --badge-radius: var(--border-radius); + --badge-radius: 12px; --badge-text-size: 0.75rem; --badge-background: var(--rc-color-primary-dark); --badge-unread-background: var(--rc-color-button-primary); @@ -332,4 +332,10 @@ --alerts-color: var(--color-white); --alerts-font-size: var(--text-default-size); --content-page-padding: 2.5rem; + + /* + * badge + */ + --badge-size: 14px; + --badge-font-size: 0.625rem; } diff --git a/app/theme/server/variables.js b/app/theme/server/variables.js index 1d6db63e2b24..1214f7d7ddf7 100644 --- a/app/theme/server/variables.js +++ b/app/theme/server/variables.js @@ -35,7 +35,7 @@ const majorColors = { 'secondary-background-color': '#F4F4F4', 'secondary-font-color': '#A0A0A0', 'secondary-action-color': '#DDDDDD', - 'component-color': '#EAEAEA', + 'component-color': '#f2f3f5', 'success-color': '#4dff4d', 'pending-color': '#FCB316', 'error-color': '#BC2031', @@ -77,4 +77,3 @@ settings.add('theme-custom-css', '', { section: 'Custom CSS', public: true, }); - diff --git a/app/threads/README.md b/app/threads/README.md new file mode 100644 index 000000000000..513899a0c82b --- /dev/null +++ b/app/threads/README.md @@ -0,0 +1,17 @@ +# Threads + + +# TODO + +* Tests + * reply to message and check if thread is created + * reply to a msg reply and see if thead id updated + * remove the unique reply of a thread and see if the thread is removed + * remove some message from a thread and see if tlm is updated + * open threads list + * start a thread sending a reply, the thread should appear on the list + * remove a thread, the thread should be removed from the list + * open a thread + * send a new reply, the message should appear + * delete a reply, the message should be removed + * remove the thread, the panel should close diff --git a/app/threads/client/flextab/thread.html b/app/threads/client/flextab/thread.html new file mode 100644 index 000000000000..863ebb2c6751 --- /dev/null +++ b/app/threads/client/flextab/thread.html @@ -0,0 +1,26 @@ + diff --git a/app/threads/client/flextab/thread.js b/app/threads/client/flextab/thread.js new file mode 100644 index 000000000000..d8dbaf39500a --- /dev/null +++ b/app/threads/client/flextab/thread.js @@ -0,0 +1,157 @@ +import { Mongo } from 'meteor/mongo'; +import { Template } from 'meteor/templating'; +import { ReactiveDict } from 'meteor/reactive-dict'; +import { Tracker } from 'meteor/tracker'; + +import _ from 'underscore'; + +import { ChatMessages } from '../../../ui'; +import { call } from '../../../ui-utils'; +import { messageContext } from '../../../ui-utils/client/lib/messageContext'; +import { Messages } from '../../../models'; +import { lazyloadtick } from '../../../lazy-load'; + +import { upsert } from '../upsert'; + +import './thread.html'; + +const sort = { ts: 1 }; + +Template.thread.events({ + 'click .js-close'(e) { + e.preventDefault(); + e.stopPropagation(); + const { close } = this; + return close && close(); + }, + 'scroll .js-scroll-thread': _.throttle(({ currentTarget: e }, i) => { + lazyloadtick(); + i.atBottom = e.scrollTop >= e.scrollHeight - e.clientHeight; + }, 500), +}); + +Template.thread.helpers({ + mainMessage() { + return Template.parentData().mainMessage; + }, + loading() { + return Template.instance().state.get('loading'); + }, + messages() { + const { Threads, state } = Template.instance(); + const tmid = state.get('tmid'); + return Threads.find({ tmid }, { sort }); + }, + messageContext() { + const result = messageContext.apply(this); + return { + ...result, + settings: { + ...result.settings, + showReplyButton: false, + showreply:false, + }, + }; + }, + messageBoxData() { + const instance = Template.instance(); + const { mainMessage: { rid, _id: tmid } } = this; + + return { + rid, + tmid, + onSend: (...args) => 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), + }; + }, +}); + + +Template.thread.onRendered(function() { + const rid = Tracker.nonreactive(() => this.state.get('rid')); + const tmid = Tracker.nonreactive(() => this.state.get('tmid')); + + this.chatMessages = new ChatMessages; + this.chatMessages.initializeWrapper(this.find('.js-scroll-thread')); + this.chatMessages.initializeInput(this.find('.js-input-message'), { rid, tmid }); + + this.chatMessages.wrapper.scrollTop = this.chatMessages.wrapper.scrollHeight - this.chatMessages.wrapper.clientHeight; + + this.sendToBottom = _.throttle(() => { + this.chatMessages.wrapper.scrollTop = this.chatMessages.wrapper.scrollHeight; + }, 300); + + this.autorun(() => { + const tmid = this.state.get('tmid'); + this.state.set({ + tmid, + loading: false, + }); + this.loadMore(); + }); + + this.autorun(() => { + const tmid = this.state.get('tmid'); + this.threadsObserve && this.threadsObserve.stop(); + + this.threadsObserve = Messages.find({ tmid }).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), + }); + }); + + this.autorun(() => { + const rid = this.state.get('rid'); + const tmid = this.state.get('tmid'); + this.chatMessages.initializeInput(this.find('.js-input-message'), { rid, tmid }); + }); + + Tracker.afterFlush(() => { + this.autorun(async () => { + const { mainMessage } = Template.currentData(); + this.state.set({ + tmid: mainMessage._id, + rid: mainMessage.rid, + }); + }); + }); +}); + +Template.thread.onCreated(async function() { + this.Threads = new Mongo.Collection(null); + + this.state = new ReactiveDict(); + + this.loadMore = _.debounce(async () => { + if (this.state.get('loading')) { + return; + } + + const { tmid } = Tracker.nonreactive(() => this.state.all()); + + this.state.set('loading', true); + + const messages = await call('getThreadMessages', { tmid }); + + upsert(this.Threads, messages); + + this.state.set('loading', false); + + }, 500); +}); + +Template.thread.onDestroyed(function() { + const { Threads, threadsObserve } = this; + Threads.remove({}); + threadsObserve && threadsObserve.stop(); +}); diff --git a/app/threads/client/flextab/threadlist.js b/app/threads/client/flextab/threadlist.js new file mode 100644 index 000000000000..3a88fb8eed73 --- /dev/null +++ b/app/threads/client/flextab/threadlist.js @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import { Session } from 'meteor/session'; +import { TabBar } from '../../../ui-utils/client'; +import { Subscriptions } from '../../../models/client'; + +Meteor.startup(function() { + return TabBar.addButton({ + groups: ['channel', 'group', 'direct'], + id: 'thread', + i18nTitle: 'Threads', + icon: 'thread', + template: 'threads', + badge: () => { + const subscription = Subscriptions.findOne({ rid: Session.get('openedRoom') }, { fields: { tunread: 1 } }); + if (subscription) { + return subscription.tunread && subscription.tunread.length && { body: subscription.tunread.length > 99 ? '99+' : subscription.tunread.length }; + } + }, + order: 0, + }); +}); diff --git a/app/threads/client/flextab/threads.html b/app/threads/client/flextab/threads.html new file mode 100644 index 000000000000..812d43eadf5e --- /dev/null +++ b/app/threads/client/flextab/threads.html @@ -0,0 +1,34 @@ + diff --git a/app/threads/client/flextab/threads.js b/app/threads/client/flextab/threads.js new file mode 100644 index 000000000000..ffc4e2adfe47 --- /dev/null +++ b/app/threads/client/flextab/threads.js @@ -0,0 +1,163 @@ +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 { lazyloadtick } from '../../../lazy-load'; +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 { upsert } from '../upsert'; + +import './threads.html'; + +const LIST_SIZE = 50; +const sort = { tlm: -1 }; + +Template.threads.events({ + 'click .js-open-thread'(e, instance) { + const { msg } = messageArgs(this); + instance.state.set('mid', msg._id); + e.preventDefault(); + e.stopPropagation(); + return false; + }, + 'scroll .js-scroll-threads': _.throttle(({ currentTarget: e }, { incLimit }) => { + lazyloadtick(); + if (e.offsetHeight + e.scrollTop <= e.scrollHeight - 50) { + incLimit && incLimit(); + } + }, 500), +}); + +Template.threads.helpers({ + close() { + const instance = Template.instance(); + const { tabBar } = instance.data; + return () => (instance.close ? tabBar.close() : instance.state.set('mid', null)); + }, + message() { + return Template.instance().state.get('thread'); + }, + isLoading() { + return Template.instance().state.get('loading'); + }, + hasThreads() { + return Template.instance().Threads.find({ rid: Template.instance().state.get('rid') }, { sort }).count(); + }, + 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.state = new ReactiveDict({ + rid: this.data.rid, + loading: true, + }); + + this.Threads = new Mongo.Collection(null); + + this.incLimit = () => { + if (this.state.get('loading')) { + return; + } + 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 () => { + if (this.state.get('loading')) { + return; + } + + const { rid, limit } = Tracker.nonreactive(() => this.state.all()); + + this.state.set('loading', true); + const threads = await call('getThreadsList', { rid, limit: LIST_SIZE, skip: limit - LIST_SIZE }); + upsert(this.Threads, threads); + // threads.forEach(({ _id, ...msg }) => this.Threads.upsert({ _id }, msg)); + this.state.set('loading', false); + + }, 500); + + Tracker.afterFlush(() => { + this.autorun(async () => { + const { rid, mid } = Template.currentData(); + this.close = !!mid; + + this.state.set({ + mid, + rid, + }); + }); + }); + + this.autorun(() => { + const rid = this.state.get('rid'); + this.rid = rid; + this.state.set({ + limit: LIST_SIZE, + loading: false, + }); + this.loadMore(); + }); + + this.autorun(() => { + const rid = this.state.get('rid'); + this.threadsObserve && this.threadsObserve.stop(); + this.threadsObserve = Messages.find({ rid, tcount: { $exists: 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 { _id: mid } = this.mid.get() || {}; + if (_id === mid) { + this.mid.set(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 && 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(); +}); diff --git a/app/threads/client/index.js b/app/threads/client/index.js new file mode 100644 index 000000000000..6ddd27945ba9 --- /dev/null +++ b/app/threads/client/index.js @@ -0,0 +1,6 @@ +import './flextab/threadlist'; +import './flextab/thread'; +import './flextab/threads'; +import './threads.css'; +import './messageAction/follow'; +import './messageAction/unfollow'; diff --git a/app/threads/client/messageAction/follow.js b/app/threads/client/messageAction/follow.js new file mode 100644 index 000000000000..4bf89c0c51cb --- /dev/null +++ b/app/threads/client/messageAction/follow.js @@ -0,0 +1,36 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { Messages } from '../../../models/client'; +import { settings } from '../../../settings/client'; +import { MessageAction, call } from '../../../ui-utils/client'; +import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; + +Meteor.startup(function() { + Tracker.autorun(() => { + if (!settings.get('Threads_enabled')) { + return MessageAction.removeButton('follow-message'); + } + MessageAction.addButton({ + id: 'follow-message', + icon: 'bell', + label: 'Follow_message', + context: ['message', 'message-mobile', 'threads'], + async action() { + const { msg } = messageArgs(this); + call('followMessage', { mid: msg._id }); + }, + condition({ tmid, replies = [] }) { + if (tmid) { + const parentMessage = Messages.findOne({ _id: tmid }, { fields: { replies: 1 } }); + if (parentMessage) { + replies = parentMessage.replies || []; + } + } + return !replies.includes(Meteor.userId()); + }, + order: 0, + group: 'menu', + }); + }); +}); diff --git a/app/threads/client/messageAction/unfollow.js b/app/threads/client/messageAction/unfollow.js new file mode 100644 index 000000000000..1f54ad1dcea1 --- /dev/null +++ b/app/threads/client/messageAction/unfollow.js @@ -0,0 +1,36 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { Messages } from '../../../models/client'; +import { settings } from '../../../settings/client'; +import { MessageAction, call } from '../../../ui-utils/client'; +import { messageArgs } from '../../../ui-utils/client/lib/messageArgs'; + +Meteor.startup(function() { + Tracker.autorun(() => { + if (!settings.get('Threads_enabled')) { + return MessageAction.removeButton('unfollow-message'); + } + MessageAction.addButton({ + id: 'unfollow-message', + icon: 'bell-off', + label: 'Unfollow_message', + context: ['message', 'message-mobile', 'threads'], + async action() { + const { msg } = messageArgs(this); + call('unfollowMessage', { mid: msg._id }); + }, + condition({ tmid, replies = [] }) { + if (tmid) { + const parentMessage = Messages.findOne({ _id: tmid }, { fields: { replies: 1 } }); + if (parentMessage) { + replies = parentMessage.replies || []; + } + } + return replies.includes(Meteor.userId()); + }, + order: 0, + group: 'menu', + }); + }); +}); diff --git a/app/threads/client/threads.css b/app/threads/client/threads.css new file mode 100644 index 000000000000..0f7e324b2f51 --- /dev/null +++ b/app/threads/client/threads.css @@ -0,0 +1,58 @@ +.message.thread-main { + padding-top: var(--default-padding); + padding-bottom: var(--default-padding); + + border-bottom: 1px solid var(--color-gray-light); +} + +.message.thread-message { + padding-top: 16px; + padding-bottom: 8px; +} + +.thread-message + .thread-message { + border-top: 1px solid var(--color-gray-light); +} + +.thread-empty { + padding: calc(2 * var(--default-padding)); +} + +.thread-list { + overflow: auto; + flex-shrink: 1; +} + +.thread-replied { + overflow: hidden; + + white-space: nowrap; + text-overflow: ellipsis; + + color: var(--color-gray); + + font-size: 14px; +} + +.message.sequential > .body > .thread-replied { + display: none; +} + +.thread-quote { + cursor: pointer; + + color: var(--rc-color-link-active); + + font-style: normal; + + line-height: initial; +} + +.contextual-bar__content.thread, +.contextual-bar__content.threads { + padding: 0; +} + +.js-open-thread { + cursor: pointer; +} diff --git a/app/threads/client/upsert.js b/app/threads/client/upsert.js new file mode 100644 index 000000000000..be5068a94e8f --- /dev/null +++ b/app/threads/client/upsert.js @@ -0,0 +1,10 @@ +export const upsert = (Collection, objects) => { + const { queries } = Collection; + Collection.queries = []; + objects.forEach(({ _id, ...obj }, index) => { + if (index === obj.length - 1) { + Collection.queries = queries; + } + Collection.upsert({ _id }, { _id, ...obj }); + }); +}; diff --git a/app/threads/server/functions.js b/app/threads/server/functions.js new file mode 100644 index 000000000000..a657f795deb1 --- /dev/null +++ b/app/threads/server/functions.js @@ -0,0 +1,49 @@ +import { Messages, Subscriptions } from '../../models/server'; + +export const reply = ({ tmid }, { rid, ts, u }, parentMessage) => { + if (!tmid) { + return false; + } + + Messages.updateRepliesByThreadId(tmid, [parentMessage.u._id, u._id], ts); + + const replies = Messages.getThreadFollowsByThreadId(tmid); + + // doesnt need to update the sender (u._id) subscription, so filter it + Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, replies.filter((userId) => userId !== u._id), tmid); +}; + +export const undoReply = ({ tmid }) => { + if (!tmid) { + return false; + } + + const { ts } = Messages.getFirstReplyTsByThreadId(tmid) || {}; + if (!ts) { + return Messages.unsetThreadByThreadId(tmid); + } + + return Messages.updateThreadLastMessageAndCountByThreadId(tmid, ts, -1); +}; + +export const follow = ({ tmid, uid }) => { + if (!tmid || !uid) { + return false; + } + + Messages.addThreadFollowerByThreadId(tmid, uid); +}; + +export const unfollow = ({ tmid, rid, uid }) => { + if (!tmid || !uid) { + return false; + } + + Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, uid, tmid); + + return Messages.removeThreadFollowerByThreadId(tmid, uid); +}; + +export const readThread = ({ userId, rid, tmid }) => Subscriptions.removeUnreadThreadByRoomIdAndUserId(rid, userId, tmid); + +export const readAllThreads = (rid, userId) => Subscriptions.removeAllUnreadThreadsByRoomIdAndUserId(rid, userId); diff --git a/app/threads/server/hooks/afterReadMessages.js b/app/threads/server/hooks/afterReadMessages.js new file mode 100644 index 000000000000..ef01a1d82574 --- /dev/null +++ b/app/threads/server/hooks/afterReadMessages.js @@ -0,0 +1,19 @@ +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'); + }); +}); diff --git a/app/threads/server/hooks/afterdeletemessage.js b/app/threads/server/hooks/afterdeletemessage.js new file mode 100644 index 000000000000..e7d3ac93d037 --- /dev/null +++ b/app/threads/server/hooks/afterdeletemessage.js @@ -0,0 +1,33 @@ +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'); + }); + +}); diff --git a/app/threads/server/hooks/aftersavemessage.js b/app/threads/server/hooks/aftersavemessage.js new file mode 100644 index 000000000000..8952596d84a1 --- /dev/null +++ b/app/threads/server/hooks/aftersavemessage.js @@ -0,0 +1,131 @@ +import { Meteor } from 'meteor/meteor'; + +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 { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; + + +// messages in a thread will have normal behavior as sent to the room it belongs; + + +function notifyUsersOnReply(message, replies, 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) { + // return message; + // } + + // skips this callback if the message was edited and increments it if the edit was way in the past (aka imported) + if (message.editedAt) { + return message; + } + + updateUsersSubscriptions(message, room, replies); + + // let toAll = false; + // let toHere = false; + // const mentionIds = []; + // const highlightsIds = []; + // const highlights = Subscriptions.findByRoomAndUsersWithUserHighlights(room._id, replies, { fields: { userHighlights: 1, 'u._id': 1 } }).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); + // } + // }); + // } + + // highlights.forEach(function(subscription) { + // if (messageContainsHighlight(message, subscription.userHighlights)) { + // if (subscription.u._id !== message.u._id) { + // highlightsIds.push(subscription.u._id); + // } + // } + // }); + + // if (room.t === 'd') { + // const unreadCountDM = settings.get('Unread_Count_DM'); + + // if (unreadCountDM === 'all_messages') { + // Subscriptions.incUnreadForRoomIdExcludingUserId(room._id, message.u._id); + // } else if (toAll || toHere) { + // Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, 1); + // } else if ((mentionIds && mentionIds.length > 0) || (highlightsIds && highlightsIds.length > 0)) { + // Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, _.compact(_.unique(mentionIds.concat(highlightsIds))), 1, 1); + // } + // } else { + // const unreadCount = settings.get('Unread_Count'); + + // if (toAll || toHere) { + // let incUnread = 0; + // if (['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount)) { + // incUnread = 1; + // } + // Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, incUnread); + + // } else if ((mentionIds && mentionIds.length > 0) || (highlightsIds && highlightsIds.length > 0)) { + // let incUnread = 0; + // if (['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount)) { + // incUnread = 1; + // } + // Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, _.compact(_.unique(mentionIds.concat(highlightsIds))), 1, incUnread); + // } else if (unreadCount === 'all_messages') { + // Subscriptions.incUnreadForRoomIdExcludingUserId(room._id, message.u._id); + // } + // } + + return message; +} + +const metaData = (message, parentMessage) => { + reply({ tmid: message.tmid }, message, parentMessage); + + return message; +}; + +const notification = (message, room, replies) => { + + // will send a notification to everyone who replied/followed the thread except the owner of the message + sendMessageNotifications(message, room, replies); + + return message; +}; + +const processThreads = (message, room) => { + if (!message.tmid) { + return; + } + + const parentMessage = Messages.findOneById(message.tmid); + if (!parentMessage) { + return; + } + + const replies = [ + parentMessage.u._id, + ...(parentMessage.replies || []), + ].filter((userId) => userId !== message.u._id); + + notifyUsersOnReply(message, replies, room); + metaData(message, parentMessage); + notification(message, room, replies); +}; + +Meteor.startup(function() { + settings.get('Threads_enabled', function(key, value) { + if (!value) { + callbacks.remove('afterSaveMessage', 'threads-after-save-message'); + return; + } + callbacks.add('afterSaveMessage', processThreads, callbacks.priority.LOW, 'threads-after-save-message'); + }); +}); diff --git a/app/threads/server/hooks/index.js b/app/threads/server/hooks/index.js new file mode 100644 index 000000000000..8ca16a6257fe --- /dev/null +++ b/app/threads/server/hooks/index.js @@ -0,0 +1,3 @@ +import './afterdeletemessage'; +import './afterReadMessages'; +import './aftersavemessage'; diff --git a/app/threads/server/index.js b/app/threads/server/index.js new file mode 100644 index 000000000000..a4ea0a9c372c --- /dev/null +++ b/app/threads/server/index.js @@ -0,0 +1,3 @@ +import './hooks'; +import './methods'; +import './settings'; diff --git a/app/threads/server/methods/followMessage.js b/app/threads/server/methods/followMessage.js new file mode 100644 index 000000000000..f855babeabb7 --- /dev/null +++ b/app/threads/server/methods/followMessage.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Messages } from '../../../models/server'; +import { RateLimiter } from '../../../lib/server'; +import { settings } from '../../../settings/server'; + +import { follow } from '../functions'; + +Meteor.methods({ + 'followMessage'({ mid }) { + check(mid, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'followMessage' }); + } + + if (mid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + } + + const message = Messages.findOneById(mid, { fields: { rid: 1, tmid: 1 } }); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'followMessage' }); + } + + const room = Meteor.call('canAccessRoom', message.rid, uid); + if (!room) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + } + + return follow({ tmid: message.tmid || message._id, uid }); + }, +}); + +RateLimiter.limitMethod('followMessage', 5, 5000, { + userId() { return true; }, +}); diff --git a/app/threads/server/methods/getThreadMessages.js b/app/threads/server/methods/getThreadMessages.js new file mode 100644 index 000000000000..4fb878254098 --- /dev/null +++ b/app/threads/server/methods/getThreadMessages.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages, Rooms } from '../../../models/server'; +import { canAccessRoom } from '../../../authorization/server'; +import { settings } from '../../../settings/server'; + +import { readThread } from '../functions'; + +const MAX_LIMIT = 100; + +Meteor.methods({ + getThreadMessages({ tmid, limit, skip }) { + if (limit > MAX_LIMIT) { + throw new Meteor.Error('error-not-allowed', `max limit: ${ MAX_LIMIT }`, { method: 'getThreadMessages' }); + } + + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadMessages' }); + } + + const thread = Messages.findOneById(tmid); + if (!thread) { + return []; + } + + const user = Meteor.user(); + const room = Rooms.findOneById(thread.rid); + + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' }); + } + + readThread({ userId: user._id, rid: thread.rid, tmid }); + + const result = Messages.find({ tmid }, { ...(skip && { skip }), ...(limit && { limit }), sort: { ts : -1 } }).fetch(); + + return [thread, ...result]; + }, +}); diff --git a/app/threads/server/methods/getThreadsList.js b/app/threads/server/methods/getThreadsList.js new file mode 100644 index 000000000000..61f2dd01e9d0 --- /dev/null +++ b/app/threads/server/methods/getThreadsList.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +import { Messages, Rooms } from '../../../models/server'; +import { canAccessRoom } from '../../../authorization/server'; +import { settings } from '../../../settings/server'; + +const MAX_LIMIT = 100; + +Meteor.methods({ + getThreadsList({ rid, limit = 50, skip = 0 }) { + + if (limit > MAX_LIMIT) { + throw new Meteor.Error('error-not-allowed', `max limit: ${ MAX_LIMIT }`, { method: 'getThreadsList' }); + } + + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'getThreadsList' }); + } + + const user = Meteor.user(); + const room = Rooms.findOneById(rid); + + if (!canAccessRoom(room, user)) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'getThreadsList' }); + } + + return Messages.findThreadsByRoomId(rid, skip, limit).fetch(); + }, +}); diff --git a/app/threads/server/methods/index.js b/app/threads/server/methods/index.js new file mode 100644 index 000000000000..4e6ed3003b11 --- /dev/null +++ b/app/threads/server/methods/index.js @@ -0,0 +1,4 @@ +import './followMessage'; +import './getThreadMessages'; +import './getThreadsList'; +import './unfollowMessage'; diff --git a/app/threads/server/methods/unfollowMessage.js b/app/threads/server/methods/unfollowMessage.js new file mode 100644 index 000000000000..4e602747f5a5 --- /dev/null +++ b/app/threads/server/methods/unfollowMessage.js @@ -0,0 +1,39 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { Messages } from '../../../models/server'; +import { RateLimiter } from '../../../lib/server'; +import { settings } from '../../../settings/server'; + +import { unfollow } from '../functions'; + +Meteor.methods({ + 'unfollowMessage'({ mid }) { + check(mid, String); + + const uid = Meteor.userId(); + if (!uid) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unfollowMessage' }); + } + + if (mid && !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'unfollowMessage' }); + } + + const message = Messages.findOneById(mid, { fields: { rid: 1, tmid: 1 } }); + if (!message) { + throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'followMessage' }); + } + + const room = Meteor.call('canAccessRoom', message.rid, uid); + if (!room) { + throw new Meteor.Error('error-not-allowed', 'not-allowed', { method: 'followMessage' }); + } + + return unfollow({ rid: message.rid, tmid: message.tmid || message._id, uid }); + }, +}); + +RateLimiter.limitMethod('unfollowMessage', 5, 5000, { + userId() { return true; }, +}); diff --git a/app/threads/server/settings.js b/app/threads/server/settings.js new file mode 100644 index 000000000000..644f9a33e88c --- /dev/null +++ b/app/threads/server/settings.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { settings } from '../../settings'; + +Meteor.startup(() => { + settings.addGroup('Threads', function() { + this.add('Threads_enabled', true, { + group: 'Threads', + i18nLabel: 'Enable', + type: 'boolean', + public: true, + }); + }); +}); diff --git a/app/ui-flextab/client/flexTabBar.html b/app/ui-flextab/client/flexTabBar.html index 1b99c02e6bf9..aee858855613 100644 --- a/app/ui-flextab/client/flexTabBar.html +++ b/app/ui-flextab/client/flexTabBar.html @@ -45,6 +45,9 @@

{{_ label}}

{{#each buttons}}
+ {{#if badge}} + {{badge.body}} + {{/if}} diff --git a/app/ui-master/client/main.js b/app/ui-master/client/main.js index d14224512a95..c5fad0cba14d 100644 --- a/app/ui-master/client/main.js +++ b/app/ui-master/client/main.js @@ -218,13 +218,7 @@ Template.main.events({ Template.main.onRendered(function() { $('#initial-page-loading').remove(); - window.addEventListener('focus', function() { - return Meteor.setTimeout(function() { - if (!$(':focus').is('INPUT,TEXTAREA')) { - return $('.input-message').focus(); - } - }, 100); - }); + return Tracker.autorun(function() { const userId = Meteor.userId(); const Show_Setup_Wizard = settings.get('Show_Setup_Wizard'); diff --git a/app/ui-message/client/message.html b/app/ui-message/client/message.html index 033445d01c36..9bd5c1e9c29d 100644 --- a/app/ui-message/client/message.html +++ b/app/ui-message/client/message.html @@ -1,35 +1,35 @@ diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index 55126882b807..0299d8d7f30c 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -1,20 +1,20 @@ +import _ from 'underscore'; +import moment from 'moment'; + import { Meteor } from 'meteor/meteor'; import { Blaze } from 'meteor/blaze'; -import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { TAPi18n } from 'meteor/tap:i18n'; -import _ from 'underscore'; -import moment from 'moment'; -import { DateFormat } from '../../lib'; -import { renderEmoji } from '../../emoji'; -import { renderMessageBody, MessageTypes, MessageAction } from '../../ui-utils'; -import { settings } from '../../settings'; -import { RoomRoles, UserRoles, Roles, Subscriptions, Rooms } from '../../models'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { timeAgo } from '../../lib/client/lib/formatDate'; +import { DateFormat } from '../../lib/client'; +import { renderMessageBody, MessageTypes, MessageAction, call, normalizeThreadMessage } from '../../ui-utils/client'; +import { RoomRoles, UserRoles, Roles, Messages } from '../../models/client'; import { AutoTranslate } from '../../autotranslate/client'; -import { hasAtLeastOnePermission } from '../../authorization'; -import { callbacks } from '../../callbacks'; +import { callbacks } from '../../callbacks/client'; import { Markdown } from '../../markdown/client'; -import { t, getUserPreference, roomTypes, getURL } from '../../utils'; +import { t, roomTypes, getURL } from '../../utils'; async function renderPdfToCanvas(canvasId, pdfLink) { const isSafari = /constructor/i.test(window.HTMLElement) || @@ -69,47 +69,63 @@ async function renderPdfToCanvas(canvasId, pdfLink) { } Template.message.helpers({ - i18nKeyReply() { - return this.dcount > 1 + hover() { + return Template.instance().hover.get(); + }, + and(a, b) { + return a && b; + }, + i18nKeyMessage() { + const { msg } = this; + return msg.dcount > 1 ? 'messages' : 'message'; }, - dlm() { - return this.dlm && moment(this.dlm).format('LLL'); + i18nKeyReply() { + const { msg } = this; + return msg.tcount > 1 + ? 'replies' + : 'reply'; + }, + formatDate(date) { + return moment(date).format('LLL'); }, encodeURI(text) { return encodeURI(text); }, broadcast() { - const instance = Template.instance(); - return !this.private && !this.t && this.u._id !== Meteor.userId() && instance.room && instance.room.broadcast; + const { msg, room = {} } = this; + return !msg.private && !msg.t && msg.u._id !== Meteor.userId() && room && room.broadcast; }, isIgnored() { - return this.ignored; + const { msg } = this; + return msg.ignored; }, ignoredClass() { - return this.ignored ? 'message--ignored' : ''; + const { msg } = this; + return msg.ignored ? 'message--ignored' : ''; }, isDecrypting() { - return this.e2e === 'pending'; + const { msg } = this; + return msg.e2e === 'pending'; }, isBot() { - if (this.bot != null) { - return 'bot'; - } + const { msg } = this; + return msg.bot && 'bot'; }, roleTags() { - if (!settings.get('UI_DisplayRoles') || getUserPreference(Meteor.userId(), 'hideRoles')) { + const { msg, hideRoles } = this; + if (hideRoles) { return []; } - if (!this.u || !this.u._id) { + if (!msg.u || !msg.u._id) { return []; } - const userRoles = UserRoles.findOne(this.u._id); + const userRoles = UserRoles.findOne(msg.u._id); const roomRoles = RoomRoles.findOne({ - 'u._id': this.u._id, - rid: this.rid, + 'u._id': msg.u._id, + rid: msg.rid, }); const roles = [...(userRoles && userRoles.roles) || [], ...(roomRoles && roomRoles.roles) || []]; return Roles.find({ @@ -127,59 +143,64 @@ Template.message.helpers({ }); }, isGroupable() { - if (Template.instance().room.broadcast || this.groupable === false) { + const { msg, room = {}, settings, groupable } = this; + if (groupable === false || settings.allowGroup === false || room.broadcast || msg.groupable === false) { return 'false'; } }, - isSequential() { - return this.groupable !== false && !Template.instance().room.broadcast; - }, sequentialClass() { - if (this.groupable !== false) { - return 'sequential'; - } + const { msg } = this; + return msg.groupable !== false && 'sequential'; }, avatarFromUsername() { - if ((this.avatar != null) && this.avatar[0] === '@') { - return this.avatar.replace(/^@/, ''); + const { msg } = this; + + if (msg.avatar != null && msg.avatar[0] === '@') { + return msg.avatar.replace(/^@/, ''); } }, - getEmoji(emoji) { - return renderEmoji(emoji); - }, getName() { - if (this.alias) { - return this.alias; + const { msg, settings } = this; + if (msg.alias) { + return msg.alias; } - if (!this.u) { + if (!msg.u) { return ''; } - return (settings.get('UI_Use_Real_Name') && this.u.name) || this.u.username; + return (settings.UI_Use_Real_Name && msg.u.name) || msg.u.username; }, showUsername() { - return this.alias || (settings.get('UI_Use_Real_Name') && this.u && this.u.name); + const { msg, settings } = this; + return msg.alias || (settings.UI_Use_Real_Name && msg.u && msg.u.name); }, own() { - if (this.u && this.u._id === Meteor.userId()) { + const { msg, u } = this; + if (msg.u && msg.u._id === u._id) { return 'own'; } }, timestamp() { - return +this.ts; + const { msg } = this; + return +msg.ts; }, chatops() { - if (this.u && this.u.username === settings.get('Chatops_Username')) { + const { msg, settings } = this; + if (msg.u && msg.u.username === settings.Chatops_Username) { return 'chatops-message'; } }, time() { - return DateFormat.formatTime(this.ts); + const { msg, timeAgo: useTimeAgo } = this; + + return useTimeAgo ? timeAgo(msg.ts) : DateFormat.formatTime(msg.ts); }, date() { - return DateFormat.formatDate(this.ts); + const { msg } = this; + return DateFormat.formatDate(msg.ts); }, isTemp() { - if (this.temp === true) { + const { msg } = this; + if (msg.temp === true) { return 'temp'; } }, @@ -187,10 +208,12 @@ Template.message.helpers({ return Template.instance().body; }, bodyClass() { - return MessageTypes.isSystemMessage(this) ? 'color-info-font-color' : 'color-primary-font-color'; + const { msg } = this; + return MessageTypes.isSystemMessage(msg) ? 'color-info-font-color' : 'color-primary-font-color'; }, system(returnClass) { - if (MessageTypes.isSystemMessage(this)) { + const { msg } = this; + if (MessageTypes.isSystemMessage(msg)) { if (returnClass) { return 'color-info-font-color'; } @@ -198,107 +221,58 @@ Template.message.helpers({ } }, showTranslated() { - if (settings.get('AutoTranslate_Enabled') && this.u && this.u._id !== Meteor.userId() && !MessageTypes.isSystemMessage(this)) { - const subscription = Subscriptions.findOne({ - rid: this.rid, - 'u._id': Meteor.userId(), - }, { - fields: { - autoTranslate: 1, - autoTranslateLanguage: 1, - }, - }); - const language = AutoTranslate.getLanguage(this.rid); - return this.autoTranslateFetching || (subscription && subscription.autoTranslate !== this.autoTranslateShowInverse && this.translations && this.translations[language]); + const { msg, subscription, settings } = this; + if (settings.AutoTranslate_Enabled && msg.u && msg.u._id !== Meteor.userId() && !MessageTypes.isSystemMessage(msg)) { + const language = AutoTranslate.getLanguage(msg.rid); + return msg.autoTranslateFetching || (subscription && subscription.autoTranslate !== msg.autoTranslateShowInverse && msg.translations && msg.translations[language]); } }, edited() { return Template.instance().wasEdited; }, editTime() { + const { msg } = this; if (Template.instance().wasEdited) { - return DateFormat.formatDateAndTime(this.editedAt); + return DateFormat.formatDateAndTime(msg.editedAt); } }, editedBy() { if (!Template.instance().wasEdited) { return ''; } + const { msg } = this; // try to return the username of the editor, // otherwise a special "?" character that will be // rendered as a special avatar - return (this.editedBy && this.editedBy.username) || '?'; - }, - canEdit() { - const hasPermission = hasAtLeastOnePermission('edit-message', this.rid); - const isEditAllowed = settings.get('Message_AllowEditing'); - const editOwn = this.u && this.u._id === Meteor.userId(); - if (!(hasPermission || (isEditAllowed && editOwn))) { - return; - } - const blockEditInMinutes = settings.get('Message_AllowEditing_BlockEditInMinutes'); - if (blockEditInMinutes) { - let msgTs; - if (this.ts != null) { - msgTs = moment(this.ts); - } - let currentTsDiff; - if (msgTs != null) { - currentTsDiff = moment().diff(msgTs, 'minutes'); - } - return currentTsDiff < blockEditInMinutes; - } else { - return true; - } - }, - canDelete() { - const hasPermission = hasAtLeastOnePermission('delete-message', this.rid); - const isDeleteAllowed = settings.get('Message_AllowDeleting'); - const deleteOwn = this.u && this.u._id === Meteor.userId(); - if (!(hasPermission || (isDeleteAllowed && deleteOwn))) { - return; - } - const blockDeleteInMinutes = settings.get('Message_AllowDeleting_BlockDeleteInMinutes'); - if (blockDeleteInMinutes) { - let msgTs; - if (this.ts != null) { - msgTs = moment(this.ts); - } - let currentTsDiff; - if (msgTs != null) { - currentTsDiff = moment().diff(msgTs, 'minutes'); - } - return currentTsDiff < blockDeleteInMinutes; - } else { - return true; - } - }, - showEditedStatus() { - return settings.get('Message_ShowEditedStatus'); + return (msg.editedBy && msg.editedBy.username) || '?'; }, label() { - if (this.i18nLabel) { - return t(this.i18nLabel); - } else if (this.label) { - return this.label; + const { msg } = this; + + if (msg.i18nLabel) { + return t(msg.i18nLabel); + } else if (msg.label) { + return msg.label; } }, hasOembed() { + const { msg, settings } = this; // there is no URLs, there is no template to show the oembed (oembed package removed) or oembed is not enable - if (!(this.urls && this.urls.length > 0) || !Template.oembedBaseWidget || !settings.get('API_Embed')) { + if (!(msg.urls && msg.urls.length > 0) || !Template.oembedBaseWidget || !settings.API_Embed) { return false; } // check if oembed is disabled for message's sender - if ((settings.get('API_EmbedDisabledFor') || '').split(',').map((username) => username.trim()).includes(this.u && this.u.username)) { + if ((settings.API_EmbedDisabledFor || '').split(',').map((username) => username.trim()).includes(msg.u && msg.u.username)) { return false; } return true; }, reactions() { const { username: myUsername, name: myName } = Meteor.user() || {}; + const { msg: { reactions = {} } } = this; - return Object.entries(this.reactions || {}) + return Object.entries(reactions) .map(([emoji, reaction]) => { const myDisplayName = reaction.names ? myName : `@${ myUsername }`; const displayNames = (reaction.names || reaction.usernames.map((username) => `@${ username }`)); @@ -335,54 +309,53 @@ Template.message.helpers({ } }, hideReactions() { - if (_.isEmpty(this.reactions)) { + const { msg } = this; + if (_.isEmpty(msg.reactions)) { return 'hidden'; } }, actionLinks() { + const { msg } = this; // remove 'method_id' and 'params' properties - return _.map(this.actionLinks, function(actionLink, key) { + return _.map(msg.actionLinks, function(actionLink, key) { return _.extend({ id: key, }, _.omit(actionLink, 'method_id', 'params')); }); }, hideActionLinks() { - if (_.isEmpty(this.actionLinks)) { + const { msg } = this; + if (_.isEmpty(msg.actionLinks)) { return 'hidden'; } }, injectIndex(data, index) { data.index = index; }, - hideCog() { - const subscription = Subscriptions.findOne({ - rid: this.rid, - }); - if (subscription == null) { - return 'hidden'; - } - }, channelName() { - const subscription = Subscriptions.findOne({ rid: this.rid }); + const { subscription } = this; + // const subscription = Subscriptions.findOne({ rid: this.rid }); return subscription && subscription.name; }, roomIcon() { - const room = Session.get(`roomData${ this.rid }`); + const { room } = this; if (room && room.t === 'd') { return 'at'; } return roomTypes.getIcon(room); }, fromSearch() { - return this.customClass === 'search'; + const { customClass } = this; + return customClass === 'search'; }, actionContext() { - return this.actionContext; + const { msg } = this; + return msg.actionContext; }, messageActions(group) { + const { msg, context: ctx } = this; let messageGroup = group; - let context = this.actionContext; + let context = ctx || msg.actionContext; if (!group) { messageGroup = 'message'; @@ -392,113 +365,184 @@ Template.message.helpers({ context = 'message'; } - return MessageAction.getButtons(Template.currentData(), context, messageGroup); + return MessageAction.getButtons(msg, context, messageGroup); }, isSnippet() { - return this.actionContext === 'snippeted'; + const { msg } = this; + return msg.actionContext === 'snippeted'; + }, + parentMessage() { + const { msg: { threadMsg } } = this; + return threadMsg; }, }); -Template.message.onCreated(function() { - let msg = Template.currentData(); - - this.wasEdited = (msg.editedAt != null) && !MessageTypes.isSystemMessage(msg); +const findParentMessage = (() => { - this.room = Rooms.findOne({ - _id: msg.rid, - }, { - fields: { - broadcast: 1, - }, - }); + const waiting = []; - return this.body = (() => { - const isSystemMessage = MessageTypes.isSystemMessage(msg); - const messageType = MessageTypes.getType(msg) || {}; - if (messageType.render) { - msg = messageType.render(msg); - } else if (messageType.template) { - // render template - } else if (messageType.message) { - if (typeof messageType.data === 'function' && messageType.data(msg)) { - msg = TAPi18n.__(messageType.message, messageType.data(msg)); - } else { - msg = TAPi18n.__(messageType.message); + const getMessages = _.debounce(async function() { + const _tmp = [...waiting]; + waiting.length = 0; + const messages = await call('getMessages', _tmp); + messages.forEach((message) => { + if (!message) { + return; } - } else if (msg.u && msg.u.username === settings.get('Chatops_Username')) { - msg.html = msg.msg; - msg = callbacks.run('renderMentions', msg); - msg = msg.html; - } else { - msg = renderMessageBody(msg); + const { _id, ...msg } = message; + Messages.update({ tmid: _id }, { + $set: { + threadMsg: normalizeThreadMessage(msg), + repliesCount: msg.tcount, + }, + }, { multi: true }); + Messages.upsert({ _id }, msg); + }); + }, 500); + + return (tmid) => { + if (waiting.indexOf(tmid) > -1) { + return; } - if (isSystemMessage) { - msg.html = Markdown.parse(msg.html); + const message = Messages.findOne({ _id: tmid }); + + if (message) { + return; } - return msg; - })(); + + waiting.push(tmid); + getMessages(); + }; +})(); + + +const renderBody = (msg, settings) => { + const isSystemMessage = MessageTypes.isSystemMessage(msg); + const messageType = MessageTypes.getType(msg) || {}; + if (msg.thread_message) { + msg.reply = Markdown.parse(TAPi18n.__('Thread_message', { + username: msg.u.username, + msg: msg.thread_message.msg, + })); + } + + if (messageType.render) { + msg = messageType.render(msg); + } else if (messageType.template) { + // render template + } else if (messageType.message) { + msg = TAPi18n.__(messageType.message, { ... typeof messageType.data === 'function' && messageType.data(msg) }); + } else if (msg.u && msg.u.username === settings.Chatops_Username) { + msg.html = msg.msg; + msg = callbacks.run('renderMentions', msg); + msg = msg.html; + } else { + msg = renderMessageBody(msg); + } + + if (isSystemMessage) { + msg.html = Markdown.parse(msg.html); + } + return msg; +}; + +Template.message.onCreated(function() { + this.hover = new ReactiveVar(false); + // const [, currentData] = Template.currentData()._arguments; + // const { msg, settings } = currentData.hash; + const { msg, settings } = Template.currentData(); + + this.wasEdited = msg.editedAt && !MessageTypes.isSystemMessage(msg); + if (msg.tmid && !msg.thread_message) { + findParentMessage(msg.tmid); + } + return this.body = renderBody(msg, settings); }); +const hasTempClass = (node) => node.classList.contains('temp'); + + +const getPreviousSentMessage = (currentNode) => { + if (hasTempClass(currentNode)) { + return currentNode.previousElementSibling; + } + if (currentNode.previousElementSibling != null) { + let previousValid = currentNode.previousElementSibling; + while (previousValid != null && (hasTempClass(previousValid) || !previousValid.classList.contains('message'))) { + previousValid = previousValid.previousElementSibling; + } + return previousValid; + } +}; + +const setNewDayAndGroup = (currentNode, previousNode, forceDate, period, noDate) => { + + + const { classList } = currentNode; + + // const $nextNode = $(nextNode); + if (previousNode == null) { + + classList.remove('sequential'); + return !noDate && classList.add('new-day'); + } + + const previousDataset = previousNode.dataset; + const currentDataset = currentNode.dataset; + const previousMessageDate = new Date(parseInt(previousDataset.timestamp)); + const currentMessageDate = new Date(parseInt(currentDataset.timestamp)); + + if (!noDate && (forceDate || previousMessageDate.toDateString() !== currentMessageDate.toDateString())) { + classList.add('new-day'); + } + + + if (previousDataset.tmid !== currentDataset.tmid) { + return classList.remove('sequential'); + } + + if (previousDataset.username !== currentDataset.username || parseInt(currentDataset.timestamp) - parseInt(previousDataset.timestamp) > period) { + return classList.remove('sequential'); + } + + if ([previousDataset.groupable, currentDataset.groupable].includes('false')) { + return classList.remove('sequential'); + } + +}; + Template.message.onViewRendered = function(context) { + const [, currentData] = Template.currentData()._arguments; + const { settings, forceDate, noDate } = currentData.hash; return this._domrange.onAttached((domRange) => { if (context.file && context.file.type === 'application/pdf') { Meteor.defer(() => { renderPdfToCanvas(context.file._id, context.attachments[0].title_link); }); } const currentNode = domRange.lastNode(); const currentDataset = currentNode.dataset; - const getPreviousSentMessage = (currentNode) => { - if ($(currentNode).hasClass('temp')) { - return currentNode.previousElementSibling; - } - if (currentNode.previousElementSibling != null) { - let previousValid = currentNode.previousElementSibling; - while (previousValid != null && $(previousValid).hasClass('temp')) { - previousValid = previousValid.previousElementSibling; - } - return previousValid; - } - }; const previousNode = getPreviousSentMessage(currentNode); const nextNode = currentNode.nextElementSibling; - const $currentNode = $(currentNode); - const $nextNode = $(nextNode); - if (previousNode == null) { - $currentNode.addClass('new-day').removeClass('sequential'); - } else if (previousNode.dataset) { - const previousDataset = previousNode.dataset; - const previousMessageDate = new Date(parseInt(previousDataset.timestamp)); - const currentMessageDate = new Date(parseInt(currentDataset.timestamp)); - if (previousMessageDate.toDateString() !== currentMessageDate.toDateString()) { - $currentNode.addClass('new-day').removeClass('sequential'); - } else { - $currentNode.removeClass('new-day'); - } - if (previousDataset.groupable === 'false' || currentDataset.groupable === 'false') { - $currentNode.removeClass('sequential'); - } else if (previousDataset.username !== currentDataset.username || parseInt(currentDataset.timestamp) - parseInt(previousDataset.timestamp) > settings.get('Message_GroupingPeriod') * 1000) { - $currentNode.removeClass('sequential'); - } else if (!$currentNode.hasClass('new-day')) { - $currentNode.addClass('sequential'); - } - } + setNewDayAndGroup(currentNode, previousNode, forceDate, settings.Message_GroupingPeriod, noDate); if (nextNode && nextNode.dataset) { const nextDataset = nextNode.dataset; - if (nextDataset.date !== currentDataset.date) { - $nextNode.addClass('new-day').removeClass('sequential'); + if (forceDate || nextDataset.date !== currentDataset.date) { + if (!noDate) { + currentNode.classList.add('new-day'); + } + currentNode.classList.remove('sequential'); } else { - $nextNode.removeClass('new-day'); + nextNode.classList.remove('new-day'); } if (nextDataset.groupable !== 'false') { - if (nextDataset.username !== currentDataset.username || parseInt(nextDataset.timestamp) - parseInt(currentDataset.timestamp) > settings.get('Message_GroupingPeriod') * 1000) { - $nextNode.removeClass('sequential'); - } else if (!$nextNode.hasClass('new-day') && !$currentNode.hasClass('temp')) { - $nextNode.addClass('sequential'); + if (nextDataset.tmid !== currentDataset.tmid || nextDataset.username !== currentDataset.username || parseInt(nextDataset.timestamp) - parseInt(currentDataset.timestamp) > settings.Message_GroupingPeriod) { + nextNode.classList.remove('sequential'); + } else if (!nextNode.classList.contains('new-day') && !currentNode.classList.contains('temp')) { + nextNode.classList.add('sequential'); } } - } - if (nextNode == null) { + } else { const [el] = $(`#chat-window-${ context.rid }`); const view = el && Blaze.getView(el); const templateInstance = view && view.templateInstance(); @@ -511,5 +555,7 @@ Template.message.onViewRendered = function(context) { } templateInstance.sendToBottomIfNecessary(); } + }); + }; diff --git a/app/ui-message/client/messageBox.html b/app/ui-message/client/messageBox.html index 6b672023a1ea..4c8097a5f7fc 100644 --- a/app/ui-message/client/messageBox.html +++ b/app/ui-message/client/messageBox.html @@ -1,22 +1,26 @@ diff --git a/app/ui-message/client/messageBox.js b/app/ui-message/client/messageBox.js index f4610733df61..e7286f51667a 100644 --- a/app/ui-message/client/messageBox.js +++ b/app/ui-message/client/messageBox.js @@ -4,221 +4,114 @@ import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { Tracker } from 'meteor/tracker'; import { EmojiPicker } from '../../emoji'; -import { katex } from '../../katex/client'; -import { Markdown } from '../../markdown/client'; -import { ChatSubscription } from '../../models'; import { settings } from '../../settings'; import { - ChatMessages, - chatMessages, fileUpload, KonchatNotification, } from '../../ui'; -import { Layout, messageBox, popover, RoomManager, call } from '../../ui-utils'; -import { t, roomTypes, getUserPreference } from '../../utils'; +import { + messageBox, + popover, + call, + keyCodes, + isRTL, +} from '../../ui-utils'; +import { + t, + roomTypes, + getUserPreference, +} from '../../utils'; import moment from 'moment'; - +import { + formattingButtons, + applyFormatting, +} from './messageBoxFormatting'; import './messageBoxReplyPreview'; import './messageBoxTyping'; import './messageBoxAudioMessage'; import './messageBoxNotSubscribed'; import './messageBox.html'; -const formattingButtons = [ - { - label: 'bold', - icon: 'bold', - pattern: '*{{text}}*', - command: 'b', - condition: () => Markdown && settings.get('Markdown_Parser') === 'original', - }, - { - label: 'bold', - icon: 'bold', - pattern: '**{{text}}**', - command: 'b', - condition: () => Markdown && settings.get('Markdown_Parser') === 'marked', - }, - { - label: 'italic', - icon: 'italic', - pattern: '_{{text}}_', - command: 'i', - condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', - }, - { - label: 'strike', - icon: 'strike', - pattern: '~{{text}}~', - condition: () => Markdown && settings.get('Markdown_Parser') === 'original', - }, - { - label: 'strike', - icon: 'strike', - pattern: '~~{{text}}~~', - condition: () => Markdown && settings.get('Markdown_Parser') === 'marked', - }, - { - label: 'inline_code', - icon: 'code', - pattern: '`{{text}}`', - condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', - }, - { - label: 'multi_line', - icon: 'multiline', - pattern: '```\n{{text}}\n``` ', - condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', - }, - { - label: 'KaTeX', - text: () => { - if (!katex.isEnabled()) { - return; - } - if (katex.isDollarSyntaxEnabled()) { - return '$$KaTeX$$'; - } - if (katex.isParenthesisSyntaxEnabled()) { - return '\\[KaTeX\\]'; - } - }, - link: 'https://khan.github.io/KaTeX/function-support.html', - condition: () => katex.isEnabled(), - }, -]; - - -function applyFormatting(event, instance) { - event.preventDefault(); - const { input } = chatMessages[RoomManager.openedRoom]; - const { selectionEnd = input.value.length, selectionStart = 0 } = input; - const initText = input.value.slice(0, selectionStart); - const selectedText = input.value.slice(selectionStart, selectionEnd); - const finalText = input.value.slice(selectionEnd, input.value.length); - - const [btn] = instance.findAll(`.js-format[aria-label=${ this.label }]`); - if (btn) { - btn.classList.add('active'); - setTimeout(() => { - btn.classList.remove('active'); - }, 100); - } - input.focus(); - - const startPattern = this.pattern.substr(0, this.pattern.indexOf('{{text}}')); - const startPatternFound = [...startPattern].reverse().every((char, index) => input.value.substr(selectionStart - index - 1, 1) === char); - - if (startPatternFound) { - const endPattern = this.pattern.substr(this.pattern.indexOf('{{text}}') + '{{text}}'.length); - const endPatternFound = [...endPattern].every((char, index) => input.value.substr(selectionEnd + index, 1) === char); - - if (endPatternFound) { - input.selectionStart = selectionStart - startPattern.length; - input.selectionEnd = selectionEnd + endPattern.length; - - if (!document.execCommand || !document.execCommand('insertText', false, selectedText)) { - input.value = initText.substr(0, initText.length - startPattern.length) + selectedText + finalText.substr(endPattern.length); - } - - input.selectionStart = selectionStart - startPattern.length; - input.selectionEnd = input.selectionStart + selectedText.length; - $(input).change(); - return; - } - } - - if (!document.execCommand || !document.execCommand('insertText', false, this.pattern.replace('{{text}}', selectedText))) { - input.value = initText + this.pattern.replace('{{text}}', selectedText) + finalText; - } - - input.selectionStart = selectionStart + this.pattern.indexOf('{{text}}'); - input.selectionEnd = input.selectionStart + selectedText.length; - $(input).change(); -} - Template.messageBox.onCreated(function() { EmojiPicker.init(); + this.popupConfig = new ReactiveVar(null); this.replyMessageData = new ReactiveVar(); - this.isMessageFieldEmpty = new ReactiveVar(true); this.isMicrophoneDenied = new ReactiveVar(true); this.sendIconDisabled = new ReactiveVar(false); - messageBox.emit('created', this); }); Template.messageBox.onRendered(function() { this.autorun(() => { - const subscribed = roomTypes.verifyCanSendMessage(this.data._id); + const { rid, onInputChanged, onResize } = Template.currentData(); Tracker.afterFlush(() => { - const input = subscribed && this.find('.js-input-message'); + const input = this.find('.js-input-message'); + + if (this.input === input) { + return; + } + + this.input = input; + onInputChanged && onInputChanged(input); + + if (input && rid) { + this.popupConfig.set({ + rid, + getInput: () => input, + }); + } else { + this.popupConfig.set(null); + } if (!input) { return; } const $input = $(input); - $input.on('dataChange', () => { // TODO: remove jQuery event layer dependency - const reply = $input.data('reply'); - this.replyMessageData.set(reply); - }); - $input.autogrow({ - animate: true, - onInitialize: true, - }) - .on('autogrow', () => { - this.data && this.data.onResize && this.data.onResize(); - }); + $input.on('dataChange', () => { + const messages = $input.data('reply') || []; + this.replyMessageData.set(messages); + }); - chatMessages[RoomManager.openedRoom] = chatMessages[RoomManager.openedRoom] || new ChatMessages; - chatMessages[RoomManager.openedRoom].input = input; + $input.autogrow().on('autogrow', () => { + onResize && onResize(); + }); }); }); }); Template.messageBox.helpers({ - isEmbedded() { - return Layout.isEmbedded(); - }, - subscribed() { - return roomTypes.verifyCanSendMessage(this._id); - }, - canSend() { - if (roomTypes.readOnly(this._id, Meteor.user())) { - return false; - } - if (roomTypes.archived(this._id)) { + isAnonymousOrMustJoinWithCode() { + const { rid, subscription } = Template.currentData(); + if (!rid) { return false; } - const roomData = Session.get(`roomData${ this._id }`); - if (roomData && roomData.t === 'd') { - const subscription = ChatSubscription.findOne({ - rid: this._id, - }, { - fields: { - archived: 1, - blocked: 1, - blocker: 1, - }, - }); - if (subscription && (subscription.archived || subscription.blocked || subscription.blocker)) { - return false; - } + + const roomData = Session.get(`roomData${ rid }`); + const isAnonymous = !Meteor.userId(); + const mustJoinWithCode = !subscription && roomData && roomData.joinCodeRequired; + return isAnonymous || mustJoinWithCode; + }, + isWritable() { + const { rid, subscription } = Template.currentData(); + if (!rid) { + return true; } - return true; + + const roomData = Session.get(`roomData${ rid }`); + const isReadOnly = roomTypes.readOnly(rid, Meteor.user()); + const isArchived = roomTypes.archived(rid) || (roomData && roomData.t === 'd' && subscription && subscription.archived); + const isBlocked = (roomData && roomData.t === 'd' && subscription && subscription.blocked); + const isBlocker = (roomData && roomData.t === 'd' && subscription && subscription.blocker); + return !isReadOnly && !isArchived && !isBlocked && !isBlocker; }, popupConfig() { - const template = Template.instance(); - return { - getInput() { - return template.find('.js-input-message'); - }, - }; + return Template.instance().popupConfig.get(); }, input() { - return Template.instance().find('.js-input-message'); + return Template.instance().input; }, replyMessageData() { return Template.instance().replyMessageData.get(); @@ -232,53 +125,117 @@ Template.messageBox.helpers({ isSendIconDisabled() { return !Template.instance().sendIconDisabled.get(); }, + canSend() { + const { rid } = Template.currentData(); + if (!rid) { + return true; + } + + return roomTypes.verifyCanSendMessage(rid); + }, actions() { const actionGroups = messageBox.actions.get(); return Object.values(actionGroups) .reduce((actions, actionGroup) => [...actions, ...actionGroup], []); }, - isAnonymousOrJoinCode() { - const room = Session.get(`roomData${ this._id }`); - return !Meteor.userId() || (!ChatSubscription.findOne({ - rid: this._id, - }) && room && room.joinCodeRequired); - }, - showFormattingTips() { - return settings.get('Message_ShowFormattingTips'); - }, formattingButtons() { - return formattingButtons.filter((button) => !button.condition || button.condition()); + return formattingButtons.filter(({ condition }) => !condition || condition()); }, isBlockedOrBlocker() { - const roomData = Session.get(`roomData${ this._id }`); - if (roomData && roomData.t === 'd') { - const subscription = ChatSubscription.findOne({ - rid: this._id, - }, { - fields: { - blocked: 1, - blocker: 1, - }, - }); - if (subscription && (subscription.blocked || subscription.blocker)) { - return true; - } + const { rid, subscription } = Template.currentData(); + if (!rid) { + return true; } + + const roomData = Session.get(`roomData${ rid }`); + const isBlocked = (roomData && roomData.t === 'd' && subscription && subscription.blocked); + const isBlocker = (roomData && roomData.t === 'd' && subscription && subscription.blocker); + return isBlocked || isBlocker; }, }); +const handleFormattingShortcut = (event, instance) => { + const isMacOS = navigator.platform.indexOf('Mac') !== -1; + const isCmdOrCtrlPressed = (isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey); + + if (!isCmdOrCtrlPressed) { + return false; + } + + const key = event.key.toLowerCase(); + + const { pattern } = formattingButtons + .filter(({ condition }) => !condition || condition()) + .find(({ command }) => command === key) || {}; + + if (!pattern) { + return false; + } + + const { input } = instance; + applyFormatting(pattern, input); + return true; +}; + +const insertNewLine = (input) => { + if (document.selection) { + input.focus(); + const sel = document.selection.createRange(); + sel.text = '\n'; + } else if (input.selectionStart || input.selectionStart === 0) { + const newPosition = input.selectionStart + 1; + const before = input.value.substring(0, input.selectionStart); + const after = input.value.substring(input.selectionEnd, input.value.length); + input.value = `${ before }\n${ after }`; + input.selectionStart = input.selectionEnd = newPosition; + } else { + input.value += '\n'; + } + + input.blur(); + input.focus(); + input.updateAutogrow(); +}; + +const handleSubmit = (event, instance) => { + const { data: { rid, tmid, onSend }, input } = instance; + const { which: keyCode } = event; + + const isSubmitKey = keyCode === keyCodes.CARRIAGE_RETURN || keyCode === keyCodes.NEW_LINE; + + if (!isSubmitKey) { + return false; + } + + const sendOnEnter = getUserPreference(Meteor.userId(), 'sendOnEnter'); + const sendOnEnterActive = sendOnEnter == null || sendOnEnter === 'normal' || + (sendOnEnter === 'desktop' && Meteor.Device.isDesktop()); + const withModifier = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; + const isSending = (sendOnEnterActive && !withModifier) || (!sendOnEnterActive && withModifier); + + if (isSending) { + onSend && onSend.call(this, event, { rid, tmid, value: input.value }, () => { + input.updateAutogrow(); + input.focus(); + }); + return true; + } + + insertNewLine(input); + return true; +}; + Template.messageBox.events({ - 'click .js-join'(event) { + async 'click .js-join'(event) { event.stopPropagation(); event.preventDefault(); const joinCodeInput = Template.instance().find('[name=joinCode]'); const joinCode = joinCodeInput && joinCodeInput.value; - call('joinRoom', this._id, joinCode); + await call('joinRoom', this.rid, joinCode); }, - - 'click .emoji-picker-icon'(event) { + 'click .js-emoji-picker'(event, instance) { event.stopPropagation(); event.preventDefault(); @@ -293,7 +250,8 @@ Template.messageBox.events({ EmojiPicker.open(event.currentTarget, (emoji) => { const emojiValue = `:${ emoji }: `; - const { input } = chatMessages[RoomManager.openedRoom]; + + const { input } = instance; const caretPos = input.selectionStart; const textAreaTxt = input.value; @@ -308,29 +266,28 @@ Template.messageBox.events({ input.selectionEnd = caretPos + emojiValue.length; }); }, - 'focus .js-input-message'(event, instance) { - KonchatNotification.removeRoomNotification(this._id); - if (chatMessages[this._id]) { - chatMessages[this._id].input = instance.find('.js-input-message'); - } + 'focus .js-input-message'() { + KonchatNotification.removeRoomNotification(this.rid); }, - 'click .cancel-reply'(event, instance) { + 'keydown .js-input-message'(event, instance) { + const isEventHandled = handleFormattingShortcut(event, instance) || handleSubmit(event, instance); - const input = instance.find('.js-input-message'); - const messages = $(input).data('reply') || []; - const filtered = messages.filter((msg) => msg._id !== this._id); + if (isEventHandled) { + event.preventDefault(); + event.stopPropagation(); + return; + } - $(input) - .data('reply', filtered) - .trigger('dataChange'); + const { rid, tmid, onKeyDown } = this; + onKeyDown && onKeyDown.call(this, event, { rid, tmid }); }, - 'keyup .js-input-message'(event, instance) { - chatMessages[this._id].keyup(this._id, event, instance); - instance.isMessageFieldEmpty.set(chatMessages[this._id].isEmpty()); + 'keyup .js-input-message'(event) { + const { rid, tmid, onKeyUp } = this; + onKeyUp && onKeyUp.call(this, event, { rid, tmid }); }, 'paste .js-input-message'(event, instance) { - const { $input } = chatMessages[RoomManager.openedRoom]; - const [input] = $input; + const { rid, tmid } = this; + const { input } = instance; setTimeout(() => { typeof input.updateAutogrow === 'function' && input.updateAutogrow(); }, 50); @@ -349,35 +306,49 @@ Template.messageBox.events({ if (files.length) { event.preventDefault(); - fileUpload(files, input); + fileUpload(files, input, { rid, tmid }); return; } - - instance.isMessageFieldEmpty.set(false); - }, - 'keydown .js-input-message'(event, instance) { - const isMacOS = navigator.platform.indexOf('Mac') !== -1; - if (isMacOS && (event.metaKey || event.ctrlKey)) { - const action = formattingButtons.find( - (action) => action.command === event.key.toLowerCase() && (!action.condition || action.condition())); - action && applyFormatting.apply(action, [event, instance]); - } - chatMessages[this._id].keydown(this._id, event, Template.instance()); }, 'input .js-input-message'(event, instance) { - instance.sendIconDisabled.set(event.target.value !== ''); - chatMessages[this._id].valueChanged(this._id, event, Template.instance()); + const { input } = instance; + if (!input) { + return; + } + + instance.sendIconDisabled.set(!!input.value); + + if (input.value.length > 0) { + input.dir = isRTL(input.value) ? 'rtl' : 'ltr'; + } + + const { rid, tmid, onValueChanged } = this; + onValueChanged && onValueChanged.call(this, event, { rid, tmid }); }, - 'propertychange .js-input-message'(event) { - if (event.originalEvent.propertyName === 'value') { - chatMessages[this._id].valueChanged(this._id, event, Template.instance()); + 'propertychange .js-input-message'(event, instance) { + if (event.originalEvent.propertyName !== 'value') { + return; } + + const { input } = instance; + if (!input) { + return; + } + + instance.sendIconDisabled.set(!!input.value); + + if (input.value.length > 0) { + input.dir = isRTL(input.value) ? 'rtl' : 'ltr'; + } + + const { rid, tmid, onValueChanged } = this; + onValueChanged && onValueChanged.call(this, event, { rid, tmid }); }, async 'click .js-send'(event, instance) { - const { input } = chatMessages[RoomManager.openedRoom]; - chatMessages[this._id].send(this._id, input, () => { + const { input } = instance; + const { rid, tmid, onSend } = this; + onSend && onSend.call(this, event, { rid, tmid, value: input.value }, () => { input.updateAutogrow(); - instance.isMessageFieldEmpty.set(chatMessages[this._id].isEmpty()); input.focus(); }); }, @@ -408,7 +379,8 @@ Template.messageBox.events({ direction: 'top-inverted', currentTarget: event.currentTarget.firstElementChild.firstElementChild, data: { - rid: this._id, + rid: this.rid, + tmid: this.tmid, messageBox: instance.firstNode, }, activeElement: event.currentTarget, @@ -423,13 +395,26 @@ Template.messageBox.events({ .filter(({ action }) => !!action) .forEach(({ action }) => { action.call(null, { - rid: this._id, + rid: this.rid, + tmid: this.tmid, messageBox: instance.firstNode, event, }); }); }, - 'click .js-format'(e, t) { - applyFormatting.apply(this, [e, t]); + 'click .js-format'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const { id } = event.currentTarget.dataset; + const { pattern } = formattingButtons + .filter(({ condition }) => !condition || condition()) + .find(({ label }) => label === id) || {}; + + if (!pattern) { + return; + } + + applyFormatting(pattern, instance.input); }, }); diff --git a/app/ui-message/client/messageBoxAudioMessage.js b/app/ui-message/client/messageBoxAudioMessage.js index 9073726df0ef..88a940fac416 100644 --- a/app/ui-message/client/messageBoxAudioMessage.js +++ b/app/ui-message/client/messageBoxAudioMessage.js @@ -4,7 +4,7 @@ import { Tracker } from 'meteor/tracker'; import { Template } from 'meteor/templating'; import { fileUploadHandler } from '../../file-upload'; import { settings } from '../../settings'; -import { AudioRecorder, chatMessages } from '../../ui'; +import { AudioRecorder } from '../../ui'; import { call } from '../../ui-utils'; import { t } from '../../utils'; import './messageBoxAudioMessage.html'; @@ -39,7 +39,7 @@ const unregisterUploadProgress = (upload) => setTimeout(() => { Session.set('uploading', uploads.filter(({ id }) => id !== upload.id)); }, 2000); -const uploadRecord = async ({ rid, blob }) => { +const uploadRecord = async ({ rid, tmid, blob }) => { const upload = fileUploadHandler('Uploads', { name: `${ t('Audio record') }.mp3`, size: blob.size, @@ -59,7 +59,7 @@ const uploadRecord = async ({ rid, blob }) => { upload.start((error, ...args) => (error ? reject(error) : resolve(args))); }); - await call('sendFileMessage', rid, storage, file); + await call('sendFileMessage', rid, storage, file, { tmid }); unregisterUploadProgress(upload); } catch (error) { @@ -135,7 +135,6 @@ Template.messageBoxAudioMessage.events({ return; } - chatMessages[this.rid].recording = true; instance.state.set('recording'); try { @@ -153,7 +152,6 @@ Template.messageBoxAudioMessage.events({ } catch (error) { instance.state.set(null); instance.isMicrophoneDenied.set(true); - chatMessages[this.rid].recording = false; } }, @@ -171,7 +169,6 @@ Template.messageBoxAudioMessage.events({ await stopRecording(); instance.state.set(null); - chatMessages[this.rid].recording = false; }, async 'click .js-audio-message-done'(event, instance) { @@ -190,8 +187,8 @@ Template.messageBoxAudioMessage.events({ const blob = await stopRecording(); instance.state.set(null); - chatMessages[this.rid].recording = false; - await uploadRecord({ rid: this.rid, blob }); + const { rid, tmid } = this; + await uploadRecord({ rid, tmid, blob }); }, }); diff --git a/app/ui-message/client/messageBoxFormatting.js b/app/ui-message/client/messageBoxFormatting.js new file mode 100644 index 000000000000..2a6d41a4fd6e --- /dev/null +++ b/app/ui-message/client/messageBoxFormatting.js @@ -0,0 +1,107 @@ +import { katex } from '../../katex/client'; +import { Markdown } from '../../markdown/client'; +import { settings } from '../../settings'; + + +export const formattingButtons = [ + { + label: 'bold', + icon: 'bold', + pattern: '*{{text}}*', + command: 'b', + condition: () => Markdown && settings.get('Markdown_Parser') === 'original', + }, + { + label: 'bold', + icon: 'bold', + pattern: '**{{text}}**', + command: 'b', + condition: () => Markdown && settings.get('Markdown_Parser') === 'marked', + }, + { + label: 'italic', + icon: 'italic', + pattern: '_{{text}}_', + command: 'i', + condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', + }, + { + label: 'strike', + icon: 'strike', + pattern: '~{{text}}~', + condition: () => Markdown && settings.get('Markdown_Parser') === 'original', + }, + { + label: 'strike', + icon: 'strike', + pattern: '~~{{text}}~~', + condition: () => Markdown && settings.get('Markdown_Parser') === 'marked', + }, + { + label: 'inline_code', + icon: 'code', + pattern: '`{{text}}`', + condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', + }, + { + label: 'multi_line', + icon: 'multiline', + pattern: '```\n{{text}}\n``` ', + condition: () => Markdown && settings.get('Markdown_Parser') !== 'disabled', + }, + { + label: 'KaTeX', + text: () => { + if (!katex.isEnabled()) { + return; + } + if (katex.isDollarSyntaxEnabled()) { + return '$$KaTeX$$'; + } + if (katex.isParenthesisSyntaxEnabled()) { + return '\\[KaTeX\\]'; + } + }, + link: 'https://khan.github.io/KaTeX/function-support.html', + condition: () => katex.isEnabled(), + }, +]; + +export function applyFormatting(pattern, input) { + const { selectionEnd = input.value.length, selectionStart = 0 } = input; + const initText = input.value.slice(0, selectionStart); + const selectedText = input.value.slice(selectionStart, selectionEnd); + const finalText = input.value.slice(selectionEnd, input.value.length); + + input.focus(); + + const startPattern = pattern.substr(0, pattern.indexOf('{{text}}')); + const startPatternFound = [...startPattern].reverse().every((char, index) => input.value.substr(selectionStart - index - 1, 1) === char); + + if (startPatternFound) { + const endPattern = pattern.substr(pattern.indexOf('{{text}}') + '{{text}}'.length); + const endPatternFound = [...endPattern].every((char, index) => input.value.substr(selectionEnd + index, 1) === char); + + if (endPatternFound) { + input.selectionStart = selectionStart - startPattern.length; + input.selectionEnd = selectionEnd + endPattern.length; + + if (!document.execCommand || !document.execCommand('insertText', false, selectedText)) { + input.value = initText.substr(0, initText.length - startPattern.length) + selectedText + finalText.substr(endPattern.length); + } + + input.selectionStart = selectionStart - startPattern.length; + input.selectionEnd = input.selectionStart + selectedText.length; + $(input).change(); + return; + } + } + + if (!document.execCommand || !document.execCommand('insertText', false, pattern.replace('{{text}}', selectedText))) { + input.value = initText + pattern.replace('{{text}}', selectedText) + finalText; + } + + input.selectionStart = selectionStart + pattern.indexOf('{{text}}'); + input.selectionEnd = input.selectionStart + selectedText.length; + $(input).change(); +} diff --git a/app/ui-message/client/messageBoxNotSubscribed.html b/app/ui-message/client/messageBoxNotSubscribed.html index feccd6e089b2..c1813279b4cb 100644 --- a/app/ui-message/client/messageBoxNotSubscribed.html +++ b/app/ui-message/client/messageBoxNotSubscribed.html @@ -1,4 +1,4 @@ -