mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-28 00:54:27 +00:00
Improve messaging features and configuration options
- Add more tests - Fix notifactions
This commit is contained in:
+50
-6
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user