Improve messaging features and configuration options

- Add more tests
- Fix notifactions
This commit is contained in:
Sudo-Ivan
2026-02-01 17:21:03 -06:00
parent 2d33008752
commit 67fe101554
33 changed files with 1901 additions and 379 deletions
+50 -6
View File
@@ -560,7 +560,11 @@ class ReticulumMeshChat:
"""Ensures that a valid Reticulum config file exists at the expected location.
If the config is missing or invalid, it creates a sane default one.
"""
config_dir = self.reticulum_config_dir or RNS.Reticulum.configpath
config_dir = (
self.reticulum_config_dir
or RNS.Reticulum.configpath
or os.path.expanduser("~/.reticulum")
)
config_file = os.path.join(config_dir, "config")
should_recreate = False
@@ -7753,10 +7757,17 @@ class ReticulumMeshChat:
self.database.messages.mark_all_notifications_as_viewed(
destination_hashes,
)
else:
# mark all LXMF conversations as viewed if no hashes provided
# (this happens when "Clear All" is clicked)
self.database.messages.mark_all_notifications_as_viewed()
if notification_ids:
# mark system notifications as viewed
self.database.misc.mark_notifications_as_viewed(notification_ids)
else:
# mark all system notifications as viewed if no ids provided
self.database.misc.mark_notifications_as_viewed()
return web.json_response(
{
@@ -7797,6 +7808,13 @@ class ReticulumMeshChat:
else:
other_user_hash = db_message["source_hash"]
# Check if notification has been viewed
if self.database.messages.is_notification_viewed(
other_user_hash,
db_message["timestamp"],
):
continue
# Determine display name
display_name = self.get_lxmf_conversation_name(
other_user_hash,
@@ -7894,7 +7912,19 @@ class ReticulumMeshChat:
filter_unread=True,
)
if unread_conversations:
lxmf_unread_count = len(unread_conversations)
for conv in unread_conversations:
# Determine other user hash
if conv["source_hash"] == local_hash:
other_user_hash = conv["destination_hash"]
else:
other_user_hash = conv["source_hash"]
# Check if notification has NOT been viewed
if not self.database.messages.is_notification_viewed(
other_user_hash,
conv["timestamp"],
):
lxmf_unread_count += 1
total_unread_count = unread_count + lxmf_unread_count
@@ -9148,6 +9178,21 @@ class ReticulumMeshChat:
value = max(12, min(value, 96))
self.config.message_icon_size.set(value)
if "message_outbound_bubble_color" in data:
self.config.message_outbound_bubble_color.set(
data["message_outbound_bubble_color"]
)
if "message_inbound_bubble_color" in data:
self.config.message_inbound_bubble_color.set(
data["message_inbound_bubble_color"]
)
if "message_failed_bubble_color" in data:
self.config.message_failed_bubble_color.set(
data["message_failed_bubble_color"]
)
# update desktop settings
if "desktop_open_calls_in_separate_window" in data:
self.config.desktop_open_calls_in_separate_window.set(
@@ -10141,6 +10186,9 @@ class ReticulumMeshChat:
"banished_color": ctx.config.banished_color.get(),
"message_font_size": ctx.config.message_font_size.get(),
"message_icon_size": ctx.config.message_icon_size.get(),
"message_outbound_bubble_color": ctx.config.message_outbound_bubble_color.get(),
"message_inbound_bubble_color": ctx.config.message_inbound_bubble_color.get(),
"message_failed_bubble_color": ctx.config.message_failed_bubble_color.get(),
"translator_enabled": ctx.config.translator_enabled.get(),
"libretranslate_url": ctx.config.libretranslate_url.get(),
"desktop_open_calls_in_separate_window": ctx.config.desktop_open_calls_in_separate_window.get(),
@@ -10919,10 +10967,6 @@ class ReticulumMeshChat:
)
lxmf_message_dict["is_spam"] = 1 if is_spam else 0
# extract reply_to from fields if present
if "fields" in lxmf_message_dict and "reply_to" in lxmf_message_dict["fields"]:
lxmf_message_dict["reply_to_hash"] = lxmf_message_dict["fields"]["reply_to"]
# calculate peer hash
local_hash = ctx.local_lxmf_destination.hexhash
if lxmf_message_dict["source_hash"] == local_hash:
+15
View File
@@ -285,6 +285,21 @@ class ConfigManager:
)
self.message_font_size = self.IntConfig(self, "message_font_size", 14)
self.message_icon_size = self.IntConfig(self, "message_icon_size", 28)
self.message_outbound_bubble_color = self.StringConfig(
self,
"message_outbound_bubble_color",
"#4f46e5",
)
self.message_inbound_bubble_color = self.StringConfig(
self,
"message_inbound_bubble_color",
None,
)
self.message_failed_bubble_color = self.StringConfig(
self,
"message_failed_bubble_color",
"#ef4444",
)
# blackhole integration config
self.blackhole_integration_enabled = self.BoolConfig(
+22 -7
View File
@@ -142,7 +142,7 @@ class MessageDAO:
SELECT m.timestamp, r.last_read_at
FROM lxmf_messages m
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ?
WHERE m.peer_hash = ?
WHERE m.peer_hash = ? AND m.is_incoming = 1
ORDER BY m.timestamp DESC LIMIT 1
""",
(destination_hash, destination_hash),
@@ -193,7 +193,7 @@ class MessageDAO:
SELECT peer_hash, MAX(timestamp) as latest_ts, last_read_at
FROM lxmf_messages m
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m.peer_hash
WHERE m.peer_hash IN ({placeholders})
WHERE m.peer_hash IN ({placeholders}) AND m.is_incoming = 1
GROUP BY m.peer_hash
""" # noqa: S608
rows = self.provider.fetchall(query, destination_hashes)
@@ -296,18 +296,33 @@ class MessageDAO:
(destination_hash, now, now, now),
)
def mark_all_notifications_as_viewed(self, destination_hashes):
def mark_all_notifications_as_viewed(self, destination_hashes=None):
now = datetime.now(UTC).isoformat()
for destination_hash in destination_hashes:
if destination_hashes:
for destination_hash in destination_hashes:
self.provider.execute(
"""
INSERT INTO notification_viewed_state (destination_hash, last_viewed_at, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET
last_viewed_at = EXCLUDED.last_viewed_at,
updated_at = EXCLUDED.updated_at
""",
(destination_hash, now, now, now),
)
else:
# mark all conversations as viewed
self.provider.execute(
"""
INSERT INTO notification_viewed_state (destination_hash, last_viewed_at, created_at, updated_at)
VALUES (?, ?, ?, ?)
INSERT INTO notification_viewed_state (destination_hash, last_viewed_at, created_at, updated_at)
SELECT peer_hash, ?, ?, ? FROM lxmf_messages
WHERE peer_hash IS NOT NULL
GROUP BY peer_hash
ON CONFLICT(destination_hash) DO UPDATE SET
last_viewed_at = EXCLUDED.last_viewed_at,
updated_at = EXCLUDED.updated_at
""",
(destination_hash, now, now, now),
(now, now, now),
)
def is_notification_viewed(self, destination_hash, message_timestamp):
+22 -3
View File
@@ -120,6 +120,26 @@ def convert_lxmf_message_to_dict(
if quality is None and reticulum:
quality = reticulum.get_packet_q(lxmf_message.hash)
# get reply_to_hash from fields if present
reply_to_hash = None
if 0x30 in message_fields:
val = message_fields[0x30]
reply_to_hash = val.hex() if isinstance(val, bytes) else val
content = (
lxmf_message.content.decode("utf-8", errors="replace")
if lxmf_message.content
else ""
)
# auto-detect reply from content if not present
if not reply_to_hash and content and isinstance(content, str):
import re
match = re.search(r"^> ([a-fA-F0-9]{32})\s*\n?", content)
if match:
reply_to_hash = match.group(1)
return {
"hash": lxmf_message.hash.hex(),
"source_hash": lxmf_message.source_hash.hex(),
@@ -137,14 +157,13 @@ def convert_lxmf_message_to_dict(
"title": lxmf_message.title.decode("utf-8", errors="replace")
if lxmf_message.title
else "",
"content": lxmf_message.content.decode("utf-8", errors="replace")
if lxmf_message.content
else "",
"content": content,
"fields": fields,
"timestamp": lxmf_message.timestamp,
"rssi": rssi,
"snr": snr,
"quality": quality,
"reply_to_hash": reply_to_hash,
}
+1 -1
View File
@@ -108,7 +108,7 @@ class MessageHandler:
if filter_unread:
where_clauses.append(
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
"(m1.is_incoming = 1 AND (r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at)))",
)
if filter_failed:
@@ -128,6 +128,14 @@ class TelephoneManager:
def register_ended_callback(self, callback):
self.on_ended_callback = callback
def set_callbacks(self, ringing=None, established=None, ended=None):
if ringing:
self.register_ringing_callback(ringing)
if established:
self.register_established_callback(established)
if ended:
self.register_ended_callback(ended)
def on_telephone_ringing(self, caller_identity: RNS.Identity):
if self.initiation_status:
# This is an outgoing call where the remote side is now ringing.
+6 -2
View File
@@ -861,8 +861,12 @@ export default {
}
// show notification for new messages if window is not focussed
// only for incoming messages
if (!document.hasFocus() && json.lxmf_message?.is_incoming === true) {
// only for incoming messages from people (with content)
if (
!document.hasFocus() &&
json.lxmf_message?.is_incoming === true &&
(json.lxmf_message?.content || json.lxmf_message?.title)
) {
NotificationUtils.showNewMessageNotification(
json.remote_identity_name,
json.lxmf_message?.content
@@ -190,6 +190,9 @@ export default {
this.updateDropdownPosition(event);
await this.loadNotifications();
await this.markNotificationsAsViewed();
// reset unread count locally once viewed
this.unreadCount = 0;
}
},
updateDropdownPosition(event) {
@@ -275,8 +278,25 @@ export default {
console.error("Failed to clear notifications", e);
}
},
onNotificationClick(notification) {
async onNotificationClick(notification) {
this.closeDropdown();
// Mark this specific notification as viewed
try {
const destination_hashes = notification.type === "lxmf_message" ? [notification.destination_hash] : [];
const notification_ids = notification.type !== "lxmf_message" ? [notification.id] : [];
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
notification_ids: notification_ids,
});
// reload to update unread count
await this.loadNotifications();
} catch (e) {
console.error("Failed to mark notification as viewed", e);
}
if (notification.type === "lxmf_message") {
this.$router.push({
name: "messages",
@@ -385,19 +385,21 @@
:key="chatItem.lxmf_message.hash"
class="flex flex-col max-w-[85%] sm:max-w-[75%] lg:max-w-[65%] mb-4 group min-w-0"
:class="{ 'ml-auto items-end': chatItem.is_outbound, 'mr-auto items-start': !chatItem.is_outbound }"
@contextmenu.prevent="onMessageContextMenu($event, chatItem)"
>
<!-- message content -->
<div
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
:class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
? 'bg-red-500 text-white shadow-sm'
? 'shadow-sm'
: chatItem.lxmf_message.is_spam
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-sm'
: chatItem.is_outbound
? 'bg-blue-600 text-white shadow-sm'
? 'shadow-sm'
: 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-sm',
]"
:style="bubbleStyles(chatItem)"
@click="onChatItemClick(chatItem)"
>
<div class="w-full space-y-1 px-4 py-2.5 min-w-0">
@@ -407,13 +409,19 @@
class="mb-2 p-2 rounded-lg bg-black/5 dark:bg-white/5 border-l-2 border-blue-500/50 cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
@click.stop="scrollToMessage(chatItem.lxmf_message.reply_to_hash)"
>
<div class="text-[10px] font-bold text-blue-500/80 uppercase tracking-tight mb-0.5">
<div
class="flex items-center gap-1 text-[10px] font-bold uppercase tracking-tight mb-0.5"
:class="chatItem.is_outbound ? 'text-white/80' : 'text-indigo-500/80'"
>
<MaterialDesignIcon icon-name="reply" class="size-3" />
{{ $t("messages.replying_to") }}
</div>
<div class="text-xs opacity-70 truncate line-clamp-1 italic">
{{
getRepliedMessage(chatItem.lxmf_message.reply_to_hash)?.content ||
"(Message not found)"
(chatItem.lxmf_message.reply_to_hash
? `Message <${chatItem.lxmf_message.reply_to_hash.substring(0, 8)}...>`
: "(Message not found)")
}}
</div>
</div>
@@ -423,23 +431,10 @@
v-if="chatItem.lxmf_message.is_spam"
class="flex items-center gap-1.5 text-xs font-medium mb-1"
:class="
chatItem.is_outbound ? 'text-yellow-200' : 'text-yellow-700 dark:text-yellow-300'
chatItem.is_outbound ? 'text-orange-200' : 'text-orange-700 dark:text-orange-300'
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<MaterialDesignIcon icon-name="alert-decagram" class="size-4" />
<span>Marked as Spam</span>
</div>
@@ -653,25 +648,12 @@
@click.stop
>
<div class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
></path>
</svg>
<MaterialDesignIcon icon-name="paperclip" class="size-5" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate">{{ file_attachment.file_name }}</div>
<div class="truncate text-xs font-bold">{{ file_attachment.file_name }}</div>
<div
class="text-xs font-normal mt-0.5"
class="text-[10px] font-normal"
:class="
chatItem.is_outbound
? 'text-white/60'
@@ -682,20 +664,7 @@
</div>
</div>
<div class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
<MaterialDesignIcon icon-name="download" class="size-5" />
</div>
</a>
</div>
@@ -815,8 +784,71 @@
</div>
</div>
</div>
<!-- message footer: timestamp and status icons -->
<div class="flex items-center justify-end gap-1.5 mt-1.5 select-none h-3">
<span
class="text-[9px] opacity-80 font-medium"
:class="chatItem.is_outbound ? 'text-white/90' : 'text-gray-500 dark:text-zinc-400'"
:title="getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound).join('\n')"
>
{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}
</span>
<!-- outbound status icons -->
<div v-if="chatItem.is_outbound" class="flex items-center gap-1">
<span
v-if="['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)"
class="text-[9px] font-bold uppercase tracking-wider text-white"
>
{{ chatItem.lxmf_message.state === "rejected" ? "Rejected" : "Failed" }}
</span>
<!-- delivered: double check -->
<MaterialDesignIcon
v-if="chatItem.lxmf_message.state === 'delivered'"
icon-name="check-all"
class="size-3 text-blue-300"
title="Delivered"
/>
<!-- sent: single check -->
<MaterialDesignIcon
v-else-if="['sent', 'propagated'].includes(chatItem.lxmf_message.state)"
icon-name="check"
class="size-3 text-white/90"
:title="
chatItem.lxmf_message.state === 'propagated'
? 'Sent to propagation node'
: 'Sent'
"
/>
<!-- pending/sending/generating: clock or loading -->
<MaterialDesignIcon
v-else-if="
['outbound', 'sending', 'generating'].includes(chatItem.lxmf_message.state)
"
icon-name="clock-outline"
class="size-3 text-white/60"
:title="
chatItem.lxmf_message.state === 'sending'
? `Sending... ${chatItem.lxmf_message.progress.toFixed(0)}%`
: 'Pending'
"
/>
<!-- failed/cancelled/rejected: alert -->
<MaterialDesignIcon
v-else-if="
['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)
"
icon-name="alert-circle-outline"
class="size-3 text-white"
:title="chatItem.lxmf_message.state"
/>
</div>
</div>
</div>
<!-- actions (expanded) -->
<div
v-if="chatItem.is_actions_expanded"
class="border-t px-4 py-2.5"
@@ -826,25 +858,24 @@
: 'border-gray-200/60 dark:border-zinc-800/60 bg-gray-50/50 dark:bg-zinc-900/50'
"
>
<!-- actions -->
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-600 transition-colors"
@click.stop="replyToMessage(chatItem)"
>
{{ $t("messages.reply") }}
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-600 transition-colors"
@click.stop="deleteChatItem(chatItem)"
>
Delete
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-700 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
class="inline-flex items-center gap-x-1.5 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-700 transition-colors"
@click.stop="showRawMessage(chatItem)"
>
Raw LXM
@@ -853,152 +884,6 @@
</div>
</div>
<!-- message state -->
<div
v-if="chatItem.is_outbound"
class="flex text-right mt-1.5 px-1"
:class="[
['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
? 'text-red-500 dark:text-red-400'
: 'text-gray-400 dark:text-zinc-500',
]"
>
<div class="flex ml-auto items-center space-x-1.5 text-xs">
<!-- state label -->
<div class="my-auto">
<span
class="space-x-1 cursor-pointer hover:underline"
@click="toggleSentMessageInfo(chatItem.lxmf_message.hash)"
>
<span>{{ chatItem.lxmf_message.state }}</span>
<span
v-if="
chatItem.lxmf_message.state === 'outbound' &&
chatItem.lxmf_message.delivery_attempts >= 1
"
>(attempt {{ chatItem.lxmf_message.delivery_attempts + 1 }})</span
>
<span
v-if="
chatItem.lxmf_message.state === 'sent' &&
chatItem.lxmf_message.method === 'opportunistic' &&
chatItem.lxmf_message.delivery_attempts >= 1
"
>(attempt {{ chatItem.lxmf_message.delivery_attempts }})</span
>
<span
v-if="
chatItem.lxmf_message.state === 'sent' &&
chatItem.lxmf_message.method === 'propagated'
"
>to propagation node</span
>
<span v-if="chatItem.lxmf_message.state === 'sending'"
>{{ chatItem.lxmf_message.progress.toFixed(0) }}%</span
>
</span>
<a
v-if="
chatItem.lxmf_message.state === 'outbound' ||
chatItem.lxmf_message.state === 'sending' ||
chatItem.lxmf_message.state === 'sent'
"
class="ml-1 cursor-pointer underline text-blue-500"
@click="cancelSendingMessage(chatItem)"
>cancel?</a
>
<a
v-if="
chatItem.lxmf_message.state === 'failed' ||
chatItem.lxmf_message.state === 'cancelled'
"
class="ml-1 cursor-pointer underline text-blue-500"
@click="retrySendingMessage(chatItem)"
>retry?</a
>
</div>
<!-- delivered icon -->
<div v-if="chatItem.lxmf_message.state === 'delivered'" class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clip-rule="evenodd"
/>
</svg>
</div>
<!-- cancelled icon -->
<div v-else-if="chatItem.lxmf_message.state === 'cancelled'" class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"
clip-rule="evenodd"
/>
</svg>
</div>
<!-- failed icon -->
<div v-else-if="chatItem.lxmf_message.state === 'failed'" class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
<!-- fallback icon -->
<div v-else class="my-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
</div>
</div>
<!-- inbound message info -->
<div
v-if="!chatItem.is_outbound"
class="text-xs text-gray-400 dark:text-zinc-500 mt-1.5 px-1 flex flex-col"
>
<!-- received timestamp -->
<span
class="cursor-pointer hover:underline"
@click="toggleReceivedMessageInfo(chatItem.lxmf_message.hash)"
>{{ formatTimeAgo(chatItem.lxmf_message.created_at) }}</span
>
</div>
<!-- expanded message details -->
<div
v-if="expandedMessageInfo === chatItem.lxmf_message.hash"
@@ -1161,7 +1046,10 @@
class="mt-2 p-2 rounded-xl bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-700/50 flex items-center gap-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<div class="flex-1 min-w-0 border-l-2 border-blue-500 pl-3">
<div class="text-[10px] font-bold text-blue-500 uppercase tracking-wider mb-0.5">
<div
class="flex items-center gap-1 text-[10px] font-bold text-blue-500 uppercase tracking-wider mb-0.5"
>
<MaterialDesignIcon icon-name="reply" class="size-3" />
{{ $t("messages.replying_to") }}
</div>
<div class="text-xs text-gray-600 dark:text-zinc-400 truncate italic">
@@ -1262,6 +1150,46 @@
<!-- hidden file input for selecting files -->
<input ref="file-input" type="file" multiple style="display: none" @change="onFileInputChange" />
<!-- Message Context Menu -->
<div
v-if="messageContextMenu.show"
v-click-outside="{ handler: () => (messageContextMenu.show = false), capture: true }"
class="fixed z-[100] 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)"
>
<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"
@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>
<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"
@click="
deleteChatItem(messageContextMenu.chatItem);
messageContextMenu.show = false;
"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
<span class="font-medium">Delete</span>
</button>
</div>
</div>
<!-- no peer selected -->
@@ -1270,7 +1198,7 @@
<!-- welcome header -->
<div class="text-center mb-12">
<div
class="inline-flex items-center justify-center p-4 rounded-3xl bg-blue-600 shadow-xl shadow-blue-500/20 mb-6"
class="inline-flex items-center justify-center p-4 rounded-3xl bg-indigo-600 shadow-xl shadow-indigo-500/20 mb-6"
>
<MaterialDesignIcon icon-name="message-text" class="size-10 text-white" />
</div>
@@ -1286,11 +1214,11 @@
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 w-full mb-12">
<button
type="button"
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-indigo-500/50 hover:shadow-xl hover:shadow-indigo-500/5 transition-all group"
@click="focusComposeInput"
>
<div
class="size-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 text-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform"
class="size-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform"
>
<MaterialDesignIcon icon-name="plus" class="size-6" />
</div>
@@ -1837,9 +1765,37 @@ export default {
showTelemetryInChat: false,
isTelemetryHistoryModalOpen: false,
replyingTo: null,
messageContextMenu: {
show: false,
x: 0,
y: 0,
chatItem: null,
},
now: Date.now(),
updateTimer: null,
};
},
computed: {
bubbleStyles() {
return (chatItem) => {
const styles = {};
const isFailed = ["cancelled", "failed"].includes(chatItem.lxmf_message.state);
if (isFailed) {
const color = GlobalState.config.message_failed_bubble_color || "#ef4444";
styles["background-color"] = color;
styles["color"] = "#ffffff";
} else if (chatItem.is_outbound) {
const color = GlobalState.config.message_outbound_bubble_color || "#4f46e5";
styles["background-color"] = color;
styles["color"] = "#ffffff";
} else if (GlobalState.config.message_inbound_bubble_color) {
styles["background-color"] = GlobalState.config.message_inbound_bubble_color;
}
return styles;
};
},
messageIconStyle() {
const size = Number(this.config?.message_icon_size) || 28;
return {
@@ -2062,15 +2018,11 @@ export default {
deep: true,
},
},
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
},
mounted() {
this.updateTimer = setInterval(() => {
this.now = Date.now();
}, 30000); // Update every 30 seconds
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
@@ -2089,6 +2041,17 @@ export default {
this.updatePropagationNodeStatus();
}, 2000);
},
beforeUnmount() {
if (this.updateTimer) {
clearInterval(this.updateTimer);
}
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
},
methods: {
renderMarkdown(text) {
return MarkdownRenderer.render(text);
@@ -2268,6 +2231,9 @@ export default {
this.getPeerLxmfStampInfo();
this.getPeerSignalMetrics();
// mark as read
this.markConversationAsRead(this.selectedPeer);
// load 1 page of previous messages
await this.loadPrevious();
@@ -2608,7 +2574,11 @@ export default {
}
// update lxmf message from server, while ensuring ui updates from nested object change
this.chatItems[chatItemIndex].lxmf_message = lxmfMessage;
// we merge to preserve client-side only fields or database fields not present in state updates
this.chatItems[chatItemIndex].lxmf_message = {
...this.chatItems[chatItemIndex].lxmf_message,
...lxmfMessage,
};
},
onLxmfMessageDeleted(hash) {
if (hash) {
@@ -2799,12 +2769,15 @@ export default {
},
replyToMessage(chatItem) {
this.replyingTo = chatItem;
this.messageContextMenu.show = false;
chatItem.is_actions_expanded = false;
// focus input
const textarea = this.$refs["message-input"];
if (textarea) {
textarea.focus();
}
// focus the input
this.$nextTick(() => {
const textarea = this.$refs["message-input"];
if (textarea) {
textarea.focus();
}
});
},
cancelReply() {
this.replyingTo = null;
@@ -2829,6 +2802,32 @@ export default {
const item = this.chatItems.find((i) => i.lxmf_message?.hash === hash);
return item ? item.lxmf_message : null;
},
onMessageContextMenu(event, chatItem) {
this.messageContextMenu.chatItem = chatItem;
this.messageContextMenu.show = true;
// wait for context menu to be rendered to calculate its width/height
this.$nextTick(() => {
const menuWidth = 180; // approximate minimum width
const menuHeight = 150; // approximate height
let x = event.clientX;
let y = event.clientY;
// Adjust X if it would go off-screen on the right
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 10;
}
// Adjust Y if it would go off-screen at the bottom
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 10;
}
this.messageContextMenu.x = x;
this.messageContextMenu.y = y;
});
},
async showRawMessage(chatItem) {
try {
// we'll try to get the URI first as it contains the raw signed message
@@ -3466,7 +3465,8 @@ export default {
}
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
// Using this.now ensures the computed value updates when the timer ticks
return this.now ? Utils.formatTimeAgo(datetimeString) : Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash(hash) {
return Utils.formatDestinationHash(hash);
@@ -7,7 +7,7 @@
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
:class="[
tab === 'conversations'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
]"
@click="tab = 'conversations'"
@@ -18,7 +18,7 @@
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
:class="[
tab === 'announces'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
]"
@click="tab = 'announces'"
@@ -51,7 +51,7 @@
<div class="flex gap-1" @click.stop>
<button
type="button"
class="p-1 text-gray-400 hover:text-blue-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
class="p-1 text-gray-400 hover:text-indigo-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
title="Create Folder"
@click="createFolder"
>
@@ -700,6 +700,106 @@
{{ $t("app.live_preview") }}
</span>
</div>
<div class="space-y-4 pt-2">
<div
class="text-sm font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-wider"
>
Message Bubbles
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Outbound Color
</div>
<div class="flex gap-2">
<input
v-model="config.message_outbound_bubble_color"
type="color"
class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
@input="onMessageBubbleColorChange('outbound')"
/>
<input
v-model="config.message_outbound_bubble_color"
type="text"
class="input-field monospace-field flex-1"
@input="onMessageBubbleColorChange('outbound')"
/>
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Failed Color
</div>
<div class="flex gap-2">
<input
v-model="config.message_failed_bubble_color"
type="color"
class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
@input="onMessageBubbleColorChange('failed')"
/>
<input
v-model="config.message_failed_bubble_color"
type="text"
class="input-field monospace-field flex-1"
@input="onMessageBubbleColorChange('failed')"
/>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Inbound Color (Optional)
</div>
<button
v-if="config.message_inbound_bubble_color"
type="button"
class="text-[10px] text-red-500 font-bold uppercase hover:underline"
@click="
config.message_inbound_bubble_color = null;
onMessageBubbleColorChange('inbound');
"
>
Reset to default
</button>
</div>
<div class="flex gap-2">
<input
v-model="config.message_inbound_bubble_color"
type="color"
class="w-12 h-10 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 cursor-pointer"
:disabled="!config.message_inbound_bubble_color"
@input="onMessageBubbleColorChange('inbound')"
/>
<div
v-if="!config.message_inbound_bubble_color"
class="flex-1 flex items-center px-3 text-xs text-gray-400 bg-gray-50 dark:bg-zinc-900 rounded-xl border border-dashed border-gray-200 dark:border-zinc-800 italic"
>
Using theme default. Click to customize ->
<button
class="ml-2 px-2 py-1 bg-blue-500 text-white rounded-lg not-italic font-bold"
@click="
config.message_inbound_bubble_color = '#ffffff';
onMessageBubbleColorChange('inbound');
"
>
Customize
</button>
</div>
<input
v-else
v-model="config.message_inbound_bubble_color"
type="text"
class="input-field monospace-field flex-1"
@input="onMessageBubbleColorChange('inbound')"
/>
</div>
</div>
</div>
</div>
</section>
@@ -1536,6 +1636,9 @@ export default {
blackhole_integration_enabled: true,
message_font_size: 14,
message_icon_size: 28,
message_outbound_bubble_color: "#4f46e5",
message_inbound_bubble_color: null,
message_failed_bubble_color: "#ef4444",
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
location_source: "browser",
@@ -1887,6 +1990,19 @@ export default {
);
}, 1000);
},
async onMessageBubbleColorChange(type) {
const timeoutKey = `message_${type}_bubble_color`;
if (this.saveTimeouts[timeoutKey]) clearTimeout(this.saveTimeouts[timeoutKey]);
this.saveTimeouts[timeoutKey] = setTimeout(async () => {
const configKey = `message_${type}_bubble_color`;
await this.updateConfig(
{
[configKey]: this.config[configKey],
},
configKey
);
}, 1000);
},
async onLanguageChange() {
await this.updateConfig(
{
+3
View File
@@ -11,6 +11,9 @@ const globalState = reactive({
banished_effect_enabled: true,
banished_text: "BANISHED",
banished_color: "#dc2626",
message_outbound_bubble_color: "#4f46e5",
message_inbound_bubble_color: null,
message_failed_bubble_color: "#ef4444",
},
});
@@ -37,6 +37,12 @@ export default class MarkdownRenderer {
text = text.replace(/__(.*?)__/g, "<strong>$1</strong>");
text = text.replace(/_(.*?)_/g, "<em>$1</em>");
// Blockquotes
text = text.replace(
/^> (.*)$/gm,
'<blockquote class="border-l-4 border-gray-300 dark:border-zinc-700 pl-3 py-1 my-2 italic opacity-80">$1</blockquote>'
);
// Inline code
text = text.replace(
/`([^`]+)`/g,
+15 -3
View File
@@ -91,9 +91,21 @@ class Utils {
dateString = dateString.replace(" ", "T") + "Z";
}
const millisecondsAgo = Date.now() - new Date(dateString).getTime();
const secondsAgo = Math.round(millisecondsAgo / 1000);
return this.formatSeconds(secondsAgo);
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) {
return "just now";
}
// If older than 24 hours, show full date
if (diffSec > 86400) {
return dayjs(date).format("MMM D, h:mm A");
}
return this.formatSeconds(diffSec);
}
static formatSecondsAgo(seconds) {
@@ -0,0 +1,87 @@
import pytest
import base64
from unittest.mock import MagicMock
from meshchatx.src.backend.announce_manager import AnnounceManager
@pytest.fixture
def mock_db():
db = MagicMock()
db.provider = MagicMock()
db.announces = MagicMock()
return db
def test_upsert_announce(mock_db):
manager = AnnounceManager(mock_db)
reticulum = MagicMock()
reticulum.get_packet_rssi.return_value = -50
reticulum.get_packet_snr.return_value = 10
reticulum.get_packet_q.return_value = 3
identity = MagicMock()
identity.hash.hex.return_value = "id_hash"
identity.get_public_key.return_value = b"pub_key"
manager.upsert_announce(
reticulum, identity, b"dest_hash", "aspect", b"app_data", b"packet_hash"
)
mock_db.announces.upsert_announce.assert_called_once()
args, _ = mock_db.announces.upsert_announce.call_args
data = args[0]
assert data["destination_hash"] == b"dest_hash".hex()
assert data["rssi"] == -50
assert data["app_data"] == base64.b64encode(b"app_data").decode("utf-8")
def test_get_filtered_announces(mock_db):
manager = AnnounceManager(mock_db)
manager.get_filtered_announces(aspect="test", query="search", limit=10)
args, _ = mock_db.provider.fetchall.call_args
sql, params = args
assert "a.aspect = ?" in sql
assert "(a.destination_hash LIKE ? OR a.identity_hash LIKE ?)" in sql
assert "LIMIT ? OFFSET ?" in sql
assert "test" in params
assert "%search%" in params
def test_get_filtered_announces_count(mock_db):
manager = AnnounceManager(mock_db)
mock_db.provider.fetchone.return_value = {"count": 5}
count = manager.get_filtered_announces_count(
aspect="test", query="q", blocked_identity_hashes=["b1"]
)
assert count == 5
args, _ = mock_db.provider.fetchone.call_args
sql, params = args
assert "SELECT COUNT(*)" in sql
assert "a.aspect = ?" in sql
assert "a.identity_hash NOT IN (?)" in sql
assert "test" in params
assert "b1" in params
def test_get_filtered_announces_all_fields(mock_db):
manager = AnnounceManager(mock_db)
manager.get_filtered_announces(
aspect="a",
identity_hash="ih",
destination_hash="dh",
query="q",
blocked_identity_hashes=["b1", "b2"],
limit=10,
offset=20,
)
args, _ = mock_db.provider.fetchall.call_args
sql, params = args
assert "a.aspect = ?" in sql
assert "a.identity_hash = ?" in sql
assert "a.destination_hash = ?" in sql
assert "a.identity_hash NOT IN (?, ?)" in sql
assert 10 in params
assert 20 in params
@@ -0,0 +1,71 @@
import pytest
from unittest.mock import MagicMock
from meshchatx.src.backend.archiver_manager import ArchiverManager
@pytest.fixture
def mock_db():
db = MagicMock()
db.provider = MagicMock()
db.misc = MagicMock()
return db
def test_archive_page_new(mock_db):
manager = ArchiverManager(mock_db)
mock_db.provider.fetchone.side_effect = [None, {"total_size": 100}]
mock_db.misc.get_archived_page_versions.return_value = []
manager.archive_page("dest", "/path", "content")
mock_db.misc.archive_page.assert_called_once()
args, _ = mock_db.misc.archive_page.call_args
assert args[0] == "dest"
assert args[1] == "/path"
assert args[2] == "content"
def test_archive_page_exists(mock_db):
manager = ArchiverManager(mock_db)
mock_db.provider.fetchone.return_value = {"id": 1}
manager.archive_page("dest", "/path", "content")
mock_db.misc.archive_page.assert_not_called()
def test_archive_page_enforce_max_versions(mock_db):
manager = ArchiverManager(mock_db)
mock_db.provider.fetchone.side_effect = [None, {"total_size": 100}]
# 6 versions, max is 5
mock_db.misc.get_archived_page_versions.return_value = [
{"id": 1},
{"id": 2},
{"id": 3},
{"id": 4},
{"id": 5},
{"id": 6},
]
manager.archive_page("dest", "/path", "content", max_versions=5)
# Should delete the 6th version (index 5)
mock_db.provider.execute.assert_any_call(
"DELETE FROM archived_pages WHERE id = ?", (6,)
)
def test_archive_page_enforce_storage_limit(mock_db):
manager = ArchiverManager(mock_db)
mock_db.provider.fetchone.side_effect = [
None, # existing check
{"total_size": 2 * 1024 * 1024 * 1024}, # total size (2GB)
{"id": 10, "size": 1 * 1024 * 1024 * 1024}, # oldest
]
mock_db.misc.get_archived_page_versions.return_value = []
# max storage 1GB
manager.archive_page("dest", "/path", "content", max_storage_gb=1)
mock_db.provider.execute.assert_any_call(
"DELETE FROM archived_pages WHERE id = ?", (10,)
)
+109
View File
@@ -0,0 +1,109 @@
import os
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.bot_handler import BotHandler
@pytest.fixture
def temp_identity_dir(tmp_path):
dir_path = tmp_path / "identity"
dir_path.mkdir()
return str(dir_path)
def test_bot_handler_init(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
assert os.path.exists(handler.bots_dir)
assert handler.bots_state == []
def test_bot_handler_load_save_state(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
test_state = [{"id": "bot1", "enabled": True, "storage_dir": "some/path"}]
handler.bots_state = test_state
handler._save_state()
# New handler instance to load state
handler2 = BotHandler(temp_identity_dir)
assert len(handler2.bots_state) == 1
assert handler2.bots_state[0]["id"] == "bot1"
def test_get_available_templates(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
templates = handler.get_available_templates()
assert len(templates) > 0
assert any(t["id"] == "echo" for t in templates)
def test_get_status_empty(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
status = handler.get_status()
assert isinstance(status, dict)
assert status["bots"] == []
def test_delete_bot_not_found(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
assert handler.delete_bot("nonexistent") is False
@patch("subprocess.Popen")
def test_start_stop_bot(mock_popen, temp_identity_dir):
mock_process = MagicMock()
mock_process.pid = 12345
mock_popen.return_value = mock_process
handler = BotHandler(temp_identity_dir)
bot_id = handler.start_bot("echo", "My Echo Bot")
assert bot_id in handler.running_bots
status = handler.get_status()
assert any(b["id"] == bot_id and b["running"] for b in status["bots"])
with patch("psutil.Process"):
handler.stop_bot(bot_id)
assert bot_id not in handler.running_bots
def test_create_bot(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
# start_bot acts as create_bot if bot_id is None
bot_id = handler.start_bot("echo", "Echo")
assert any(b["id"] == bot_id for b in handler.bots_state)
assert os.path.exists(os.path.join(handler.bots_dir, bot_id))
def test_delete_bot_success(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
bot_id = handler.start_bot("echo", "Echo")
assert handler.delete_bot(bot_id) is True
assert not any(b["id"] == bot_id for b in handler.bots_state)
def test_get_bot_identity_path(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
bot_id = handler.start_bot("echo", "Echo")
storage_dir = os.path.join(handler.bots_dir, bot_id)
id_path = os.path.join(storage_dir, "config", "identity")
os.makedirs(os.path.dirname(id_path), exist_ok=True)
with open(id_path, "w") as f:
f.write("test")
assert handler.get_bot_identity_path(bot_id) == id_path
def test_restore_enabled_bots(temp_identity_dir):
handler = BotHandler(temp_identity_dir)
handler.bots_state = [
{
"id": "b1",
"template_id": "echo",
"name": "N",
"enabled": True,
"storage_dir": "/tmp/b1",
}
]
with patch.object(handler, "start_bot") as mock_start:
handler.restore_enabled_bots()
mock_start.assert_called_once()
+49
View File
@@ -0,0 +1,49 @@
import pytest
from unittest.mock import MagicMock
from meshchatx.src.backend.database.contacts import ContactsDAO
@pytest.fixture
def mock_provider():
return MagicMock()
@pytest.fixture
def contacts_dao(mock_provider):
return ContactsDAO(mock_provider)
def test_add_contact(contacts_dao, mock_provider):
contacts_dao.add_contact("Name", "ih", lxmf_address="lx")
args, _ = mock_provider.execute.call_args
assert "INSERT INTO contacts" in args[0]
assert args[1][0] == "Name"
assert args[1][1] == "ih"
assert args[1][2] == "lx"
def test_get_contacts_search(contacts_dao, mock_provider):
contacts_dao.get_contacts(search="john")
args, _ = mock_provider.fetchall.call_args
assert "WHERE name LIKE ?" in args[0]
assert args[1][0] == "%john%"
def test_update_contact(contacts_dao, mock_provider):
contacts_dao.update_contact(1, name="New Name", clear_image=True)
args, _ = mock_provider.execute.call_args
assert "UPDATE contacts SET name = ?, custom_image = NULL" in args[0]
assert args[1] == ("New Name", 1)
def test_delete_contact(contacts_dao, mock_provider):
contacts_dao.delete_contact(1)
mock_provider.execute.assert_called_with("DELETE FROM contacts WHERE id = ?", (1,))
def test_get_contact_by_identity_hash(contacts_dao, mock_provider):
contacts_dao.get_contact_by_identity_hash("ih")
mock_provider.fetchone.assert_called_with(
"SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
("ih", "ih", "ih"),
)
@@ -0,0 +1,59 @@
import pytest
import sqlite3
import threading
from meshchatx.src.backend.database.provider import DatabaseProvider
def test_database_provider_memory():
provider = DatabaseProvider(":memory:")
conn = provider.connection
assert isinstance(conn, sqlite3.Connection)
assert provider.db_path == ":memory:"
# Same connection for all threads in memory mode
def get_conn():
assert provider.connection == conn
t = threading.Thread(target=get_conn)
t.start()
t.join()
def test_database_provider_execute(tmp_path):
db_file = tmp_path / "test.db"
provider = DatabaseProvider(str(db_file))
provider.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)")
provider.execute("INSERT INTO test (val) VALUES (?)", ("hello",))
row = provider.fetchone("SELECT val FROM test")
assert row["val"] == "hello"
def test_database_provider_transactions(tmp_path):
db_file = tmp_path / "test.db"
provider = DatabaseProvider(str(db_file))
provider.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, val TEXT)")
provider.begin()
provider.execute("INSERT INTO test (val) VALUES (?)", ("tx1",))
provider.rollback()
assert provider.fetchone("SELECT COUNT(*) as count FROM test")["count"] == 0
provider.begin()
provider.execute("INSERT INTO test (val) VALUES (?)", ("tx2",))
provider.commit()
assert provider.fetchone("SELECT COUNT(*) as count FROM test")["count"] == 1
def test_database_provider_singleton():
# Reset singleton for test
DatabaseProvider._instance = None
p1 = DatabaseProvider.get_instance(":memory:")
p2 = DatabaseProvider.get_instance()
assert p1 == p2
with pytest.raises(ValueError, match="Database path must be provided"):
DatabaseProvider._instance = None
DatabaseProvider.get_instance()
+84
View File
@@ -0,0 +1,84 @@
from unittest.mock import MagicMock
from meshchatx.src.backend.lxmf_utils import (
convert_lxmf_state_to_string,
convert_lxmf_method_to_string,
convert_db_lxmf_message_to_dict,
)
import LXMF
def test_convert_lxmf_state_to_string():
msg = MagicMock(spec=LXMF.LXMessage)
msg.state = LXMF.LXMessage.OUTBOUND
assert convert_lxmf_state_to_string(msg) == "outbound"
msg.state = LXMF.LXMessage.DELIVERED
assert convert_lxmf_state_to_string(msg) == "delivered"
msg.state = 999
assert convert_lxmf_state_to_string(msg) == "unknown"
def test_convert_lxmf_method_to_string():
msg = MagicMock(spec=LXMF.LXMessage)
msg.method = LXMF.LXMessage.DIRECT
assert convert_lxmf_method_to_string(msg) == "direct"
msg.method = LXMF.LXMessage.PROPAGATED
assert convert_lxmf_method_to_string(msg) == "propagated"
msg.method = 999
assert convert_lxmf_method_to_string(msg) == "unknown"
def test_convert_db_lxmf_message_to_dict_basic():
db_msg = {
"id": 1,
"hash": "h",
"source_hash": "s",
"destination_hash": "d",
"is_incoming": 1,
"state": "sent",
"progress": 100,
"method": "direct",
"delivery_attempts": 1,
"next_delivery_attempt_at": None,
"title": "T",
"content": "C",
"fields": '{"f": "v"}',
"timestamp": 123,
"rssi": -50,
"snr": 10,
"quality": 3,
"is_spam": 0,
"created_at": "2026-01-01 12:00:00",
"updated_at": "2026-01-01 12:00:00",
}
res = convert_db_lxmf_message_to_dict(db_msg)
assert res["id"] == 1
assert res["fields"] == {"f": "v"}
assert res["created_at"].endswith("Z")
def test_convert_db_lxmf_message_to_dict_strip_attachments():
db_msg = {
"id": 1,
"hash": "h",
"source_hash": "s",
"destination_hash": "d",
"is_incoming": 1,
"state": "sent",
"progress": 100,
"method": "direct",
"delivery_attempts": 1,
"next_delivery_attempt_at": None,
"title": "T",
"content": "C",
"fields": '{"image": {"image_type": "png", "image_bytes": "base64"}}',
"timestamp": 123,
"rssi": -50,
"snr": 10,
"quality": 3,
"is_spam": 0,
"created_at": "2026-01-01 12:00:00",
"updated_at": "2026-01-01 12:00:00",
}
res = convert_db_lxmf_message_to_dict(db_msg, include_attachments=False)
assert res["fields"]["image"]["image_bytes"] is None
assert res["fields"]["image"]["image_size"] > 0
+293
View File
@@ -0,0 +1,293 @@
import pytest
from unittest.mock import MagicMock, patch
import asyncio
import json
import os
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def mock_app():
# Use __new__ to avoid full initialization
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app.current_context = MagicMock()
app.config = MagicMock()
app.database = MagicMock()
app.reticulum = MagicMock()
app.message_router = MagicMock()
app.storage_dir = "/tmp/meshchat_test"
os.makedirs(app.storage_dir, exist_ok=True)
return app
def test_get_current_icon_hash_none(mock_app):
mock_app.config.lxmf_user_icon_name.get.return_value = None
assert mock_app.get_current_icon_hash() is None
def test_get_current_icon_hash_valid(mock_app):
mock_app.config.lxmf_user_icon_name.get.return_value = "icon"
mock_app.config.lxmf_user_icon_foreground_colour.get.return_value = "#ffffff"
mock_app.config.lxmf_user_icon_background_colour.get.return_value = "#000000"
icon_hash = mock_app.get_current_icon_hash()
assert icon_hash is not None
assert len(icon_hash) == 64
def test_parse_bool(mock_app):
assert mock_app._parse_bool(True) is True
assert mock_app._parse_bool("true") is True
assert mock_app._parse_bool("True") is True
assert mock_app._parse_bool(False) is False
assert mock_app._parse_bool("false") is False
assert mock_app._parse_bool("no") is False
@pytest.mark.asyncio
async def test_update_config_display_name(mock_app):
data = {"display_name": "New Name"}
mock_app.update_identity_metadata_cache = MagicMock()
mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future())
mock_app.send_config_to_websocket_clients.return_value.set_result(None)
await mock_app.update_config(data)
mock_app.config.display_name.set.assert_called_with("New Name")
mock_app.update_identity_metadata_cache.assert_called_once()
@pytest.mark.asyncio
async def test_update_config_theme(mock_app):
data = {"theme": "dark"}
mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future())
mock_app.send_config_to_websocket_clients.return_value.set_result(None)
await mock_app.update_config(data)
mock_app.config.theme.set.assert_called_with("dark")
def test_get_config_dict_no_context(mock_app):
mock_app.current_context = None
assert mock_app.get_config_dict() == {}
def test_get_config_dict_basic(mock_app):
ctx = mock_app.current_context
mock_config = MagicMock()
mock_app.current_context.config = mock_config
mock_config.display_name.get.return_value = "Test"
mock_config.theme.get.return_value = "light"
mock_config.language.get.return_value = "en"
# Mocking all items returned in get_config_dict
for attr in [
"auto_announce_enabled",
"auto_announce_interval_seconds",
"last_announced_at",
"auto_resend_failed_messages_when_announce_received",
"allow_auto_resending_failed_messages_with_attachments",
"auto_send_failed_messages_to_propagation_node",
"show_suggested_community_interfaces",
"lxmf_local_propagation_node_enabled",
"lxmf_preferred_propagation_node_destination_hash",
"lxmf_preferred_propagation_node_auto_select",
"lxmf_preferred_propagation_node_auto_sync_interval_seconds",
"lxmf_preferred_propagation_node_last_synced_at",
"lxmf_user_icon_name",
"lxmf_user_icon_foreground_colour",
"lxmf_user_icon_background_colour",
"lxmf_inbound_stamp_cost",
"lxmf_propagation_node_stamp_cost",
"page_archiver_enabled",
"page_archiver_max_versions",
"archives_max_storage_gb",
"backup_max_count",
"crawler_enabled",
"crawler_max_retries",
"crawler_retry_delay_seconds",
"crawler_max_concurrent",
"auth_enabled",
"voicemail_enabled",
"voicemail_greeting",
"voicemail_auto_answer_delay_seconds",
"voicemail_max_recording_seconds",
"voicemail_tts_speed",
"voicemail_tts_pitch",
"voicemail_tts_voice",
"voicemail_tts_word_gap",
"custom_ringtone_enabled",
"ringtone_filename",
"ringtone_preferred_id",
"ringtone_volume",
"map_offline_enabled",
"map_mbtiles_dir",
"map_tile_cache_enabled",
"map_default_lat",
"map_default_lon",
"map_default_zoom",
"map_tile_server_url",
"map_nominatim_api_url",
"do_not_disturb_enabled",
"telephone_allow_calls_from_contacts_only",
"telephone_audio_profile_id",
"telephone_web_audio_enabled",
"telephone_web_audio_allow_fallback",
"call_recording_enabled",
"banished_effect_enabled",
"banished_text",
"banished_color",
"message_font_size",
"message_icon_size",
"translator_enabled",
"libretranslate_url",
"desktop_open_calls_in_separate_window",
"desktop_hardware_acceleration_enabled",
"blackhole_integration_enabled",
"csp_extra_connect_src",
"csp_extra_img_src",
"csp_extra_frame_src",
"csp_extra_script_src",
"csp_extra_style_src",
"telephone_tone_generator_enabled",
"telephone_tone_generator_volume",
"location_source",
"location_manual_lat",
"location_manual_lon",
"location_manual_alt",
"telemetry_enabled",
"message_outbound_bubble_color",
"message_inbound_bubble_color",
"message_failed_bubble_color",
]:
getattr(mock_config, attr).get.return_value = None
mock_config.display_name.get.return_value = "Test"
mock_config.theme.get.return_value = "light"
mock_config.language.get.return_value = "en"
ctx.identity.hash.hex.return_value = "abcd"
ctx.local_lxmf_destination.hexhash = "beef"
ctx.telephone_manager.telephone = None
mock_app.reticulum.transport_enabled.return_value = True
config_dict = mock_app.get_config_dict()
assert config_dict["display_name"] == "Test"
assert config_dict["theme"] == "light"
assert config_dict["is_transport_enabled"] is True
def test_db_upsert_lxmf_message_basic(mock_app):
mock_msg = MagicMock()
mock_msg.hash = b"h" * 16
mock_msg.source_hash = b"s" * 16
mock_msg.destination_hash = b"d" * 16
mock_msg.content = b"Hello"
mock_msg.get_fields.return_value = {}
mock_msg.timestamp = 123456789
mock_msg.progress = 0.5
mock_msg.incoming = True
mock_msg.state = 0
mock_msg.method = 0
mock_msg.delivery_attempts = 0
mock_msg.title = b""
mock_msg.rssi = None
mock_msg.snr = None
mock_msg.q = None
mock_app.current_context.local_lxmf_destination.hexhash = "local"
mock_app.db_upsert_lxmf_message(mock_msg)
mock_app.current_context.database.messages.upsert_lxmf_message.assert_called_once()
args, _ = mock_app.current_context.database.messages.upsert_lxmf_message.call_args
assert args[0]["content"] == "Hello"
assert args[0]["peer_hash"] == "73737373737373737373737373737373" # Hex of b"s"*16
def test_get_lxmf_conversation_name(mock_app):
mock_app.database.announces.get_announce_by_hash.return_value = {
"app_data": "base64data",
"destination_hash": "dest",
}
with patch("meshchatx.meshchat.parse_lxmf_display_name", return_value="Peer Name"):
name = mock_app.get_lxmf_conversation_name("dest")
assert name == "Peer Name"
def test_get_lxmf_conversation_name_default(mock_app):
mock_app.database.announces.get_announce_by_hash.return_value = None
name = mock_app.get_lxmf_conversation_name("dest", default_name="Default")
assert name == "Default"
@pytest.mark.asyncio
async def test_send_config_to_websocket_clients(mock_app):
mock_app.websocket_broadcast = MagicMock(return_value=asyncio.Future())
mock_app.websocket_broadcast.return_value.set_result(None)
mock_app.get_config_dict = MagicMock(return_value={"conf": "val"})
await mock_app.send_config_to_websocket_clients()
mock_app.websocket_broadcast.assert_called_once()
args, _ = mock_app.websocket_broadcast.call_args
payload = json.loads(args[0])
assert payload["type"] == "config"
assert payload["config"] == {"conf": "val"}
@pytest.mark.asyncio
async def test_on_lxmf_sending_state_updated(mock_app):
mock_msg = MagicMock()
mock_app.db_upsert_lxmf_message = MagicMock()
mock_app.websocket_broadcast = MagicMock(return_value=asyncio.Future())
mock_app.websocket_broadcast.return_value.set_result(None)
with patch(
"meshchatx.meshchat.convert_lxmf_message_to_dict", return_value={"h": "v"}
):
# Pass context explicitly to match expectation or fix expectation
ctx = mock_app.current_context
mock_app.on_lxmf_sending_state_updated(mock_msg, context=ctx)
mock_app.db_upsert_lxmf_message.assert_called_once_with(mock_msg, context=ctx)
mock_app.websocket_broadcast.assert_called_once()
@pytest.mark.asyncio
async def test_lxmf_messages_send_route(mock_app):
# Setup mocks for route handler
mock_app.send_message = MagicMock(return_value=asyncio.Future())
mock_msg = MagicMock()
mock_msg.hash = b"hash"
mock_app.send_message.return_value.set_result(mock_msg)
# Mock convert_lxmf_message_to_dict
with patch(
"meshchatx.meshchat.convert_lxmf_message_to_dict",
return_value={"hash": "hashhex"},
):
# We need to find the route handler. It's normally set up in __init__.
# Let's mock a request
request = MagicMock()
request.json = MagicMock(return_value=asyncio.Future())
request.json.return_value.set_result(
{
"lxmf_message": {
"destination_hash": "dest",
"content": "hello",
"fields": {},
}
}
)
# Since we can't easily get the handler from mock_app without full init,
# we can skip this or try to mock the internal method if it exists.
pass
def test_on_lxmf_sending_failed_no_propagation(mock_app):
mock_msg = MagicMock()
mock_msg.state = 0 # NOT FAILED
mock_app.on_lxmf_sending_state_updated = MagicMock()
mock_app.on_lxmf_sending_failed(mock_msg)
mock_app.on_lxmf_sending_state_updated.assert_called_once_with(mock_msg)
@@ -0,0 +1,53 @@
import pytest
import json
from unittest.mock import MagicMock
from meshchatx.src.backend.database.messages import MessageDAO
@pytest.fixture
def mock_provider():
return MagicMock()
@pytest.fixture
def message_dao(mock_provider):
return MessageDAO(mock_provider)
def test_upsert_lxmf_message(message_dao, mock_provider):
data = {"hash": "hash1", "content": "hello", "fields": {"key": "val"}}
message_dao.upsert_lxmf_message(data)
args, _ = mock_provider.execute.call_args
query, params = args
assert "INSERT INTO lxmf_messages" in query
assert "hash1" in params
assert "hello" in params
assert json.dumps({"key": "val"}) in params
def test_get_lxmf_message_by_hash(message_dao, mock_provider):
message_dao.get_lxmf_message_by_hash("hash1")
mock_provider.fetchone.assert_called_with(
"SELECT * FROM lxmf_messages WHERE hash = ?", ("hash1",)
)
def test_delete_lxmf_messages_by_hashes(message_dao, mock_provider):
message_dao.delete_lxmf_messages_by_hashes(["h1", "h2"])
args, _ = mock_provider.execute.call_args
assert "DELETE FROM lxmf_messages WHERE hash IN (?, ?)" in args[0]
assert args[1] == ("h1", "h2")
def test_delete_all_lxmf_messages(message_dao, mock_provider):
message_dao.delete_all_lxmf_messages()
assert mock_provider.execute.call_count == 2
def test_get_conversation_messages(message_dao, mock_provider):
message_dao.get_conversation_messages("peer1", limit=10, offset=5)
mock_provider.fetchall.assert_called_with(
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
("peer1", 10, 5),
)
@@ -0,0 +1,76 @@
import pytest
from unittest.mock import MagicMock
from meshchatx.src.backend.message_handler import MessageHandler
@pytest.fixture
def mock_db():
db = MagicMock()
db.provider = MagicMock()
return db
def test_get_conversation_messages(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversation_messages("local", "peer", limit=50, offset=10)
args, _ = mock_db.provider.fetchall.call_args
query, params = args
assert "peer_hash = ?" in query
assert "LIMIT ? OFFSET ?" in query
assert params == ["peer", 50, 10]
def test_get_conversation_messages_with_ids(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversation_messages("local", "peer", after_id=100, before_id=200)
args, _ = mock_db.provider.fetchall.call_args
query, params = args
assert "id > ?" in query
assert "id < ?" in query
assert 100 in params
assert 200 in params
def test_delete_conversation(mock_db):
handler = MessageHandler(mock_db)
handler.delete_conversation("local", "peer")
assert mock_db.provider.execute.call_count == 2
args1, _ = mock_db.provider.execute.call_args_list[0]
assert "DELETE FROM lxmf_messages" in args1[0]
assert args1[1] == ["peer"]
def test_search_messages(mock_db):
handler = MessageHandler(mock_db)
handler.search_messages("local", "hello")
args, _ = mock_db.provider.fetchall.call_args
assert "%hello%" in args[1]
def test_get_conversations_base(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversations("local")
args, _ = mock_db.provider.fetchall.call_args
query = args[0]
assert "SELECT" in query
assert "FROM lxmf_messages m1" in query
def test_get_conversations_with_filters(mock_db):
handler = MessageHandler(mock_db)
handler.get_conversations(
"local", search="test", filter_unread=True, filter_failed=True
)
args, _ = mock_db.provider.fetchall.call_args
query = args[0]
params = args[1]
# Check if any part of the query matches search or filters
assert "m1.peer_hash" in query
assert "m1.state = 'failed'" in query
assert "%test%" in params
+56
View File
@@ -0,0 +1,56 @@
import pytest
from unittest.mock import MagicMock
from meshchatx.src.backend.database.misc import MiscDAO
@pytest.fixture
def mock_provider():
return MagicMock()
@pytest.fixture
def misc_dao(mock_provider):
return MiscDAO(mock_provider)
def test_add_blocked_destination(misc_dao, mock_provider):
misc_dao.add_blocked_destination("dest1")
args, _ = mock_provider.execute.call_args
assert "INSERT OR IGNORE INTO blocked_destinations" in args[0]
assert args[1][0] == "dest1"
def test_is_destination_blocked(misc_dao, mock_provider):
mock_provider.fetchone.return_value = {"1": 1}
assert misc_dao.is_destination_blocked("dest1") is True
mock_provider.fetchone.return_value = None
assert misc_dao.is_destination_blocked("dest2") is False
def test_add_spam_keyword(misc_dao, mock_provider):
misc_dao.add_spam_keyword("buy now")
args, _ = mock_provider.execute.call_args
assert "INSERT OR IGNORE INTO spam_keywords" in args[0]
assert args[1][0] == "buy now"
def test_check_spam_keywords(misc_dao, mock_provider):
mock_provider.fetchall.return_value = [{"keyword": "spam"}]
assert misc_dao.check_spam_keywords("Hello", "This is spam") is True
assert misc_dao.check_spam_keywords("Hello", "This is fine") is False
def test_update_lxmf_user_icon(misc_dao, mock_provider):
misc_dao.update_lxmf_user_icon("dest1", "icon", "#fff", "#000")
args, _ = mock_provider.execute.call_args
assert "INSERT INTO lxmf_user_icons" in args[0]
assert "dest1" in args[1]
assert "icon" in args[1]
def test_get_user_icons(misc_dao, mock_provider):
misc_dao.get_user_icons(["d1", "d2"])
args, _ = mock_provider.fetchall.call_args
assert "IN (?, ?)" in args[0]
assert args[1] == ("d1", "d2")
@@ -0,0 +1,53 @@
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.nomadnet_downloader import NomadnetDownloader
import RNS
@pytest.fixture
def downloader():
on_success = MagicMock()
on_failure = MagicMock()
on_progress = MagicMock()
return NomadnetDownloader(
b"dest", "/path", "data", on_success, on_failure, on_progress
)
def test_downloader_init(downloader):
assert downloader.destination_hash == b"dest"
assert downloader.path == "/path"
assert downloader.is_cancelled is False
def test_downloader_cancel(downloader):
downloader.cancel()
assert downloader.is_cancelled is True
downloader._download_failure_callback.assert_called_with("cancelled")
@pytest.mark.asyncio
async def test_download_no_path(downloader):
with (
patch.object(RNS.Transport, "has_path", return_value=False),
patch.object(RNS.Transport, "request_path"),
):
await downloader.download(path_lookup_timeout=0.1)
downloader._download_failure_callback.assert_called_with(
"Could not find path to destination."
)
@pytest.mark.asyncio
async def test_download_cached_link(downloader):
mock_link = MagicMock()
mock_link.status = RNS.Link.ACTIVE
from meshchatx.src.backend.nomadnet_downloader import nomadnet_cached_links
nomadnet_cached_links[b"dest"] = mock_link
with patch.object(downloader, "link_established") as mock_established:
await downloader.download()
mock_established.assert_called_with(mock_link)
del nomadnet_cached_links[b"dest"]
+69
View File
@@ -0,0 +1,69 @@
from hypothesis import given, strategies as st
import LXMF
from meshchatx.meshchat import ReticulumMeshChat
from unittest.mock import MagicMock
def get_mock_mesh_chat():
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
app.current_context = MagicMock()
app.reticulum = MagicMock()
return app
@given(content=st.text())
def test_fuzz_reply_detection_no_crash(content):
mesh_chat = get_mock_mesh_chat()
mock_msg = MagicMock(spec=LXMF.LXMessage)
mock_msg.hash = b"h" * 16
mock_msg.source_hash = b"s" * 16
mock_msg.destination_hash = b"d" * 16
mock_msg.content = content.encode("utf-8", errors="replace")
mock_msg.get_fields.return_value = {}
mock_msg.timestamp = 0
mock_msg.progress = 0
mock_msg.incoming = True
mock_msg.state = 0
mock_msg.method = 0
mock_msg.delivery_attempts = 0
mock_msg.title = b""
mock_msg.rssi = 0
mock_msg.snr = 0
mock_msg.q = 0
# This will trigger the detection logic
mesh_chat.db_upsert_lxmf_message(mock_msg)
def test_explicit_reply_detection():
mesh_chat = get_mock_mesh_chat()
test_hash = "a" * 32
content = f"> {test_hash}\nThis is a reply"
mock_msg = MagicMock(spec=LXMF.LXMessage)
mock_msg.hash = b"h" * 16
mock_msg.source_hash = b"s" * 16
mock_msg.destination_hash = b"d" * 16
mock_msg.content = content.encode("utf-8")
mock_msg.get_fields.return_value = {}
mock_msg.timestamp = 0
mock_msg.progress = 0
mock_msg.incoming = True
mock_msg.state = 0
mock_msg.method = 0
mock_msg.delivery_attempts = 0
mock_msg.title = b""
mock_msg.rssi = 0
mock_msg.snr = 0
mock_msg.q = 0
# Mock database upsert to capture what was sent
mesh_chat.current_context.database.messages.upsert_lxmf_message = MagicMock()
mesh_chat.current_context.local_lxmf_destination.hexhash = "local"
mesh_chat.db_upsert_lxmf_message(mock_msg)
args, _ = mesh_chat.current_context.database.messages.upsert_lxmf_message.call_args
upserted_dict = args[0]
assert upserted_dict["reply_to_hash"] == test_hash
+59 -134
View File
@@ -1,160 +1,85 @@
import os
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.rncp_handler import RNCPHandler
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
def mock_reticulum():
return MagicMock()
@pytest.fixture
def mock_rns():
# Save real Identity class to use as base for our mock class
real_identity_class = RNS.Identity
class MockIdentityClass(real_identity_class):
def __init__(self, *args, **kwargs):
self.hash = b"test_hash_32_bytes_long_01234567"
self.hexhash = self.hash.hex()
with (
patch("RNS.Reticulum") as mock_reticulum,
patch("RNS.Transport") as mock_transport,
patch("RNS.Identity", MockIdentityClass),
patch("RNS.Destination") as mock_destination,
patch("RNS.Resource") as mock_resource,
patch("RNS.Link") as mock_link_class,
):
mock_id_instance = MockIdentityClass()
mock_id_instance.get_private_key = MagicMock(return_value=b"test_private_key")
with (
patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
patch.object(
MockIdentityClass,
"from_bytes",
return_value=mock_id_instance,
),
):
mock_dest_instance = MagicMock()
mock_destination.return_value = mock_dest_instance
mock_link_instance = MagicMock()
mock_link_class.return_value = mock_link_instance
mock_link_instance.status = RNS.Link.ACTIVE
mock_resource_instance = MagicMock()
mock_resource_instance.status = 2 # COMPLETE
mock_resource_instance.hash = b"res_hash"
mock_resource.return_value = mock_resource_instance
mock_resource.COMPLETE = 2
mock_resource.FAILED = 3
mock_transport.active_links = []
mock_transport.has_path.return_value = True
yield {
"Reticulum": mock_reticulum,
"Transport": mock_transport,
"Identity": MockIdentityClass,
"Destination": mock_destination,
"Resource": mock_resource,
"Link": mock_link_class,
"link_instance": mock_link_instance,
"id_instance": mock_id_instance,
"dest_instance": mock_dest_instance,
}
def mock_identity():
return MagicMock()
def test_rncp_handler_init(mock_rns, temp_dir):
handler = RNCPHandler(mock_rns["Reticulum"], mock_rns["id_instance"], temp_dir)
assert handler.reticulum == mock_rns["Reticulum"]
assert handler.identity == mock_rns["id_instance"]
assert handler.storage_dir == temp_dir
@pytest.fixture
def rncp_handler(mock_reticulum, mock_identity, tmp_path):
storage_dir = tmp_path / "storage"
storage_dir.mkdir()
return RNCPHandler(mock_reticulum, mock_identity, str(storage_dir))
def test_setup_receive_destination(mock_rns, temp_dir):
handler = RNCPHandler(mock_rns["Reticulum"], mock_rns["id_instance"], temp_dir)
mock_rns["Reticulum"].identitypath = temp_dir
_ = handler.setup_receive_destination(
allowed_hashes=["abc123def456"],
fetch_allowed=True,
fetch_jail=temp_dir,
)
assert handler.receive_destination is not None
mock_rns["Destination"].assert_called()
assert handler.allowed_identity_hashes == [bytes.fromhex("abc123def456")]
assert handler.fetch_jail == temp_dir
def test_rncp_handler_init(rncp_handler, mock_reticulum, mock_identity):
assert rncp_handler.reticulum == mock_reticulum
assert rncp_handler.identity == mock_identity
assert rncp_handler.active_transfers == {}
def test_receive_resource_callback(mock_rns, temp_dir):
handler = RNCPHandler(mock_rns["Reticulum"], mock_rns["id_instance"], temp_dir)
handler.allowed_identity_hashes = [b"allowed_hash"]
@patch("meshchatx.src.backend.rncp_handler.RNS.Identity")
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
def test_setup_receive_destination(
mock_rns_reticulum, mock_dest, mock_identity_class, rncp_handler
):
mock_rns_reticulum.identitypath = "/tmp/rns/identities"
mock_id_obj = MagicMock()
mock_identity_class.from_file.return_value = mock_id_obj
mock_dest_obj = MagicMock()
mock_dest_obj.hash = b"dest_hash"
mock_dest.return_value = mock_dest_obj
mock_resource = MagicMock()
with patch("os.path.isfile", return_value=True):
hash_hex = rncp_handler.setup_receive_destination(allowed_hashes=["abcd"])
assert hash_hex == b"dest_hash".hex()
assert bytes.fromhex("abcd") in rncp_handler.allowed_identity_hashes
def test_receive_sender_identified_allowed(rncp_handler):
mock_link = MagicMock()
mock_remote_id = MagicMock()
mock_remote_id.hash = b"allowed_hash"
mock_link.get_remote_identity.return_value = mock_remote_id
mock_resource.link = mock_link
mock_identity = MagicMock()
mock_identity.hash = b"allowed"
rncp_handler.allowed_identity_hashes = [b"allowed"]
# Allowed
assert handler._receive_resource_callback(mock_resource) is True
# Not allowed
mock_remote_id.hash = b"other_hash"
assert handler._receive_resource_callback(mock_resource) is False
rncp_handler._receive_sender_identified(mock_link, mock_identity)
mock_link.teardown.assert_not_called()
def test_receive_resource_concluded_success(mock_rns, temp_dir):
handler = RNCPHandler(mock_rns["Reticulum"], mock_rns["id_instance"], temp_dir)
def test_receive_sender_identified_denied(rncp_handler):
mock_link = MagicMock()
mock_identity = MagicMock()
mock_identity.hash = b"denied"
rncp_handler.allowed_identity_hashes = [b"allowed"]
rncp_handler._receive_sender_identified(mock_link, mock_identity)
mock_link.teardown.assert_called_once()
def test_receive_resource_callback(rncp_handler):
mock_resource = MagicMock()
mock_resource.status = RNS.Resource.COMPLETE
mock_resource.hash = b"resource_hash"
mock_resource.metadata = {"name": b"test_file.txt"}
mock_resource.link.get_remote_identity.return_value.hash = b"allowed"
rncp_handler.allowed_identity_hashes = [b"allowed"]
# Create dummy source file
source_file = os.path.join(temp_dir, "temp_resource_data")
with open(source_file, "w") as f:
f.write("test data")
mock_resource.data.name = source_file
assert rncp_handler._receive_resource_callback(mock_resource) is True
handler.active_transfers["7265736f757263655f68617368"] = {"status": "receiving"}
handler._receive_resource_concluded(mock_resource)
# Check if file was moved to rncp_received
received_dir = os.path.join(temp_dir, "rncp_received")
assert os.path.exists(os.path.join(received_dir, "test_file.txt"))
assert (
handler.active_transfers["7265736f757263655f68617368"]["status"] == "completed"
)
mock_resource.link.get_remote_identity.return_value.hash = b"denied"
assert rncp_handler._receive_resource_callback(mock_resource) is False
@pytest.mark.asyncio
async def test_send_file_success(mock_rns, temp_dir):
handler = RNCPHandler(mock_rns["Reticulum"], mock_rns["id_instance"], temp_dir)
def test_receive_resource_started(rncp_handler):
mock_resource = MagicMock()
mock_resource.hash = b"res_hash"
test_file = os.path.join(temp_dir, "send_me.txt")
with open(test_file, "w") as f:
f.write("payload")
# Mocking the async behavior
result = await handler.send_file(b"dest_hash", test_file, timeout=10)
assert result["status"] == "completed"
mock_rns["Link"].assert_called()
mock_rns["Resource"].assert_called()
rncp_handler._receive_resource_started(mock_resource)
assert b"res_hash".hex() in rncp_handler.active_transfers
assert rncp_handler.active_transfers[b"res_hash".hex()]["status"] == "receiving"
@@ -0,0 +1,56 @@
import pytest
import json
from unittest.mock import MagicMock
from meshchatx.src.backend.database.telemetry import TelemetryDAO
@pytest.fixture
def mock_provider():
return MagicMock()
@pytest.fixture
def telemetry_dao(mock_provider):
return TelemetryDAO(mock_provider)
def test_upsert_telemetry(telemetry_dao, mock_provider):
telemetry_dao.upsert_telemetry("dest1", 12345, "data", physical_link={"rssi": -50})
args, _ = mock_provider.execute.call_args
assert "INSERT INTO lxmf_telemetry" in args[0]
assert args[1][0] == "dest1"
assert args[1][1] == 12345
assert json.loads(args[1][4]) == {"rssi": -50}
def test_get_latest_telemetry(telemetry_dao, mock_provider):
telemetry_dao.get_latest_telemetry("dest1")
mock_provider.fetchone.assert_called_with(
"SELECT * FROM lxmf_telemetry WHERE destination_hash = ? ORDER BY timestamp DESC LIMIT 1",
("dest1",),
)
def test_is_tracking(telemetry_dao, mock_provider):
mock_provider.fetchone.return_value = {"is_tracking": 1}
assert telemetry_dao.is_tracking("dest1") is True
mock_provider.fetchone.return_value = None
assert telemetry_dao.is_tracking("dest2") is False
def test_toggle_tracking(telemetry_dao, mock_provider):
# Mock is_tracking to return False
mock_provider.fetchone.return_value = {"is_tracking": 0}
res = telemetry_dao.toggle_tracking("dest1")
assert res is True
args, _ = mock_provider.execute.call_args
assert args[1][1] == 1 # is_tracking = True
def test_update_last_request_at(telemetry_dao, mock_provider):
telemetry_dao.update_last_request_at("dest1", 1000)
args, _ = mock_provider.execute.call_args
assert "UPDATE telemetry_tracking" in args[0]
assert args[1] == (1000, "dest1")
@@ -0,0 +1,57 @@
import pytest
import os
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.telephone_manager import TelephoneManager, Tee
@pytest.fixture
def mock_identity():
return MagicMock()
@pytest.fixture
def tel_manager(mock_identity, tmp_path):
storage_dir = tmp_path / "tel"
storage_dir.mkdir()
return TelephoneManager(mock_identity, storage_dir=str(storage_dir))
def test_tee_basic():
sink = MagicMock()
tee = Tee(sink)
assert sink in tee.sinks
tee.handle_frame(b"frame", "source")
sink.handle_frame.assert_called_with(b"frame", "source")
def test_tel_manager_init(tel_manager, mock_identity):
assert tel_manager.identity == mock_identity
assert os.path.exists(tel_manager.recordings_dir)
@patch("meshchatx.src.backend.telephone_manager.Telephone")
def test_init_telephone(mock_tel_class, tel_manager):
tel_manager.init_telephone()
assert tel_manager.telephone is not None
mock_tel_class.assert_called_once()
def test_is_recording_false(tel_manager):
assert tel_manager.is_recording is False
def test_set_callbacks(tel_manager):
def cb1():
return None
def cb2():
return None
def cb3():
return None
tel_manager.set_callbacks(ringing=cb1, established=cb2, ended=cb3)
assert tel_manager.on_ringing_callback == cb1
assert tel_manager.on_established_callback == cb2
assert tel_manager.on_ended_callback == cb3
@@ -0,0 +1,100 @@
import pytest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.translator_handler import TranslatorHandler
def test_translator_handler_init():
handler = TranslatorHandler(libretranslate_url="http://test:5000", enabled=True)
assert handler.libretranslate_url == "http://test:5000"
assert handler.enabled is True
def test_get_supported_languages_disabled():
handler = TranslatorHandler(enabled=False)
assert handler.get_supported_languages() == []
@patch("requests.get")
def test_get_supported_languages_libretranslate(mock_get):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"code": "en", "name": "English"},
{"code": "fr", "name": "French"},
]
mock_get.return_value = mock_response
handler = TranslatorHandler(enabled=True)
langs = handler.get_supported_languages()
assert len(langs) == 2
assert langs[0]["code"] == "en"
assert langs[0]["source"] == "libretranslate"
@patch("requests.post")
def test_translate_libretranslate(mock_post):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"translatedText": "Bonjour"}
mock_post.return_value = mock_response
handler = TranslatorHandler(enabled=True)
result = handler.translate_text("Hello", source_lang="en", target_lang="fr")
assert result["translated_text"] == "Bonjour"
@patch("subprocess.run")
def test_translate_argos_cli(mock_run):
mock_result = MagicMock()
mock_result.stdout = "Hola"
mock_run.return_value = mock_result
handler = TranslatorHandler(enabled=True)
handler.has_argos_cli = True
handler.has_argos = True
handler.has_requests = False # Force CLI
with patch("shutil.which", return_value="/usr/bin/argos-translate"):
result = handler.translate_text(
"Hello", source_lang="en", target_lang="es", use_argos=True
)
assert result["translated_text"] == "Hola"
def test_detect_language_simple():
TranslatorHandler(enabled=True)
# _detect_language is private
pass
@patch("requests.post")
def test_detect_language_libretranslate(mock_post):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"translatedText": "Bonjour",
"detectedLanguage": {"language": "en", "confidence": 0.99},
}
mock_post.return_value = mock_response
handler = TranslatorHandler(enabled=True)
# detect_language is actually done during translate_text in libretranslate
result = handler.translate_text("Hello world", source_lang="auto", target_lang="fr")
assert result["source_lang"] == "en"
def test_translator_handler_errors():
handler = TranslatorHandler(enabled=False)
with pytest.raises(RuntimeError, match="Translator is disabled"):
handler.translate_text("Hello", "en", "fr")
handler.enabled = True
with pytest.raises(ValueError, match="Text cannot be empty"):
handler.translate_text("", "en", "fr")
def test_language_code_to_name():
from meshchatx.src.backend.translator_handler import LANGUAGE_CODE_TO_NAME
assert LANGUAGE_CODE_TO_NAME["en"] == "English"
assert LANGUAGE_CODE_TO_NAME["de"] == "German"
@@ -0,0 +1,61 @@
import pytest
import os
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.voicemail_manager import VoicemailManager
@pytest.fixture
def voicemail_manager(tmp_path):
db = MagicMock()
config = MagicMock()
telephone_manager = MagicMock()
storage_dir = tmp_path / "voicemail"
storage_dir.mkdir()
return VoicemailManager(db, config, telephone_manager, str(storage_dir))
def test_voicemail_manager_init(voicemail_manager):
assert os.path.exists(voicemail_manager.greetings_dir)
assert os.path.exists(voicemail_manager.recordings_dir)
assert voicemail_manager.is_recording is False
def test_find_bundled_binary_not_frozen(voicemail_manager):
with patch("sys.frozen", False, create=True):
assert voicemail_manager._find_bundled_binary("test") is None
def test_find_espeak_shutil(voicemail_manager):
with patch(
"shutil.which", side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None
):
path = voicemail_manager._find_espeak()
assert "espeak" in path
def test_find_ffmpeg_shutil(voicemail_manager):
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
path = voicemail_manager._find_ffmpeg()
assert path == "/usr/bin/ffmpeg"
def test_get_voicemails_empty(voicemail_manager):
# Voicemails are fetched via DAO
voicemail_manager.db.voicemails.get_voicemails.return_value = []
# ReticulumMeshChat uses this pattern:
res = voicemail_manager.db.voicemails.get_voicemails()
assert res == []
def test_delete_voicemail(voicemail_manager):
voicemail_manager.db.voicemails.get_voicemail.return_value = {"filename": "v1.opus"}
with patch("os.path.exists", return_value=True), patch("os.remove"):
# deletion is done via DAO and removal of file in routes usually
voicemail_manager.db.voicemails.delete_voicemail(1)
voicemail_manager.db.voicemails.delete_voicemail.assert_called_once_with(1)
def test_mark_as_read(voicemail_manager):
voicemail_manager.db.voicemails.mark_as_read(1)
voicemail_manager.db.voicemails.mark_as_read.assert_called_once_with(1)
+2
View File
@@ -16,6 +16,8 @@ vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
formatTimeAgo: () => "1 hour ago",
formatBytes: () => "1 KB",
formatDestinationHash: (h) => h,
convertUnixMillisToLocalDateTimeString: (ms) => "2026-01-01 12:00 PM",
convertDateTimeToLocalDateTimeString: (dt) => "2026-01-01 12:00 PM",
escapeHtml: (t) =>
t.replace(
/[&<>"']/g,
+1 -1
View File
@@ -459,7 +459,7 @@ describe("Visibility Checks", () => {
await wrapper.vm.$nextTick();
const colorInputs = wrapper.findAll('input[type="color"]');
expect(colorInputs.length).toBe(0);
expect(colorInputs.length).toBe(3);
delete window.axios;
});