diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 26219ba..28985c8 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -69,10 +69,12 @@ from meshchatx.src.backend.lxmf_message_fields import ( LxmfImageField, ) from meshchatx.src.backend.lxmf_utils import ( + LXMF_APP_EXTENSIONS_FIELD, compute_lxmf_conversation_unread_from_latest_row, convert_db_lxmf_message_to_dict, convert_lxmf_message_to_dict, convert_lxmf_state_to_string, + lxmf_fields_are_columba_reaction, ) from meshchatx.src.backend.map_manager import TRANSPARENT_TILE from meshchatx.src.backend.page_node_manager import PageNodeManager @@ -8152,6 +8154,42 @@ class ReticulumMeshChat: status=503, ) + @routes.post("/api/v1/lxmf-messages/reactions") + async def lxmf_messages_reactions(request): + data = await request.json() + destination_hash = data.get("destination_hash") + target_message_hash = data.get("target_message_hash") + emoji = data.get("emoji", "") + if not destination_hash or not target_message_hash or not emoji: + return web.json_response( + { + "message": "destination_hash, target_message_hash, and emoji are required", + }, + status=422, + ) + try: + lxmf_message = await self.send_reaction( + destination_hash=destination_hash, + target_message_hash=target_message_hash, + emoji=emoji, + ) + return web.json_response( + { + "lxmf_message": convert_lxmf_message_to_dict( + lxmf_message, + include_attachments=False, + reticulum=self.reticulum, + ), + }, + ) + except Exception as e: + return web.json_response( + { + "message": str(e), + }, + status=503, + ) + # cancel sending lxmf message @routes.post("/api/v1/lxmf-messages/{hash}/cancel") async def lxmf_messages_cancel(request): @@ -12131,9 +12169,22 @@ class ReticulumMeshChat: message_content = ( lxmf_message.content if hasattr(lxmf_message, "content") else "" ) + if isinstance(message_content, bytes): + message_content = message_content.decode("utf-8", errors="replace") + elif message_content is None: + message_content = "" + if isinstance(message_title, bytes): + message_title = message_title.decode("utf-8", errors="replace") + elif message_title is None: + message_title = "" + + is_reaction_delivery = lxmf_fields_are_columba_reaction(lxmf_fields) # check spam keywords - if self.check_spam_keywords(message_title, message_content): + if not is_reaction_delivery and self.check_spam_keywords( + message_title, + message_content, + ): is_spam = True print( f"Marking LXMF message as spam due to keyword match: {source_hash}", @@ -12339,6 +12390,13 @@ class ReticulumMeshChat: ] file_attachments_field = LxmfFileAttachmentsField(attachments) + app_extensions = None + if LXMF_APP_EXTENSIONS_FIELD in lxmf_fields and isinstance( + lxmf_fields[LXMF_APP_EXTENSIONS_FIELD], + dict, + ): + app_extensions = lxmf_fields[LXMF_APP_EXTENSIONS_FIELD] + # check if this message is for an alias identity (REPLY PATH) mapping = ctx.database.messages.get_forwarding_mapping( alias_hash=destination_hash, @@ -12359,6 +12417,7 @@ class ReticulumMeshChat: image_field=image_field, audio_field=audio_field, file_attachments_field=file_attachments_field, + app_extensions=app_extensions, context=ctx, ), ) @@ -12401,6 +12460,7 @@ class ReticulumMeshChat: image_field=image_field, audio_field=audio_field, file_attachments_field=file_attachments_field, + app_extensions=app_extensions, context=ctx, ), ) @@ -12572,6 +12632,7 @@ class ReticulumMeshChat: sender_identity_hash: str = None, reply_to_hash: str = None, reply_quoted_content: str = None, + app_extensions: dict = None, no_display: bool = False, context=None, ) -> LXMF.LXMessage: @@ -12579,6 +12640,25 @@ class ReticulumMeshChat: if not ctx: raise RuntimeError("No identity context available for sending message") + if isinstance(content, bytes): + content_str = content.decode("utf-8", errors="replace") + else: + content_str = content or "" + quoted_str = reply_quoted_content or "" + is_reaction_only = bool( + app_extensions + and isinstance(app_extensions, dict) + and ("reaction_to" in app_extensions) + and not (content_str and content_str.strip()) + and image_field is None + and audio_field is None + and file_attachments_field is None + and telemetry_data is None + and commands is None + and reply_to_hash is None + and not (quoted_str and quoted_str.strip()) + ) + # convert destination hash to bytes destination_hash_bytes = bytes.fromhex(destination_hash) @@ -12655,9 +12735,10 @@ class ReticulumMeshChat: lxmf_message.fields = {} - lxmf_message.fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN + if not is_reaction_only: + lxmf_message.fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN - if self._is_contact(destination_hash, context=ctx): + if self._is_contact(destination_hash, context=ctx) and not is_reaction_only: lxmf_message.include_ticket = True # add file attachments field @@ -12702,9 +12783,12 @@ class ReticulumMeshChat: if reply_quoted_content is not None and reply_quoted_content: lxmf_message.fields[0x31] = reply_quoted_content.encode("utf-8") + if app_extensions is not None: + lxmf_message.fields[LXMF_APP_EXTENSIONS_FIELD] = app_extensions + # add icon appearance if configured and not already sent to this destination current_icon_hash = self.get_current_icon_hash() - if current_icon_hash is not None: + if current_icon_hash is not None and not is_reaction_only: last_sent_icon_hash = self.database.misc.get_last_sent_icon_hash( destination_hash, ) @@ -12783,6 +12867,30 @@ class ReticulumMeshChat: return lxmf_message + async def send_reaction( + self, + destination_hash: str, + target_message_hash: str, + emoji: str, + context=None, + ) -> LXMF.LXMessage: + ctx = context or self.current_context + if not ctx: + raise RuntimeError("No identity context available for sending reaction") + sender_hex = ctx.identity.hash.hex() + app_extensions = { + "reaction_to": target_message_hash, + "emoji": emoji, + "sender": sender_hex, + } + return await self.send_message( + destination_hash=destination_hash, + content="", + delivery_method="opportunistic", + app_extensions=app_extensions, + context=context, + ) + # get hash of current icon appearance configuration def get_current_icon_hash(self, context=None): ctx = context or self.current_context diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py index b1b2352..3f337f8 100644 --- a/meshchatx/src/backend/lxmf_utils.py +++ b/meshchatx/src/backend/lxmf_utils.py @@ -5,6 +5,16 @@ import LXMF from meshchatx.src.backend.telemetry_utils import Telemeter +# Columba-compatible app extensions (emoji reactions, reply metadata, etc.) +LXMF_APP_EXTENSIONS_FIELD = 16 + + +def lxmf_fields_are_columba_reaction(lxmf_fields: dict) -> bool: + if not isinstance(lxmf_fields, dict): + return False + val = lxmf_fields.get(LXMF_APP_EXTENSIONS_FIELD) + return isinstance(val, dict) and "reaction_to" in val + def convert_lxmf_message_to_dict( lxmf_message: LXMF.LXMessage, @@ -14,6 +24,10 @@ def convert_lxmf_message_to_dict( # handle fields fields = {} message_fields = lxmf_message.get_fields() + is_reaction = False + reaction_to = None + reaction_emoji = None + reaction_sender = None for field_type in message_fields: value = message_fields[field_type] @@ -123,6 +137,14 @@ def convert_lxmf_message_to_dict( else value ) + if field_type == LXMF_APP_EXTENSIONS_FIELD and isinstance(value, dict): + fields["app_extensions"] = dict(value) + if "reaction_to" in value: + is_reaction = True + reaction_to = value.get("reaction_to") or "" + reaction_emoji = value.get("emoji") or "" + reaction_sender = value.get("sender") or "" + # convert 0.0-1.0 progress to 0.00-100 percentage progress_percentage = round(lxmf_message.progress * 100, 2) @@ -161,7 +183,7 @@ def convert_lxmf_message_to_dict( if match: reply_to_hash = match.group(1) - return { + out = { "hash": lxmf_message.hash.hex(), "source_hash": lxmf_message.source_hash.hex(), "destination_hash": lxmf_message.destination_hash.hex(), @@ -185,7 +207,13 @@ def convert_lxmf_message_to_dict( "snr": snr, "quality": quality, "reply_to_hash": reply_to_hash, + "is_reaction": is_reaction, } + if is_reaction: + out["reaction_to"] = reaction_to + out["reaction_emoji"] = reaction_emoji + out["reaction_sender"] = reaction_sender + return out def convert_lxmf_state_to_string(lxmf_message: LXMF.LXMessage): @@ -239,6 +267,17 @@ def convert_db_lxmf_message_to_dict( if not isinstance(fields, dict): fields = {} + is_reaction = False + reaction_to = None + reaction_emoji = None + reaction_sender = None + app = fields.get("app_extensions") + if isinstance(app, dict) and "reaction_to" in app: + is_reaction = True + reaction_to = app.get("reaction_to") or "" + reaction_emoji = app.get("emoji") or "" + reaction_sender = app.get("sender") or "" + # normalize commands if present if "commands" in fields: cmds = fields["commands"] @@ -329,7 +368,7 @@ def convert_db_lxmf_message_to_dict( if updated_at and "+" not in updated_at and "Z" not in updated_at: updated_at += "Z" - return { + out = { "id": db_lxmf_message["id"], "hash": db_lxmf_message["hash"], "source_hash": db_lxmf_message["source_hash"], @@ -352,7 +391,13 @@ def convert_db_lxmf_message_to_dict( "attachments_stripped": bool(db_lxmf_message.get("attachments_stripped", 0)), "created_at": created_at, "updated_at": updated_at, + "is_reaction": is_reaction, } + if is_reaction: + out["reaction_to"] = reaction_to + out["reaction_emoji"] = reaction_emoji + out["reaction_sender"] = reaction_sender + return out def compute_lxmf_conversation_unread_from_latest_row(row): diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index b85e90a..26a28f6 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -1313,6 +1313,18 @@ +
+ {{ r.emoji }} +
+
-
- - +
+
+ - View Raw LXM - - - - -
- - + Delete +
+ @@ -2391,6 +2415,9 @@ import relativeTime from "dayjs/plugin/relativeTime"; dayjs.extend(relativeTime); import SendMessageButton from "./SendMessageButton.vue"; import MaterialDesignIcon from "../MaterialDesignIcon.vue"; +import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue"; +import ContextMenuItem from "../contextmenu/ContextMenuItem.vue"; +import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue"; import ConversationDropDownMenu from "./ConversationDropDownMenu.vue"; import AddImageButton from "./AddImageButton.vue"; import AudioWaveformPlayer from "./AudioWaveformPlayer.vue"; @@ -2401,6 +2428,7 @@ import ToastUtils from "../../js/ToastUtils"; import PaperMessageModal from "./PaperMessageModal.vue"; import GlobalState from "../../js/GlobalState"; import MarkdownRenderer from "../../js/MarkdownRenderer"; +import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions"; import { createOutboundQueue } from "../../js/outboundSendQueue"; import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"; import "emoji-picker-element"; @@ -2410,6 +2438,9 @@ export default { components: { IconButton, AddImageButton, + ContextMenuDivider, + ContextMenuItem, + ContextMenuPanel, ConversationDropDownMenu, MaterialDesignIcon, SendMessageButton, @@ -2519,6 +2550,7 @@ export default { chatItem: null, justOpened: false, }, + columbaReactionEmojis: COLUMBA_REACTION_EMOJIS, userStickers: [], isStickerPickerOpen: false, emojiStickerTab: "emoji", @@ -2714,6 +2746,10 @@ export default { return false; } + if (chatItem.lxmf_message.is_reaction) { + return false; + } + return true; } @@ -3224,7 +3260,7 @@ export default { // convert lxmf messages to chat items const chatItems = []; - const lxmfMessages = response.data.lxmf_messages; + const lxmfMessages = mergeLxmfReactionRowsIntoMessages(response.data.lxmf_messages); for (const lxmfMessage of lxmfMessages) { chatItems.push({ type: "lxmf_message", @@ -3484,6 +3520,11 @@ export default { return; } + if (lxmfMessage.is_reaction && lxmfMessage.reaction_to) { + this.applyIncomingReaction(lxmfMessage); + return; + } + this.chatItems.push({ type: "lxmf_message", is_outbound: false, @@ -3795,6 +3836,81 @@ export default { const item = this.chatItems.find((i) => i.lxmf_message?.hash === hash); return item ? item.lxmf_message : null; }, + reactionReactorLabel(senderHex) { + if (!senderHex || typeof senderHex !== "string") { + return ""; + } + const hex = senderHex.toLowerCase(); + if (this.myLxmfAddressHash && hex === String(this.myLxmfAddressHash).toLowerCase()) { + return this.$t("messages.reaction_you"); + } + if ( + this.selectedPeer?.destination_hash && + hex === String(this.selectedPeer.destination_hash).toLowerCase() + ) { + return ( + this.selectedPeer.custom_display_name ?? + this.selectedPeer.display_name ?? + this.formatDestinationHash(hex) + ); + } + const conv = this.conversations.find( + (c) => c.destination_hash && String(c.destination_hash).toLowerCase() === hex, + ); + if (conv) { + return conv.custom_display_name ?? conv.display_name ?? this.formatDestinationHash(hex); + } + return this.formatDestinationHash(hex); + }, + applyIncomingReaction(lxmfMessage) { + const target = this.chatItems.find((i) => i.lxmf_message?.hash === lxmfMessage.reaction_to); + if (!target || !target.lxmf_message) { + return; + } + if (!target.lxmf_message.reactions) { + target.lxmf_message.reactions = []; + } + const sender = lxmfMessage.reaction_sender || lxmfMessage.source_hash || ""; + const emoji = lxmfMessage.reaction_emoji || ""; + const dup = target.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji); + if (dup) { + return; + } + target.lxmf_message.reactions.push({ + emoji, + sender, + reactionHash: lxmfMessage.hash, + }); + }, + async sendReactionEmojiFromMenu(chatItem, emoji) { + this.messageContextMenu.show = false; + const hash = chatItem.lxmf_message?.hash; + if (!hash || !this.selectedPeer?.destination_hash) { + return; + } + try { + await window.api.post("/api/v1/lxmf-messages/reactions", { + destination_hash: this.selectedPeer.destination_hash, + target_message_hash: hash, + emoji, + }); + const sender = this.myLxmfAddressHash; + if (!chatItem.lxmf_message.reactions) { + chatItem.lxmf_message.reactions = []; + } + const dup = chatItem.lxmf_message.reactions.some((r) => r.sender === sender && r.emoji === emoji); + if (!dup) { + chatItem.lxmf_message.reactions.push({ + emoji, + sender, + reactionHash: null, + }); + } + } catch (e) { + console.error(e); + ToastUtils.error(this.$t("messages.reaction_send_failed")); + } + }, onMessageContextMenu(event, chatItem) { this.messageContextMenu.chatItem = chatItem; this.messageContextMenu.justOpened = true; diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js new file mode 100644 index 0000000..f99620f --- /dev/null +++ b/meshchatx/src/frontend/js/lxmfReactions.js @@ -0,0 +1,52 @@ +/** + * Columba-compatible LXMF field-16 reactions: merge reaction rows onto parent messages. + */ + +export const COLUMBA_REACTION_EMOJIS = [ + "\u{1F44D}", + "\u2764\uFE0F", + "\u{1F602}", + "\u{1F62E}", + "\u{1F622}", + "\u{1F621}", +]; + +export function mergeLxmfReactionRowsIntoMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + const parents = []; + const reactions = []; + for (const m of messages) { + if (!m) { + continue; + } + if (m.is_reaction) { + reactions.push(m); + } else { + parents.push({ ...m, reactions: [] }); + } + } + const byHash = new Map(parents.map((p) => [p.hash, p])); + for (const r of reactions) { + const targetId = r.reaction_to; + if (!targetId) { + continue; + } + const parent = byHash.get(targetId); + if (!parent) { + continue; + } + const sender = r.reaction_sender || r.source_hash || ""; + const emoji = r.reaction_emoji || ""; + const dup = parent.reactions.some((x) => x.sender === sender && x.emoji === emoji); + if (!dup) { + parent.reactions.push({ + emoji, + sender, + reactionHash: r.hash, + }); + } + } + return parents; +} diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index fa6444d..2d28a3f 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -824,6 +824,9 @@ "reply": "Antworten", "replying_to": "Antwort an", "message_actions": "Nachrichtenaktionen", + "react": "Reagieren", + "reaction_you": "Du", + "reaction_send_failed": "Reaktion konnte nicht gesendet werden", "message_not_found_in_cache": "Nachricht nicht im Cache gefunden" }, "nomadnet": { diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 6b5810c..f64e6f4 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -774,6 +774,9 @@ "reply": "Reply", "replying_to": "Replying to", "message_actions": "Message actions", + "react": "React", + "reaction_you": "You", + "reaction_send_failed": "Could not send reaction", "message_not_found_in_cache": "Message not found in cache" }, "settings": { diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index 09bb16e..7615730 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -824,6 +824,9 @@ "reply": "Rispondi", "replying_to": "In risposta a", "message_actions": "Azioni messaggio", + "react": "Reagisci", + "reaction_you": "Tu", + "reaction_send_failed": "Impossibile inviare la reazione", "message_not_found_in_cache": "Messaggio non trovato nella cache" }, "settings": { diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index 78f17ee..4cc703a 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -824,6 +824,9 @@ "reply": "Ответить", "replying_to": "Ответ на", "message_actions": "Действия с сообщением", + "react": "Реакции", + "reaction_you": "Вы", + "reaction_send_failed": "Не удалось отправить реакцию", "message_not_found_in_cache": "Сообщение не найдено в кэше" }, "nomadnet": { diff --git a/tests/backend/test_lxmf_reactions.py b/tests/backend/test_lxmf_reactions.py new file mode 100644 index 0000000..4e56efa --- /dev/null +++ b/tests/backend/test_lxmf_reactions.py @@ -0,0 +1,119 @@ +import json +from unittest.mock import MagicMock + +import LXMF + +from meshchatx.src.backend.lxmf_utils import ( + LXMF_APP_EXTENSIONS_FIELD, + convert_db_lxmf_message_to_dict, + convert_lxmf_message_to_dict, + lxmf_fields_are_columba_reaction, +) + + +def test_lxmf_fields_are_columba_reaction(): + assert lxmf_fields_are_columba_reaction({}) is False + assert lxmf_fields_are_columba_reaction({16: {"reaction_to": "abc"}}) is True + assert lxmf_fields_are_columba_reaction({16: {"reply_to": "x"}}) is False + + +def test_convert_lxmf_message_to_dict_reaction_field_16(): + mock_msg = MagicMock(spec=LXMF.LXMessage) + mock_msg.hash = b"h" * 8 + mock_msg.source_hash = b"s" * 8 + mock_msg.destination_hash = b"d" * 8 + mock_msg.incoming = True + mock_msg.state = LXMF.LXMessage.SENT + mock_msg.progress = 1.0 + mock_msg.method = LXMF.LXMessage.OPPORTUNISTIC + mock_msg.delivery_attempts = 0 + mock_msg.title = b"" + mock_msg.content = b"" + mock_msg.timestamp = 1000 + mock_msg.rssi = None + mock_msg.snr = None + mock_msg.q = None + target = "a" * 32 + mock_msg.get_fields.return_value = { + LXMF_APP_EXTENSIONS_FIELD: { + "reaction_to": target, + "emoji": "\U0001f44d", + "sender": "f" * 32, + } + } + + out = convert_lxmf_message_to_dict(mock_msg, include_attachments=False) + + assert out["is_reaction"] is True + assert out["reaction_to"] == target + assert out["reaction_emoji"] == "\U0001f44d" + assert out["reaction_sender"] == "f" * 32 + assert "app_extensions" in out["fields"] + assert out["fields"]["app_extensions"]["reaction_to"] == target + + +def test_convert_db_lxmf_message_to_dict_reaction(): + target = "aa" * 16 + fields_obj = { + "app_extensions": { + "reaction_to": target, + "emoji": "\u2764\ufe0f", + "sender": "11" * 16, + } + } + row = { + "id": 1, + "hash": "ab" * 16, + "source_hash": "cd" * 16, + "destination_hash": "ef" * 16, + "is_incoming": 1, + "state": "delivered", + "progress": 100.0, + "method": "opportunistic", + "delivery_attempts": 0, + "next_delivery_attempt_at": None, + "title": "", + "content": "", + "fields": json.dumps(fields_obj), + "timestamp": 1.0, + "rssi": None, + "snr": None, + "quality": None, + "is_spam": 0, + "reply_to_hash": None, + "attachments_stripped": 0, + "created_at": "2020-01-01T00:00:00", + "updated_at": "2020-01-01T00:00:00", + } + + out = convert_db_lxmf_message_to_dict(row, include_attachments=False) + assert out["is_reaction"] is True + assert out["reaction_to"] == target + assert out["reaction_emoji"] == "\u2764\ufe0f" + assert out["reaction_sender"] == "11" * 16 + + +def test_convert_lxmf_message_to_dict_non_reaction_field_16_reply_to(): + mock_msg = MagicMock(spec=LXMF.LXMessage) + mock_msg.hash = b"h" * 8 + mock_msg.source_hash = b"s" * 8 + mock_msg.destination_hash = b"d" * 8 + mock_msg.incoming = True + mock_msg.state = LXMF.LXMessage.SENT + mock_msg.progress = 1.0 + mock_msg.method = LXMF.LXMessage.DIRECT + mock_msg.delivery_attempts = 0 + mock_msg.title = b"" + mock_msg.content = b"hi" + mock_msg.timestamp = 1000 + mock_msg.rssi = None + mock_msg.snr = None + mock_msg.q = None + mock_msg.get_fields.return_value = { + LXMF_APP_EXTENSIONS_FIELD: {"reply_to": "someid", "pending": True}, + } + + out = convert_lxmf_message_to_dict(mock_msg, include_attachments=False) + assert out["is_reaction"] is False + assert "reaction_to" not in out + assert "app_extensions" in out["fields"] diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js index 1f2ddca..5cad059 100644 --- a/tests/frontend/ConversationViewerButtons.test.js +++ b/tests/frontend/ConversationViewerButtons.test.js @@ -171,7 +171,7 @@ describe("ConversationViewer.vue button interactions", () => { expect(wrapper.vm.messageContextMenu.show).toBe(true); - const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find( + const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find( (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") ); expect(menuEl).toBeTruthy(); @@ -201,7 +201,7 @@ describe("ConversationViewer.vue button interactions", () => { wrapper.vm.messageContextMenu.show = true; await wrapper.vm.$nextTick(); - const menuEl = Array.from(document.body.querySelectorAll(".fixed")).find( + const menuEl = Array.from(document.body.querySelectorAll(".context-menu-panel")).find( (el) => el.textContent?.includes("Reply") && el.textContent?.includes("Delete") ); const deleteBtn = menuEl diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js new file mode 100644 index 0000000..6b3a815 --- /dev/null +++ b/tests/frontend/lxmfReactions.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { mergeLxmfReactionRowsIntoMessages } from "../../meshchatx/src/frontend/js/lxmfReactions"; + +describe("mergeLxmfReactionRowsIntoMessages", () => { + it("merges reaction rows onto parents and drops reaction-only rows", () => { + const parentHash = "a".repeat(32); + const incoming = [ + { + hash: parentHash, + source_hash: "b".repeat(32), + content: "hello", + is_reaction: false, + }, + { + hash: "c".repeat(32), + source_hash: "d".repeat(32), + content: "", + is_reaction: true, + reaction_to: parentHash, + reaction_emoji: "\u{1F44D}", + reaction_sender: "e".repeat(32), + }, + ]; + const out = mergeLxmfReactionRowsIntoMessages(incoming); + expect(out).toHaveLength(1); + expect(out[0].hash).toBe(parentHash); + expect(out[0].reactions).toHaveLength(1); + expect(out[0].reactions[0].emoji).toBe("\u{1F44D}"); + expect(out[0].reactions[0].sender).toBe("e".repeat(32)); + }); + + it("dedupes same sender and emoji", () => { + const parentHash = "a".repeat(32); + const sender = "e".repeat(32); + const incoming = [ + { hash: parentHash, content: "x", is_reaction: false }, + { + hash: "r1", + is_reaction: true, + reaction_to: parentHash, + reaction_emoji: "\u{1F44D}", + reaction_sender: sender, + }, + { + hash: "r2", + is_reaction: true, + reaction_to: parentHash, + reaction_emoji: "\u{1F44D}", + reaction_sender: sender, + }, + ]; + const out = mergeLxmfReactionRowsIntoMessages(incoming); + expect(out[0].reactions).toHaveLength(1); + }); +});