From d0881e6fef05df04fb88c3c87335698d4dc4fb61 Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Fri, 21 Feb 2025 17:43:28 +1100 Subject: [PATCH] FEATURE: Type reactions in chat (#31439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change allows you to add a reaction to the most recent message, by sending a reaction message. A reaction message can be formatted as `+{emoji}` (eg, `+❤️`), or as `+{emoji_code}` (eg, `+:heart:`). --- .../discourse/app/lib/autocomplete.js | 2 +- .../javascripts/pretty-text/addon/emoji.js | 8 ++ .../pretty-text/addon/emoji/data.js | 1 + lib/emoji/db.json | 4 + .../discourse/components/chat-composer.js | 45 +++++++++++- .../components/chat/composer/channel.js | 2 +- .../components/chat/composer/thread.js | 4 + .../chat/spec/system/chat_composer_spec.rb | 73 +++++++++++++++++++ .../system/page_objects/chat/chat_thread.rb | 10 +++ 9 files changed, 144 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/autocomplete.js b/app/assets/javascripts/discourse/app/lib/autocomplete.js index 247092ac9b8..b07c12ddb32 100644 --- a/app/assets/javascripts/discourse/app/lib/autocomplete.js +++ b/app/assets/javascripts/discourse/app/lib/autocomplete.js @@ -18,7 +18,7 @@ import Site from "discourse/models/site"; export const SKIP = "skip"; export const CANCELLED_STATUS = "__CANCELLED"; -const ALLOWED_LETTERS_REGEXP = /[\s[{(/]/; +const ALLOWED_LETTERS_REGEXP = /[\s[{(/+]/; let _autoCompletePopper, _inputTimeout; const keys = { diff --git a/app/assets/javascripts/pretty-text/addon/emoji.js b/app/assets/javascripts/pretty-text/addon/emoji.js index 78c29b30ec6..538283777e4 100644 --- a/app/assets/javascripts/pretty-text/addon/emoji.js +++ b/app/assets/javascripts/pretty-text/addon/emoji.js @@ -184,6 +184,14 @@ export function emojiExists(code) { return extendedEmojiMap.has(code) || emojiMap.has(code) || aliasMap.has(code); } +export function normalizeEmoji(code) { + code = code.toLowerCase(); + if (extendedEmojiMap.get(code) || emojiMap.get(code)) { + return code; + } + return aliasMap.get(code); +} + let toSearch; export function emojiSearch(term, options) { const maxResults = options?.maxResults; diff --git a/app/assets/javascripts/pretty-text/addon/emoji/data.js b/app/assets/javascripts/pretty-text/addon/emoji/data.js index f5b3933ef4a..3d54dce5fc7 100644 --- a/app/assets/javascripts/pretty-text/addon/emoji/data.js +++ b/app/assets/javascripts/pretty-text/addon/emoji/data.js @@ -8018,6 +8018,7 @@ export const replacements = { "🖌": "paintbrush", "🔍": "mag", "🔎": "mag_right", + "❤️": "heart", "❤": "heart", "💛": "yellow_heart", "💚": "green_heart", diff --git a/lib/emoji/db.json b/lib/emoji/db.json index 0cbab5704b8..9e40e0e20d4 100644 --- a/lib/emoji/db.json +++ b/lib/emoji/db.json @@ -3656,6 +3656,10 @@ "code": "1f50e", "name": "mag_right" }, + { + "code": "2764-fe0f", + "name": "heart" + }, { "code": "2764", "name": "heart" diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index 7781d38f1d1..1f7d0b5d871 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -6,8 +6,12 @@ import { cancel, next } from "@ember/runloop"; import { service } from "@ember/service"; import { isPresent } from "@ember/utils"; import $ from "jquery"; -import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; -import { translations } from "pretty-text/emoji/data"; +import { + emojiSearch, + isSkinTonableEmoji, + normalizeEmoji, +} from "pretty-text/emoji"; +import { replacements, translations } from "pretty-text/emoji/data"; import { Promise } from "rsvp"; import EmojiPickerDetached from "discourse/components/emoji-picker/detached"; import InsertHyperlink from "discourse/components/modal/insert-hyperlink"; @@ -248,10 +252,45 @@ export default class ChatComposer extends Component { return; } + if (await this.reactingToLastMessage()) { + return; + } + await this.args.onSendMessage(this.draft); this.composer.textarea.refreshHeight(); } + async reactingToLastMessage() { + // Check if the message is a reaction to the latest message in the channel. + const message = this.draft.message.trim(); + let reactionCode = ""; + if (message.startsWith("+")) { + const reaction = message.substring(1); + // First check if the message is +{emoji} + if (replacements[reaction]) { + reactionCode = replacements[reaction]; + } else { + // Then check if the message is +:{emoji_code}: + const emojiCode = reaction.substring(1, reaction.length - 1); + reactionCode = normalizeEmoji(emojiCode); + } + } + + if (reactionCode && this.lastMessage?.id) { + const interactor = new ChatMessageInteractor( + getOwner(this), + this.lastMessage, + this.context + ); + + await interactor.react(reactionCode, "add"); + this.resetDraft(); + return true; + } + + return false; + } + reportReplyingPresence() { if (!this.args.channel || !this.draft) { return; @@ -478,7 +517,7 @@ export default class ChatComposer extends Component { treatAsTextarea: true, onKeyUp: (text, cp) => { const matches = - /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( + /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()+])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( text.substring(0, cp) ); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js index 049c3d9777b..9bee259674f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/channel.js @@ -46,7 +46,7 @@ export default class ChatComposerChannel extends ChatComposer { } get lastMessage() { - return this.args.channel.lastMessage; + return this.args.channel.messagesManager.findLastMessage(); } lastUserMessage(user) { diff --git a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js index 6928783794d..580401d3ac6 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat/composer/thread.js @@ -58,6 +58,10 @@ export default class ChatComposerThread extends ChatComposer { return i18n("chat.placeholder_thread"); } + get lastMessage() { + return this.args.thread.messagesManager.findLastMessage(); + } + lastUserMessage(user) { return this.args.thread.messagesManager.findLastUserMessage(user); } diff --git a/plugins/chat/spec/system/chat_composer_spec.rb b/plugins/chat/spec/system/chat_composer_spec.rb index 80d7e29e0ba..bc0d6b0954a 100644 --- a/plugins/chat/spec/system/chat_composer_spec.rb +++ b/plugins/chat/spec/system/chat_composer_spec.rb @@ -8,6 +8,8 @@ RSpec.describe "Chat composer", type: :system do let(:chat_page) { PageObjects::Pages::Chat.new } let(:channel_page) { PageObjects::Pages::ChatChannel.new } let(:cdp) { PageObjects::CDP.new } + let(:side_panel) { PageObjects::Pages::ChatSidePanel.new } + let(:open_thread) { PageObjects::Pages::ChatThread.new } before do chat_system_bootstrap @@ -244,4 +246,75 @@ RSpec.describe "Chat composer", type: :system do end end end + + context "when sending a react message" do + fab!(:react_message) do + Fabricate(:chat_message, user: current_user, chat_channel: channel_1, message: "HI!") + end + + context "in a channel" do + it "adds a reaction to the message" do + chat_page.visit_channel(channel_1) + + channel_page.send_message("+:+1:") + + expect(channel_page).to have_reaction(react_message, "+1") + end + + it "works with literal emoji" do + chat_page.visit_channel(channel_1) + + channel_page.send_message("+👍") + + expect(channel_page).to have_reaction(react_message, "+1") + end + end + + context "in a thread" do + fab!(:original_message) do + Fabricate(:chat_message, chat_channel: channel_1, user: current_user) + end + fab!(:thread) do + Fabricate(:chat_thread, channel: channel_1, original_message: original_message) + end + + fab!(:thread_message) do + Fabricate( + :chat_message, + in_reply_to_id: original_message.id, + chat_channel: channel_1, + thread_id: thread.id, + user: current_user, + ) + end + + before do + channel_1.update!(threading_enabled: true) + Chat::Thread.update_counts + thread.add(current_user) + end + + it "adds a reaction to the message" do + chat_page.visit_channel(channel_1) + channel_page.message_thread_indicator(original_message).click + + expect(side_panel).to have_open_thread(original_message.thread) + + open_thread.send_message("+:+1:") + + expect(open_thread).to have_reaction(thread_message, "+1") + end + + it "works with literal emoji" do + chat_page.visit_channel(channel_1) + channel_page.message_thread_indicator(original_message).click + + expect(side_panel).to have_open_thread(original_message.thread) + + open_thread.send_message("+👍") + + expect(open_thread).to have_reaction(thread_message, "+1") + end + end + end end diff --git a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb index 86b2f282a19..92905b3350e 100644 --- a/plugins/chat/spec/system/page_objects/chat/chat_thread.rb +++ b/plugins/chat/spec/system/page_objects/chat/chat_thread.rb @@ -140,6 +140,16 @@ module PageObjects end end + def has_reaction?(message, emoji, text = nil) + within(message_reactions_list(message)) do + has_css?("[data-emoji-name=\"#{emoji}\"]", text: text) + end + end + + def message_reactions_list(message) + within(message_by_id(message.id)) { find(".chat-message-reaction-list") } + end + def message_by_id(id) find(message_by_id_selector(id)) end