mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-14 08:19:47 +00:00
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:
+112
-4
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -824,6 +824,9 @@
|
||||
"reply": "Ответить",
|
||||
"replying_to": "Ответ на",
|
||||
"message_actions": "Действия с сообщением",
|
||||
"react": "Реакции",
|
||||
"reaction_you": "Вы",
|
||||
"reaction_send_failed": "Не удалось отправить реакцию",
|
||||
"message_not_found_in_cache": "Сообщение не найдено в кэше"
|
||||
},
|
||||
"nomadnet": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user