feat(reactions): implement emoji reactions for messages, including backend support for sending and processing reactions, frontend display in conversation viewer, and localization updates for multiple languages

This commit is contained in:
Ivan
2026-04-13 22:48:12 -05:00
parent c6d396ba77
commit dc280c13bd
11 changed files with 554 additions and 47 deletions
+112 -4
View File
@@ -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
+47 -2
View File
@@ -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):
@@ -1313,6 +1313,18 @@
</div>
</div>
<div
v-if="chatItem.lxmf_message.reactions?.length"
class="mt-1 flex w-full flex-wrap justify-end gap-0.5 px-0.5"
>
<span
v-for="(r, ridx) in chatItem.lxmf_message.reactions"
:key="r.reactionHash || ridx"
class="inline-flex min-h-[1.35rem] min-w-[1.35rem] cursor-default select-none items-center justify-center rounded-full border border-gray-200/90 bg-white px-1.5 py-0.5 text-sm leading-none shadow-sm dark:border-zinc-600/90 dark:bg-zinc-900"
:title="reactionReactorLabel(r.sender)"
>{{ r.emoji }}</span>
</div>
<!-- expanded message details -->
<div
v-if="expandedMessageInfo === chatItem.lxmf_message.hash"
@@ -1822,82 +1834,94 @@
<!-- Message Context Menu (Teleport to body to avoid overflow clipping) -->
<Teleport to="body">
<div
v-if="messageContextMenu.show"
<ContextMenuPanel
:show="messageContextMenu.show"
:x="messageContextMenu.x"
:y="messageContextMenu.y"
panel-class="z-[200]"
v-click-outside="{
handler: () => {
if (!messageContextMenu.justOpened) messageContextMenu.show = false;
},
capture: true,
}"
class="fixed z-[200] min-w-[180px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: messageContextMenu.y + 'px', left: messageContextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="replyToMessage(messageContextMenu.chatItem)"
>
<ContextMenuItem @click="replyToMessage(messageContextMenu.chatItem)">
<MaterialDesignIcon icon-name="reply" class="size-4 text-indigo-500" />
<span class="font-medium">Reply</span>
</button>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
Reply
</ContextMenuItem>
<div
v-if="messageContextMenu.chatItem && !messageContextMenu.chatItem.lxmf_message?.is_reaction"
class="px-3 py-2 border-t border-gray-100 dark:border-zinc-700"
>
<div
class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-zinc-400 mb-1.5"
>
{{ $t("messages.react") }}
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="(emo, emi) in columbaReactionEmojis"
:key="emi"
type="button"
class="text-lg leading-none px-1.5 py-0.5 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
:title="emo"
@click="sendReactionEmojiFromMenu(messageContextMenu.chatItem, emo)"
>
{{ emo }}
</button>
</div>
</div>
<ContextMenuItem
@click="
showRawMessage(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="code-json" class="size-4 text-gray-400" />
<span class="font-medium">View Raw LXM</span>
</button>
<button
View Raw LXM
</ContextMenuItem>
<ContextMenuItem
v-if="messageContextMenu.chatItem?.lxmf_message?.fields?.image"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="saveMessageImageToStickers(messageContextMenu.chatItem)"
>
<MaterialDesignIcon icon-name="bookmark-plus-outline" class="size-4 text-teal-500" />
<span class="font-medium">{{ $t("stickers.save_to_library") }}</span>
</button>
<button
{{ $t("stickers.save_to_library") }}
</ContextMenuItem>
<ContextMenuItem
v-if="
messageContextMenu.chatItem?.is_outbound &&
['failed', 'cancelled'].includes(messageContextMenu.chatItem?.lxmf_message?.state)
"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-all active:scale-95"
item-class="text-amber-600 dark:text-amber-400"
@click="
retrySendingMessage(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="refresh" class="size-4" />
<span class="font-medium">Retry</span>
</button>
<button
Retry
</ContextMenuItem>
<ContextMenuItem
v-if="isSelectedPeerBlocked && selectedPeer"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
item-class="text-emerald-600 dark:text-emerald-400"
@click="liftBanishmentFromMessageMenu"
>
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
<span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/20 transition-all active:scale-95"
{{ $t("banishment.lift_banishment") }}
</ContextMenuItem>
<ContextMenuDivider />
<ContextMenuItem
item-class="text-red-600 dark:text-red-400"
@click="
deleteChatItem(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
<span class="font-medium">Delete</span>
</button>
</div>
Delete
</ContextMenuItem>
</ContextMenuPanel>
</Teleport>
</div>
@@ -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;
@@ -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;
}
+3
View File
@@ -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": {
+3
View File
@@ -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": {
+3
View File
@@ -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": {
+3
View File
@@ -824,6 +824,9 @@
"reply": "Ответить",
"replying_to": "Ответ на",
"message_actions": "Действия с сообщением",
"react": "Реакции",
"reaction_you": "Вы",
"reaction_send_failed": "Не удалось отправить реакцию",
"message_not_found_in_cache": "Сообщение не найдено в кэше"
},
"nomadnet": {
+119
View File
@@ -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"]
@@ -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
+55
View File
@@ -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);
});
});