From 6008b2205c50a1bb529bd612d988e05e50b1eb7a Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 8 Jul 2026 09:25:00 -0500 Subject: [PATCH] feat(notification-sounds): implement notification sound management and settings and faster conversations/nomadnet browser loading --- meshchatx/meshchat.py | 204 ++++++++++++++ meshchatx/src/backend/config_manager.py | 17 ++ meshchatx/src/backend/database/__init__.py | 2 + .../backend/database/notification_sounds.py | 84 ++++++ meshchatx/src/backend/database/schema.py | 26 +- meshchatx/src/backend/identity_context.py | 8 + meshchatx/src/backend/ringtone_manager.py | 15 +- meshchatx/src/frontend/components/App.vue | 18 +- .../messages/ConversationViewer.vue | 6 +- .../components/messages/MessagesPage.vue | 54 +++- .../components/messages/MessagesSidebar.vue | 6 + .../nomadnetwork/NomadNetworkBrowser.vue | 55 +++- .../settings/NotificationSoundSettings.vue | 266 ++++++++++++++++++ .../components/settings/SettingsPage.vue | 9 + .../src/frontend/js/NotificationSoundUtils.js | 123 ++++++++ .../src/frontend/js/NotificationUtils.js | 5 +- .../frontend/js/lxmfConversationListSync.js | 94 +++++++ .../registries/coreSettingsSectionKeywords.js | 11 + .../src/frontend/js/settings/settingsTabs.js | 11 +- meshchatx/src/frontend/locales/de.json | 23 ++ meshchatx/src/frontend/locales/en.json | 23 ++ meshchatx/src/frontend/locales/es.json | 23 ++ meshchatx/src/frontend/locales/fi.json | 23 ++ meshchatx/src/frontend/locales/fr.json | 23 ++ meshchatx/src/frontend/locales/it.json | 23 ++ meshchatx/src/frontend/locales/nl.json | 23 ++ meshchatx/src/frontend/locales/ru.json | 23 ++ meshchatx/src/frontend/locales/zh.json | 23 ++ tests/backend/test_config_manager.py | 9 + tests/backend/test_notification_sounds.py | 81 ++++++ tests/backend/test_telemetry_integration.py | 1 - .../test_translator_handler_extended.py | 1 - tests/frontend/ConversationViewer.test.js | 4 +- tests/frontend/MessagesPage.test.js | 12 +- tests/frontend/MountLoadPerformance.test.js | 234 +++++++++++++++ tests/frontend/NomadNetworkBrowser.test.js | 22 +- tests/frontend/NotificationSoundUtils.test.js | 121 ++++++++ tests/frontend/NotificationUtils.test.js | 7 +- .../frontend/lxmfConversationListSync.test.js | 62 ++++ tests/frontend/settingsTabs.test.js | 1 + 40 files changed, 1730 insertions(+), 46 deletions(-) create mode 100644 meshchatx/src/backend/database/notification_sounds.py create mode 100644 meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue create mode 100644 meshchatx/src/frontend/js/NotificationSoundUtils.js create mode 100644 meshchatx/src/frontend/js/lxmfConversationListSync.js create mode 100644 tests/backend/test_notification_sounds.py create mode 100644 tests/frontend/MountLoadPerformance.test.js create mode 100644 tests/frontend/NotificationSoundUtils.test.js create mode 100644 tests/frontend/lxmfConversationListSync.test.js diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 9ef57e4d..5c311963 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -594,6 +594,19 @@ class ReticulumMeshChat: if self.current_context: self.current_context.ringtone_manager = value + @property + def notification_sound_manager(self): + return ( + self.current_context.notification_sound_manager + if self.current_context + else None + ) + + @notification_sound_manager.setter + def notification_sound_manager(self, value): + if self.current_context: + self.current_context.notification_sound_manager = value + @property def rncp_handler(self): return self.current_context.rncp_handler if self.current_context else None @@ -9471,6 +9484,181 @@ class ReticulumMeshChat: except Exception as e: return web.json_response({"message": str(e)}, status=500) + # notification sound routes + @routes.get("/api/v1/notification-sounds") + async def notification_sounds_get(request): + sounds = self.database.notification_sounds.get_all() + return web.json_response( + [ + { + "id": s["id"], + "filename": s["filename"], + "display_name": s["display_name"], + "is_primary": bool(s["is_primary"]), + "created_at": s["created_at"], + } + for s in sounds + ], + ) + + @routes.get("/api/v1/notification-sounds/status") + async def notification_sound_status(request): + try: + sound_id = None + + preferred_id = self.config.notification_sound_preferred_id.get() + if preferred_id and preferred_id > 0: + sound_id = preferred_id + + if sound_id is None: + primary = self.database.notification_sounds.get_primary() + if primary: + sound_id = primary["id"] + + has_sound = sound_id is not None + sound = ( + self.database.notification_sounds.get_by_id(sound_id) + if sound_id + else None + ) + + return web.json_response( + { + "has_sound": has_sound and sound is not None, + "enabled": self.config.notification_sound_enabled.get(), + "filename": sound["filename"] if sound else None, + "id": sound_id, + "volume": self.config.notification_sound_volume.get() / 100.0, + }, + ) + except Exception as e: + logger.error(f"Error in notification_sound_status: {e}") + return web.json_response( + { + "has_sound": False, + "enabled": self.config.notification_sound_enabled.get(), + "filename": None, + "id": None, + "volume": self.config.notification_sound_volume.get() / 100.0, + }, + ) + + @routes.get("/api/v1/notification-sounds/{id}/audio") + async def notification_sound_audio(request): + sound_id = int(request.match_info["id"]) + sound = self.database.notification_sounds.get_by_id(sound_id) + if not sound: + return web.Response(status=404) + + if not self.notification_sound_manager: + return web.Response(status=503) + + filepath = self.notification_sound_manager.get_ringtone_path( + sound["storage_filename"], + ) + if not os.path.exists(filepath): + return web.Response(status=404) + + return web.FileResponse( + filepath, + headers={ + "Content-Type": "audio/ogg", + "Content-Disposition": f'attachment; filename="{sound["filename"]}"', + }, + ) + + @routes.post("/api/v1/notification-sounds/upload") + async def notification_sound_upload(request): + if not self.notification_sound_manager: + return web.json_response( + {"message": "Notification sound manager unavailable"}, + status=503, + ) + try: + reader = await request.multipart() + field = await reader.next() + if field.name != "file": + return web.json_response( + {"message": "File field required"}, + status=400, + ) + + filename = field.filename + extension = os.path.splitext(filename)[1].lower() + if extension not in [".mp3", ".ogg", ".wav", ".m4a", ".flac"]: + return web.json_response( + {"message": f"Unsupported file type: {extension}"}, + status=400, + ) + + with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as f: + temp_path = f.name + while True: + chunk = await field.read_chunk() + if not chunk: + break + f.write(chunk) + + try: + storage_filename = await asyncio.to_thread( + self.notification_sound_manager.convert_to_ringtone, + temp_path, + ) + + sound_id = self.database.notification_sounds.add( + filename=filename, + storage_filename=storage_filename, + ) + + return web.json_response( + { + "message": "Notification sound uploaded and converted", + "id": sound_id, + "filename": filename, + "storage_filename": storage_filename, + }, + ) + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.patch("/api/v1/notification-sounds/{id}") + async def notification_sound_patch(request): + try: + sound_id = int(request.match_info["id"]) + data = await request.json() + + display_name = data.get("display_name") + is_primary = 1 if data.get("is_primary") else None + + self.database.notification_sounds.update( + sound_id, + display_name=display_name, + is_primary=is_primary, + ) + + return web.json_response({"message": "Notification sound updated"}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + + @routes.delete("/api/v1/notification-sounds/{id}") + async def notification_sound_delete(request): + try: + sound_id = int(request.match_info["id"]) + sound = self.database.notification_sounds.get_by_id(sound_id) + if sound: + if self.notification_sound_manager: + self.notification_sound_manager.remove_ringtone( + sound["storage_filename"], + ) + self.database.notification_sounds.delete(sound_id) + return web.json_response({"message": "Notification sound deleted"}) + except Exception as e: + return web.json_response({"message": str(e)}, status=500) + # contacts routes @routes.get("/api/v1/telephone/contacts") async def telephone_contacts_get(request): @@ -15595,6 +15783,19 @@ class ReticulumMeshChat: if value is not None: self.config.ringtone_volume.set(value) + if "notification_sound_enabled" in data: + self.config.notification_sound_enabled.set( + self._parse_bool(data["notification_sound_enabled"]), + ) + if "notification_sound_preferred_id" in data: + value = self._coerce_int(data["notification_sound_preferred_id"]) + if value is not None: + self.config.notification_sound_preferred_id.set(value) + if "notification_sound_volume" in data: + value = self._coerce_int(data["notification_sound_volume"]) + if value is not None: + self.config.notification_sound_volume.set(value) + if "do_not_disturb_enabled" in data: self.config.do_not_disturb_enabled.set( self._parse_bool(data["do_not_disturb_enabled"]), @@ -16864,6 +17065,9 @@ class ReticulumMeshChat: "ringtone_filename": ctx.config.ringtone_filename.get(), "ringtone_preferred_id": ctx.config.ringtone_preferred_id.get(), "ringtone_volume": ctx.config.ringtone_volume.get(), + "notification_sound_enabled": ctx.config.notification_sound_enabled.get(), + "notification_sound_preferred_id": ctx.config.notification_sound_preferred_id.get(), + "notification_sound_volume": ctx.config.notification_sound_volume.get(), "map_offline_enabled": ctx.config.map_offline_enabled.get(), "map_mbtiles_dir": ctx.config.map_mbtiles_dir.get(), "map_tile_cache_enabled": ctx.config.map_tile_cache_enabled.get(), diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 33303661..a09068b8 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -213,6 +213,23 @@ class ConfigManager: self.ringtone_preferred_id = self.IntConfig(self, "ringtone_preferred_id", 0) self.ringtone_volume = self.IntConfig(self, "ringtone_volume", 100) + # notification sound config + self.notification_sound_enabled = self.BoolConfig( + self, + "notification_sound_enabled", + False, + ) + self.notification_sound_preferred_id = self.IntConfig( + self, + "notification_sound_preferred_id", + 0, + ) + self.notification_sound_volume = self.IntConfig( + self, + "notification_sound_volume", + 100, + ) + # telephony config self.telephone_enabled = self.BoolConfig( self, diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py index 9e28d1a6..beaf5714 100644 --- a/meshchatx/src/backend/database/__init__.py +++ b/meshchatx/src/backend/database/__init__.py @@ -19,6 +19,7 @@ from .map_drawings import MapDrawingsDAO from .messages import MessageDAO from .misc import MiscDAO from .provider import DatabaseProvider +from .notification_sounds import NotificationSoundDAO from .ringtones import RingtoneDAO from .schema import DatabaseSchema from .sticker_packs import UserStickerPacksDAO @@ -77,6 +78,7 @@ class Database: self.telemetry = TelemetryDAO(self.provider) self.voicemails = VoicemailDAO(self.provider) self.ringtones = RingtoneDAO(self.provider) + self.notification_sounds = NotificationSoundDAO(self.provider) self.contacts = ContactsDAO(self.provider) self.map_drawings = MapDrawingsDAO(self.provider) self.stickers = UserStickersDAO(self.provider) diff --git a/meshchatx/src/backend/database/notification_sounds.py b/meshchatx/src/backend/database/notification_sounds.py new file mode 100644 index 00000000..ea6a5bca --- /dev/null +++ b/meshchatx/src/backend/database/notification_sounds.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: 0BSD + +from datetime import UTC, datetime + +from .provider import DatabaseProvider + + +class NotificationSoundDAO: + def __init__(self, provider: DatabaseProvider): + self.provider = provider + + def get_all(self): + return self.provider.fetchall( + "SELECT * FROM notification_sounds ORDER BY created_at DESC", + ) + + def get_by_id(self, sound_id): + return self.provider.fetchone( + "SELECT * FROM notification_sounds WHERE id = ?", + (sound_id,), + ) + + def get_primary(self): + return self.provider.fetchone( + "SELECT * FROM notification_sounds WHERE is_primary = 1", + ) + + def add(self, filename, storage_filename, display_name=None): + now = datetime.now(UTC) + if display_name is None: + display_name = filename + + count = self.provider.fetchone( + "SELECT COUNT(*) as count FROM notification_sounds", + )["count"] + is_primary = 1 if count == 0 else 0 + + cursor = self.provider.execute( + "INSERT INTO notification_sounds (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + (filename, display_name, storage_filename, is_primary, now, now), + ) + return cursor.lastrowid + + def update(self, sound_id, display_name=None, is_primary=None): + now = datetime.now(UTC) + if is_primary == 1: + self.provider.execute( + "UPDATE notification_sounds SET is_primary = 0, updated_at = ?", + (now,), + ) + + if display_name is not None and is_primary is not None: + self.provider.execute( + "UPDATE notification_sounds SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?", + (display_name, is_primary, now, sound_id), + ) + elif display_name is not None: + self.provider.execute( + "UPDATE notification_sounds SET display_name = ?, updated_at = ? WHERE id = ?", + (display_name, now, sound_id), + ) + elif is_primary is not None: + self.provider.execute( + "UPDATE notification_sounds SET is_primary = ?, updated_at = ? WHERE id = ?", + (is_primary, now, sound_id), + ) + + def delete(self, sound_id): + sound = self.get_by_id(sound_id) + if sound and sound["is_primary"] == 1: + self.provider.execute( + "DELETE FROM notification_sounds WHERE id = ?", + (sound_id,), + ) + next_sound = self.provider.fetchone( + "SELECT id FROM notification_sounds LIMIT 1", + ) + if next_sound: + self.update(next_sound["id"], is_primary=1) + else: + self.provider.execute( + "DELETE FROM notification_sounds WHERE id = ?", + (sound_id,), + ) diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py index 2d4033fe..0ea4c91f 100644 --- a/meshchatx/src/backend/database/schema.py +++ b/meshchatx/src/backend/database/schema.py @@ -19,7 +19,7 @@ def _validate_identifier(name: str, label: str = "identifier") -> str: class DatabaseSchema: - LATEST_VERSION = 48 + LATEST_VERSION = 49 def __init__(self, provider: DatabaseProvider): self.provider = provider @@ -386,6 +386,17 @@ class DatabaseSchema: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """, + "notification_sounds": """ + CREATE TABLE IF NOT EXISTS notification_sounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + display_name TEXT, + storage_filename TEXT, + is_primary INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """, "contacts": """ CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1294,3 +1305,16 @@ class DatabaseSchema: if current_version < 48: self._ensure_column("lxmf_messages", "path_finding_measure", "TEXT") self._ensure_column("lxmf_messages", "path_row_hash_hex", "TEXT") + + if current_version < 49: + self._safe_execute(""" + CREATE TABLE IF NOT EXISTS notification_sounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT, + display_name TEXT, + storage_filename TEXT, + is_primary INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py index 48833a44..7ba13b66 100644 --- a/meshchatx/src/backend/identity_context.py +++ b/meshchatx/src/backend/identity_context.py @@ -87,6 +87,7 @@ class IdentityContext: self.telephone_manager = None self.voicemail_manager = None self.ringtone_manager = None + self.notification_sound_manager = None self.auto_propagation_manager = None self.rncp_handler = None self.rnsh_manager = None @@ -391,6 +392,13 @@ class IdentityContext: storage_dir=self.storage_path, ) + self.notification_sound_manager = RingtoneManager( + config=self.config, + storage_dir=self.storage_path, + asset_subdir="notification_sounds", + filename_prefix="notification", + ) + self.community_interfaces_manager = CommunityInterfacesManager( public_override_path=self.app.get_public_path("community_interfaces.json"), cache_path=os.path.join( diff --git a/meshchatx/src/backend/ringtone_manager.py b/meshchatx/src/backend/ringtone_manager.py index d7e08f39..92888c99 100644 --- a/meshchatx/src/backend/ringtone_manager.py +++ b/meshchatx/src/backend/ringtone_manager.py @@ -13,9 +13,18 @@ def _ringtone_profile(): class RingtoneManager: - def __init__(self, config, storage_dir): + def __init__( + self, + config, + storage_dir, + *, + asset_subdir="ringtones", + filename_prefix="ringtone", + ): self.config = config - self.storage_dir = os.path.join(storage_dir, "ringtones") + self.asset_subdir = asset_subdir + self.filename_prefix = filename_prefix + self.storage_dir = os.path.join(storage_dir, asset_subdir) os.makedirs(self.storage_dir, exist_ok=True) @@ -30,7 +39,7 @@ class RingtoneManager: """ import secrets - filename = f"ringtone_{secrets.token_hex(8)}.opus" + filename = f"{self.filename_prefix}_{secrets.token_hex(8)}.opus" opus_path = os.path.join(self.storage_dir, filename) encode_audio_to_ogg_opus(input_path, opus_path, profile=_ringtone_profile()) return filename diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index b3fa99ed..0e334beb 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -574,6 +574,7 @@ import { countRelayMentions } from "../js/relayMentionCount.js"; import Utils from "../js/Utils"; import GlobalEmitter from "../js/GlobalEmitter"; import NotificationUtils from "../js/NotificationUtils"; +import NotificationSoundUtils from "../js/NotificationSoundUtils"; import LxmfUserIcon from "./LxmfUserIcon.vue"; import Toast from "./Toast.vue"; import ConfirmDialog from "./ConfirmDialog.vue"; @@ -869,6 +870,7 @@ export default { return count; }, onRingtoneUnlockGesture() { + NotificationSoundUtils.unlockAutoplay(); if (!this.ringtoneAutoplayBlocked) { return; } @@ -1330,7 +1332,7 @@ export default { "rrc.change": () => { this.updateRelayChatUnreadCount(); }, - "lxmf.delivery": (json) => { + "lxmf.delivery": async (json) => { if (this.config?.do_not_disturb_enabled) { return; } @@ -1338,14 +1340,18 @@ export default { return; } this.updateUnreadConversationsCount(); - if ( - !document.hasFocus() && + const isIncomingMessage = json.lxmf_message?.is_incoming === true && - (json.lxmf_message?.content || json.lxmf_message?.title) - ) { + (json.lxmf_message?.content || json.lxmf_message?.title); + let playedNotificationSound = false; + if (isIncomingMessage) { + playedNotificationSound = await NotificationSoundUtils.play(this.config); + } + if (!document.hasFocus() && isIncomingMessage) { NotificationUtils.showNewMessageNotification( json.remote_identity_name, - json.lxmf_message?.content + json.lxmf_message?.content, + playedNotificationSound ); } }, diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 6ed7d7c8..8d304630 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -6926,13 +6926,13 @@ export default { try { await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`); GlobalEmitter.emit("notifications-changed"); + if (GlobalState.unreadConversationsCount > 0) { + GlobalState.unreadConversationsCount -= 1; + } } catch (e) { // do nothing if failed to mark as read console.log(e); } - - // reload conversations - this.$emit("reload-conversations"); }, toggleSentMessageInfo: function (messageHash) { if (this.expandedMessageInfo === messageHash) { diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue index 2924159e..01da36fc 100644 --- a/meshchatx/src/frontend/components/messages/MessagesPage.vue +++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue @@ -41,6 +41,7 @@ @ingest-paper-message="openIngestPaperMessageModal" @load-more="loadMoreConversations" @load-more-announces="loadMoreAnnounces" + @announces-tab-activated="onAnnouncesTabActivated" @folder-click="onFolderClick" @create-folder="onCreateFolder" @rename-folder="onRenameFolder" @@ -93,7 +94,7 @@ @update:selected-peer="onPanePeerUpdate(pane.id, $event)" @update-peer-tracking="onUpdatePeerTracking" @close="onPaneClose(pane.id)" - @reload-conversations="getConversations" + @reload-conversations="requestConversationsRefresh" />
{ - return conversation.is_unread; - }).length; + this.syncUnreadCount(); }, paneLayoutSignature() { this.persistPanes(); @@ -552,7 +557,6 @@ export default { this.getConversations(); this.loadConversationPins(); this.getFolders(); - this.getLxmfDeliveryAnnounces(); // update info every few seconds this.reloadInterval = setInterval(() => { @@ -566,6 +570,16 @@ export default { } }, methods: { + syncUnreadCount() { + GlobalState.unreadConversationsCount = countUnreadConversations(this.conversations); + }, + onAnnouncesTabActivated() { + if (this.announcesLoaded) { + return; + } + this.announcesLoaded = true; + this.getLxmfDeliveryAnnounces(); + }, async onComposeNewMessage(destinationHash) { if (destinationHash == null) { if (this.selectedPeer) { @@ -634,7 +648,7 @@ export default { break; } case "lxmf.delivery": { - await this.getConversations(); + this.requestConversationsRefresh(); break; } case "lxmf_message_created": { @@ -684,6 +698,9 @@ export default { } }, async getLxmfDeliveryAnnounces(append = false) { + if (!append) { + this.announcesLoaded = true; + } try { if (!append) { if (this.announcesAbortController) { @@ -775,10 +792,31 @@ export default { }); const newConversations = response.data.conversations; + if (!append) { + const nextSignature = conversationListSignature(newConversations); + if (nextSignature === this.conversationListSignature) { + this.hasLoadedConversations = true; + this.hasMoreConversations = newConversations.length === this.pageSize; + return; + } + } + if (append) { this.conversations = [...this.conversations, ...newConversations]; + this.conversationListSignature = conversationListSignature(this.conversations); } else { - this.conversations = newConversations; + const nextSignature = conversationListSignature(newConversations); + if (nextSignature !== this.conversationListSignature) { + this.conversationListSignature = nextSignature; + if (this.conversations.length === 0) { + this.conversations = newConversations.slice(); + } else { + const structureChanged = syncConversationListInPlace(this.conversations, newConversations); + if (!structureChanged) { + this.conversations = this.conversations.slice(); + } + } + } } for (const conversation of newConversations) { diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue index 26867f29..550ed355 100644 --- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue +++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue @@ -911,6 +911,7 @@ export default { "ingest-paper-message", "load-more", "load-more-announces", + "announces-tab-activated", "folder-click", "create-folder", "rename-folder", @@ -1064,6 +1065,11 @@ export default { }, }, watch: { + tab(newTab, oldTab) { + if (newTab === "announces" && oldTab !== "announces") { + this.$emit("announces-tab-activated"); + } + }, foldersExpanded(newVal) { try { if (typeof localStorage !== "undefined") { diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue index 0bcc29b9..66a1227f 100644 --- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue +++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue @@ -50,21 +50,22 @@
- +
{ this.verifyActiveTabPage(tab); @@ -494,6 +517,7 @@ export default { const wasActive = this.tabs[index].id === this.activeTabId; this.tabs.splice(index, 1); + this.unmountTab(tabId); if (this.tabs.length === 0) { this.addTab(); @@ -503,6 +527,7 @@ export default { if (wasActive) { const neighbour = this.tabs[index] || this.tabs[index - 1] || this.tabs[0]; this.activeTabId = neighbour.id; + this.mountTab(neighbour.id); } this.syncRoute(); }, diff --git a/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue new file mode 100644 index 00000000..7335f6d0 --- /dev/null +++ b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index add3a1cd..218fd655 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -2311,6 +2311,13 @@ + +
@@ -2798,6 +2805,7 @@ import { DEFAULT_SETTINGS_TAB, normalizeSettingsTabId, SETTINGS_TABS } from "../ import { getAllSettingsSectionKeywords } from "../../js/registries/settingsSectionRegistry.js"; import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js"; import MicronWasmUpdateModal from "./MicronWasmUpdateModal.vue"; +import NotificationSoundSettings from "./NotificationSoundSettings.vue"; import PluginsSettingsSection from "./PluginsSettingsSection.vue"; export default { @@ -2811,6 +2819,7 @@ export default { StickerPacksManager, PluginsSettingsSection, MicronWasmUpdateModal, + NotificationSoundSettings, }, data() { return { diff --git a/meshchatx/src/frontend/js/NotificationSoundUtils.js b/meshchatx/src/frontend/js/NotificationSoundUtils.js new file mode 100644 index 00000000..473aca65 --- /dev/null +++ b/meshchatx/src/frontend/js/NotificationSoundUtils.js @@ -0,0 +1,123 @@ +class NotificationSoundUtils { + static _player = null; + static autoplayBlocked = false; + + static isSupported() { + return typeof window !== "undefined" && typeof Audio !== "undefined"; + } + + static shouldPlay(config) { + return Boolean(config?.notification_sound_enabled); + } + + static _normalizeVolume(volume) { + if (typeof volume !== "number" || Number.isNaN(volume)) { + return 1; + } + return Math.min(1, Math.max(0, volume)); + } + + static stop() { + if (!NotificationSoundUtils._player) { + return; + } + try { + NotificationSoundUtils._player.pause(); + NotificationSoundUtils._player.currentTime = 0; + } catch { + // ignore pause errors + } + NotificationSoundUtils._player = null; + } + + static unlockAutoplay() { + if (!NotificationSoundUtils.autoplayBlocked) { + return; + } + NotificationSoundUtils.autoplayBlocked = false; + } + + static async _fetchStatus() { + if (typeof window === "undefined" || !window.api) { + return null; + } + const response = await window.api.get("/api/v1/notification-sounds/status"); + return response?.data ?? null; + } + + static async play(config) { + if (!NotificationSoundUtils.isSupported()) { + return false; + } + if (!NotificationSoundUtils.shouldPlay(config)) { + return false; + } + if (NotificationSoundUtils.autoplayBlocked) { + return false; + } + + try { + const status = await NotificationSoundUtils._fetchStatus(); + if (!status?.enabled || !status?.has_sound || !status?.id) { + return false; + } + + NotificationSoundUtils.stop(); + + const player = new Audio(`/api/v1/notification-sounds/${status.id}/audio`); + player.loop = false; + player.volume = NotificationSoundUtils._normalizeVolume( + status.volume ?? config.notification_sound_volume / 100.0 + ); + + player.onended = () => { + if (NotificationSoundUtils._player === player) { + NotificationSoundUtils._player = null; + } + }; + + NotificationSoundUtils._player = player; + await player.play(); + return true; + } catch (error) { + if (error?.name === "NotAllowedError") { + NotificationSoundUtils.autoplayBlocked = true; + return false; + } + console.warn("Failed to play notification sound:", error); + return false; + } + } + + static async preview(soundId, volumePercent = 100) { + if (!NotificationSoundUtils.isSupported() || !soundId) { + return false; + } + + NotificationSoundUtils.stop(); + + try { + const player = new Audio(`/api/v1/notification-sounds/${soundId}/audio`); + player.loop = false; + player.volume = NotificationSoundUtils._normalizeVolume(volumePercent / 100.0); + player.onended = () => { + if (NotificationSoundUtils._player === player) { + NotificationSoundUtils._player = null; + } + }; + NotificationSoundUtils._player = player; + await player.play(); + NotificationSoundUtils.unlockAutoplay(); + return true; + } catch (error) { + if (error?.name === "NotAllowedError") { + NotificationSoundUtils.autoplayBlocked = true; + return false; + } + console.warn("Failed to preview notification sound:", error); + return false; + } + } +} + +export default NotificationSoundUtils; diff --git a/meshchatx/src/frontend/js/NotificationUtils.js b/meshchatx/src/frontend/js/NotificationUtils.js index c92d3705..8f50e883 100644 --- a/meshchatx/src/frontend/js/NotificationUtils.js +++ b/meshchatx/src/frontend/js/NotificationUtils.js @@ -68,11 +68,12 @@ class NotificationUtils { }); } - static showNewMessageNotification(from, content) { + static showNewMessageNotification(from, content, silent = false) { if (window.electron) { window.electron.showNotification( "New Message", - from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message." + from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message.", + silent ); return; } diff --git a/meshchatx/src/frontend/js/lxmfConversationListSync.js b/meshchatx/src/frontend/js/lxmfConversationListSync.js new file mode 100644 index 00000000..410be075 --- /dev/null +++ b/meshchatx/src/frontend/js/lxmfConversationListSync.js @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Build a compact signature for a conversation list page. + * Used to skip redundant sidebar refreshes when polling returns unchanged data. + * + * @param {Array<{ destination_hash?: string, updated_at?: string, is_unread?: boolean, failed_messages_count?: number, latest_message_created_at?: number | string | null }>} conversations + * @returns {string} + */ +export function conversationListSignature(conversations) { + if (!Array.isArray(conversations) || conversations.length === 0) { + return ""; + } + return conversations + .map((conversation) => { + const hash = conversation?.destination_hash || ""; + const updatedAt = conversation?.updated_at || ""; + const unread = conversation?.is_unread ? "1" : "0"; + const failed = conversation?.failed_messages_count ?? 0; + const latest = conversation?.latest_message_created_at ?? ""; + const preview = conversation?.latest_message_preview || ""; + return `${hash}\u241f${updatedAt}\u241f${unread}\u241f${failed}\u241f${latest}\u241f${preview}`; + }) + .join("\u241e"); +} + +/** + * Count unread conversations in a list. + * + * @param {Array<{ is_unread?: boolean }>} conversations + * @returns {number} + */ +export function countUnreadConversations(conversations) { + if (!Array.isArray(conversations)) { + return 0; + } + let count = 0; + for (const conversation of conversations) { + if (conversation?.is_unread) { + count += 1; + } + } + return count; +} + +/** + * Apply a refreshed conversation page in place, preserving row object identity when possible. + * + * @param {Array} existing + * @param {Array} incoming + * @returns {boolean} true when any visible list state changed + */ +export function syncConversationListInPlace(existing, incoming) { + if (!Array.isArray(existing) || !Array.isArray(incoming)) { + return false; + } + if (incoming.length === 0) { + if (existing.length === 0) { + return false; + } + existing.length = 0; + return true; + } + if (existing.length === 0) { + existing.push(...incoming); + return true; + } + + const existingByHash = new Map(existing.map((conversation) => [conversation.destination_hash, conversation])); + const nextRows = []; + for (const conversation of incoming) { + const hash = conversation?.destination_hash; + if (!hash) { + continue; + } + const previous = existingByHash.get(hash); + if (previous) { + Object.assign(previous, conversation); + nextRows.push(previous); + } else { + nextRows.push(conversation); + } + } + + const sameOrderAndRefs = + nextRows.length === existing.length && nextRows.every((row, index) => row === existing[index]); + if (sameOrderAndRefs) { + return false; + } + + existing.length = 0; + existing.push(...nextRows); + return true; +} diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js index aa110619..5892f30b 100644 --- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js +++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js @@ -252,6 +252,17 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = { "app.flood_max_stamp_cost", "app.flood_cooldown", ], + notificationSounds: [ + "app.notifications", + "app.notification_sound_settings", + "app.notification_sound_settings_description", + "app.enable_notification_sound", + "app.enable_notification_sound_description", + "app.notification_sound_volume", + "app.notification_sound_default", + "app.notification_sounds", + "app.notification_sound_upload", + ], propagation: [ "LXMF", "app.incoming_message_size", diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js index 889767ef..6ac89c78 100644 --- a/meshchatx/src/frontend/js/settings/settingsTabs.js +++ b/meshchatx/src/frontend/js/settings/settingsTabs.js @@ -14,7 +14,16 @@ export const SETTINGS_TABS = [ id: "messages", labelKey: "settings.tabs.messages", descriptionKey: "settings.tabs.messages_desc", - sections: ["strangerProtection", "messages", "propagation", "stickers", "gifs", "banishment", "telephony"], + sections: [ + "strangerProtection", + "messages", + "notificationSounds", + "propagation", + "stickers", + "gifs", + "banishment", + "telephony", + ], }, { id: "network", diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index d696f849..2829a525 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -356,6 +356,29 @@ "notifications_no_new": "Keine neuen Benachrichtigungen", "notifications_empty_history": "Kein Benachrichtigungsverlauf", "notifications_history_title": "Letzter Benachrichtigungsverlauf", + "notifications": "Benachrichtigungen", + "notification_sound_settings": "Benachrichtigungston für Nachrichten", + "notification_sound_settings_description": "Spielt einen benutzerdefinierten Ton ab, wenn Sie eine neue Nachricht erhalten, während MeshChat geöffnet ist. Laden Sie zuerst eine Audiodatei hoch und aktivieren Sie dann die Wiedergabe.", + "enable_notification_sound": "Benachrichtigungston aktivieren", + "enable_notification_sound_description": "Spielt Ihren gewählten Ton für eingehende Nachrichten auf dem Desktop ab (Windows, macOS und Linux).", + "notification_sound_volume": "Benachrichtigungslautstärke", + "notification_sound_default": "Standard-Benachrichtigungston", + "notification_sound_primary_default": "Primär hochgeladener Ton", + "notification_sounds": "Hochgeladene Töne", + "notification_sound_upload": "Ton hochladen", + "notification_sound_uploading": "Wird hochgeladen...", + "notification_sound_none_uploaded": "Noch kein Benachrichtigungston hochgeladen. Laden Sie eine Audiodatei hoch, um zu beginnen.", + "notification_sound_primary": "Primär", + "notification_sound_preview": "Ton anhören", + "notification_sound_set_primary": "Als primär festlegen", + "notification_sound_remove": "Ton entfernen", + "notification_sound_uploaded": "Benachrichtigungston hochgeladen", + "notification_sound_upload_failed": "Benachrichtigungston konnte nicht hochgeladen werden", + "notification_sound_deleted": "Benachrichtigungston gelöscht", + "notification_sound_delete_failed": "Benachrichtigungston konnte nicht gelöscht werden", + "notification_sound_primary_set": "Primärer Benachrichtigungston aktualisiert", + "notification_sound_primary_set_failed": "Primärer Benachrichtigungston konnte nicht festgelegt werden", + "notification_sound_preview_failed": "Benachrichtigungston konnte nicht abgespielt werden. Versuchen Sie es erneut, nachdem Sie mit der App interagiert haben.", "search_settings": "Einstellungen suchen...", "show_qr": "QR-Code anzeigen", "csp_settings": "Content-Security-Policy (CSP)", diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 33f648ef..a08b26c5 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -92,6 +92,29 @@ "notifications_no_new": "No new notifications", "notifications_empty_history": "No notification history", "notifications_history_title": "Recent notification history", + "notifications": "Notifications", + "notification_sound_settings": "Message Notification Sound", + "notification_sound_settings_description": "Play a custom sound when you receive a new message while MeshChat is open. Upload a sound file first, then enable playback.", + "enable_notification_sound": "Enable notification sound", + "enable_notification_sound_description": "Plays your chosen sound for incoming messages on desktop (Windows, macOS, and Linux).", + "notification_sound_volume": "Notification volume", + "notification_sound_default": "Default notification sound", + "notification_sound_primary_default": "Primary uploaded sound", + "notification_sounds": "Uploaded sounds", + "notification_sound_upload": "Upload sound", + "notification_sound_uploading": "Uploading...", + "notification_sound_none_uploaded": "No notification sound uploaded yet. Upload an audio file to get started.", + "notification_sound_primary": "Primary", + "notification_sound_preview": "Preview sound", + "notification_sound_set_primary": "Set primary", + "notification_sound_remove": "Remove sound", + "notification_sound_uploaded": "Notification sound uploaded", + "notification_sound_upload_failed": "Failed to upload notification sound", + "notification_sound_deleted": "Notification sound deleted", + "notification_sound_delete_failed": "Failed to delete notification sound", + "notification_sound_primary_set": "Primary notification sound updated", + "notification_sound_primary_set_failed": "Failed to set primary notification sound", + "notification_sound_preview_failed": "Could not play notification sound. Try again after interacting with the app.", "last_announced": "Last announced: {time}", "last_announced_never": "Last announced: Never", "display_name_placeholder": "Display Name", diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json index b672cbce..42a5dc4e 100644 --- a/meshchatx/src/frontend/locales/es.json +++ b/meshchatx/src/frontend/locales/es.json @@ -91,6 +91,29 @@ "notifications_no_new": "No hay nuevas notificaciones", "notifications_empty_history": "No hay historial de notificación", "notifications_history_title": "Historial de notificación reciente", + "notifications": "Notificaciones", + "notification_sound_settings": "Sonido de notificación de mensajes", + "notification_sound_settings_description": "Reproduce un sonido personalizado cuando recibes un mensaje nuevo mientras MeshChat está abierto. Sube primero un archivo de audio y luego activa la reproducción.", + "enable_notification_sound": "Activar sonido de notificación", + "enable_notification_sound_description": "Reproduce el sonido elegido para mensajes entrantes en el escritorio (Windows, macOS y Linux).", + "notification_sound_volume": "Volumen de notificación", + "notification_sound_default": "Sonido de notificación predeterminado", + "notification_sound_primary_default": "Sonido principal subido", + "notification_sounds": "Sonidos subidos", + "notification_sound_upload": "Subir sonido", + "notification_sound_uploading": "Subiendo...", + "notification_sound_none_uploaded": "Aún no hay sonido de notificación subido. Sube un archivo de audio para empezar.", + "notification_sound_primary": "Principal", + "notification_sound_preview": "Vista previa del sonido", + "notification_sound_set_primary": "Establecer como principal", + "notification_sound_remove": "Eliminar sonido", + "notification_sound_uploaded": "Sonido de notificación subido", + "notification_sound_upload_failed": "Error al subir el sonido de notificación", + "notification_sound_deleted": "Sonido de notificación eliminado", + "notification_sound_delete_failed": "Error al eliminar el sonido de notificación", + "notification_sound_primary_set": "Sonido de notificación principal actualizado", + "notification_sound_primary_set_failed": "Error al establecer el sonido principal", + "notification_sound_preview_failed": "No se pudo reproducir el sonido de notificación. Inténtalo de nuevo después de interactuar con la aplicación.", "last_announced": "Última anunciada: {time}", "last_announced_never": "Última anunciada: Nunca", "display_name_placeholder": "Nombre", diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json index 4424d62a..331c0c9b 100644 --- a/meshchatx/src/frontend/locales/fi.json +++ b/meshchatx/src/frontend/locales/fi.json @@ -92,6 +92,29 @@ "notifications_no_new": "Ei uusia ilmoituksia", "notifications_empty_history": "Ei ilmoitushistoriaa", "notifications_history_title": "Viimeaikainen ilmoitushistoria", + "notifications": "Ilmoitukset", + "notification_sound_settings": "Viestien ilmoitusääni", + "notification_sound_settings_description": "Toistaa mukautetun äänen, kun saat uuden viestin MeshChatin ollessa auki. Lataa ensin äänitiedosto ja ota toisto käyttöön.", + "enable_notification_sound": "Ota ilmoitusääni käyttöön", + "enable_notification_sound_description": "Toistaa valitsemasi äänen saapuville viesteille työpöydällä (Windows, macOS ja Linux).", + "notification_sound_volume": "Ilmoituksen äänenvoimakkuus", + "notification_sound_default": "Oletusilmoitusääni", + "notification_sound_primary_default": "Ensisijainen ladattu ääni", + "notification_sounds": "Ladatut äänet", + "notification_sound_upload": "Lataa ääni", + "notification_sound_uploading": "Ladataan...", + "notification_sound_none_uploaded": "Ilmoitusääntä ei ole vielä ladattu. Lataa äänitiedosto aloittaaksesi.", + "notification_sound_primary": "Ensisijainen", + "notification_sound_preview": "Esikuuntele ääni", + "notification_sound_set_primary": "Aseta ensisijaiseksi", + "notification_sound_remove": "Poista ääni", + "notification_sound_uploaded": "Ilmoitusääni ladattu", + "notification_sound_upload_failed": "Ilmoitusäänen lataus epäonnistui", + "notification_sound_deleted": "Ilmoitusääni poistettu", + "notification_sound_delete_failed": "Ilmoitusäänen poisto epäonnistui", + "notification_sound_primary_set": "Ensisijainen ilmoitusääni päivitetty", + "notification_sound_primary_set_failed": "Ensisijaisen ilmoitusäänen asetus epäonnistui", + "notification_sound_preview_failed": "Ilmoitusääntä ei voitu toistaa. Yritä uudelleen oltuasi vuorovaikutuksessa sovelluksen kanssa.", "last_announced": "Viimeksi kuulutettu: {time}", "last_announced_never": "Viimeksi kuulutettu: ei koskaan", "display_name_placeholder": "Näyttönimi", diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json index da1b6a5e..219d468b 100644 --- a/meshchatx/src/frontend/locales/fr.json +++ b/meshchatx/src/frontend/locales/fr.json @@ -91,6 +91,29 @@ "notifications_no_new": "Aucune nouvelle notification", "notifications_empty_history": "Aucun historique de notification", "notifications_history_title": "Historique de la notification récente", + "notifications": "Notifications", + "notification_sound_settings": "Son de notification de message", + "notification_sound_settings_description": "Joue un son personnalisé lorsque vous recevez un nouveau message pendant que MeshChat est ouvert. Téléchargez d'abord un fichier audio, puis activez la lecture.", + "enable_notification_sound": "Activer le son de notification", + "enable_notification_sound_description": "Joue le son choisi pour les messages entrants sur ordinateur (Windows, macOS et Linux).", + "notification_sound_volume": "Volume des notifications", + "notification_sound_default": "Son de notification par défaut", + "notification_sound_primary_default": "Son principal téléchargé", + "notification_sounds": "Sons téléchargés", + "notification_sound_upload": "Télécharger un son", + "notification_sound_uploading": "Téléchargement...", + "notification_sound_none_uploaded": "Aucun son de notification téléchargé. Téléchargez un fichier audio pour commencer.", + "notification_sound_primary": "Principal", + "notification_sound_preview": "Écouter le son", + "notification_sound_set_primary": "Définir comme principal", + "notification_sound_remove": "Supprimer le son", + "notification_sound_uploaded": "Son de notification téléchargé", + "notification_sound_upload_failed": "Échec du téléchargement du son de notification", + "notification_sound_deleted": "Son de notification supprimé", + "notification_sound_delete_failed": "Échec de la suppression du son de notification", + "notification_sound_primary_set": "Son de notification principal mis à jour", + "notification_sound_primary_set_failed": "Échec de la définition du son principal", + "notification_sound_preview_failed": "Impossible de lire le son de notification. Réessayez après avoir interagi avec l'application.", "last_announced": "Dernière annonce : {time}", "last_announced_never": "Dernière annonce: Jamais", "display_name_placeholder": "Afficher le nom", diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index 29b55c70..e9bd46b7 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -91,6 +91,29 @@ "notifications_no_new": "Nessuna nuova notifica", "notifications_empty_history": "Nessuna cronologia notifiche", "notifications_history_title": "Cronologia notifiche recenti", + "notifications": "Notifiche", + "notification_sound_settings": "Suono di notifica messaggi", + "notification_sound_settings_description": "Riproduce un suono personalizzato quando ricevi un nuovo messaggio mentre MeshChat è aperto. Carica prima un file audio, poi abilita la riproduzione.", + "enable_notification_sound": "Abilita suono di notifica", + "enable_notification_sound_description": "Riproduce il suono scelto per i messaggi in arrivo su desktop (Windows, macOS e Linux).", + "notification_sound_volume": "Volume notifiche", + "notification_sound_default": "Suono di notifica predefinito", + "notification_sound_primary_default": "Suono principale caricato", + "notification_sounds": "Suoni caricati", + "notification_sound_upload": "Carica suono", + "notification_sound_uploading": "Caricamento...", + "notification_sound_none_uploaded": "Nessun suono di notifica caricato. Carica un file audio per iniziare.", + "notification_sound_primary": "Principale", + "notification_sound_preview": "Anteprima suono", + "notification_sound_set_primary": "Imposta come principale", + "notification_sound_remove": "Rimuovi suono", + "notification_sound_uploaded": "Suono di notifica caricato", + "notification_sound_upload_failed": "Caricamento del suono di notifica non riuscito", + "notification_sound_deleted": "Suono di notifica eliminato", + "notification_sound_delete_failed": "Eliminazione del suono di notifica non riuscita", + "notification_sound_primary_set": "Suono di notifica principale aggiornato", + "notification_sound_primary_set_failed": "Impostazione del suono principale non riuscita", + "notification_sound_preview_failed": "Impossibile riprodurre il suono di notifica. Riprova dopo aver interagito con l'app.", "last_announced": "Ultimo annuncio: {time}", "last_announced_never": "Ultimo annuncio: Mai", "display_name_placeholder": "Nome Visualizzato", diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json index a8a3c0ca..57c37600 100644 --- a/meshchatx/src/frontend/locales/nl.json +++ b/meshchatx/src/frontend/locales/nl.json @@ -91,6 +91,29 @@ "notifications_no_new": "Geen nieuwe meldingen", "notifications_empty_history": "Geen meldingsgeschiedenis", "notifications_history_title": "Recente meldingsgeschiedenis", + "notifications": "Meldingen", + "notification_sound_settings": "Meldingsgeluid voor berichten", + "notification_sound_settings_description": "Speelt een aangepast geluid af wanneer je een nieuw bericht ontvangt terwijl MeshChat open is. Upload eerst een audiobestand en schakel daarna afspelen in.", + "enable_notification_sound": "Meldingsgeluid inschakelen", + "enable_notification_sound_description": "Speelt het gekozen geluid af voor inkomende berichten op desktop (Windows, macOS en Linux).", + "notification_sound_volume": "Meldingsvolume", + "notification_sound_default": "Standaard meldingsgeluid", + "notification_sound_primary_default": "Primair geüpload geluid", + "notification_sounds": "Geüploade geluiden", + "notification_sound_upload": "Geluid uploaden", + "notification_sound_uploading": "Uploaden...", + "notification_sound_none_uploaded": "Nog geen meldingsgeluid geüpload. Upload een audiobestand om te beginnen.", + "notification_sound_primary": "Primair", + "notification_sound_preview": "Geluid beluisteren", + "notification_sound_set_primary": "Instellen als primair", + "notification_sound_remove": "Geluid verwijderen", + "notification_sound_uploaded": "Meldingsgeluid geüpload", + "notification_sound_upload_failed": "Uploaden van meldingsgeluid mislukt", + "notification_sound_deleted": "Meldingsgeluid verwijderd", + "notification_sound_delete_failed": "Verwijderen van meldingsgeluid mislukt", + "notification_sound_primary_set": "Primair meldingsgeluid bijgewerkt", + "notification_sound_primary_set_failed": "Instellen van primair meldingsgeluid mislukt", + "notification_sound_preview_failed": "Meldingsgeluid kon niet worden afgespeeld. Probeer het opnieuw nadat je met de app hebt geïnteracteerd.", "last_announced": "Laatst aangekondigd: {time}", "last_announced_never": "Laatst aangekondigd: nooit", "display_name_placeholder": "Naam tonen", diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index c7d7a3cb..6bf1a0bb 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -356,6 +356,29 @@ "notifications_no_new": "Нет новых уведомлений", "notifications_empty_history": "Нет истории уведомлений", "notifications_history_title": "Недавняя история уведомлений", + "notifications": "Уведомления", + "notification_sound_settings": "Звук уведомления о сообщении", + "notification_sound_settings_description": "Воспроизводит пользовательский звук при получении нового сообщения, пока MeshChat открыт. Сначала загрузите аудиофайл, затем включите воспроизведение.", + "enable_notification_sound": "Включить звук уведомления", + "enable_notification_sound_description": "Воспроизводит выбранный звук для входящих сообщений на настольных системах (Windows, macOS и Linux).", + "notification_sound_volume": "Громкость уведомлений", + "notification_sound_default": "Звук уведомления по умолчанию", + "notification_sound_primary_default": "Основной загруженный звук", + "notification_sounds": "Загруженные звуки", + "notification_sound_upload": "Загрузить звук", + "notification_sound_uploading": "Загрузка...", + "notification_sound_none_uploaded": "Звук уведомления ещё не загружен. Загрузите аудиофайл, чтобы начать.", + "notification_sound_primary": "Основной", + "notification_sound_preview": "Прослушать звук", + "notification_sound_set_primary": "Сделать основным", + "notification_sound_remove": "Удалить звук", + "notification_sound_uploaded": "Звук уведомления загружен", + "notification_sound_upload_failed": "Не удалось загрузить звук уведомления", + "notification_sound_deleted": "Звук уведомления удалён", + "notification_sound_delete_failed": "Не удалось удалить звук уведомления", + "notification_sound_primary_set": "Основной звук уведомления обновлён", + "notification_sound_primary_set_failed": "Не удалось установить основной звук", + "notification_sound_preview_failed": "Не удалось воспроизвести звук уведомления. Попробуйте снова после взаимодействия с приложением.", "search_settings": "Поиск настроек...", "show_qr": "Показать QR-код", "csp_settings": "Политика безопасности контента (CSP)", diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json index a232237c..5e2988cd 100644 --- a/meshchatx/src/frontend/locales/zh.json +++ b/meshchatx/src/frontend/locales/zh.json @@ -91,6 +91,29 @@ "notifications_no_new": "没有新通知", "notifications_empty_history": "无通知历史", "notifications_history_title": "最近通知历史", + "notifications": "通知", + "notification_sound_settings": "消息通知声音", + "notification_sound_settings_description": "在 MeshChat 打开时收到新消息播放自定义声音。请先上传音频文件,然后启用播放。", + "enable_notification_sound": "启用通知声音", + "enable_notification_sound_description": "在桌面端(Windows、macOS 和 Linux)为收到的消息播放所选声音。", + "notification_sound_volume": "通知音量", + "notification_sound_default": "默认通知声音", + "notification_sound_primary_default": "主要上传声音", + "notification_sounds": "已上传声音", + "notification_sound_upload": "上传声音", + "notification_sound_uploading": "上传中...", + "notification_sound_none_uploaded": "尚未上传通知声音。请上传音频文件以开始使用。", + "notification_sound_primary": "主要", + "notification_sound_preview": "预览声音", + "notification_sound_set_primary": "设为主要", + "notification_sound_remove": "删除声音", + "notification_sound_uploaded": "通知声音已上传", + "notification_sound_upload_failed": "通知声音上传失败", + "notification_sound_deleted": "通知声音已删除", + "notification_sound_delete_failed": "通知声音删除失败", + "notification_sound_primary_set": "主要通知声音已更新", + "notification_sound_primary_set_failed": "设置主要通知声音失败", + "notification_sound_preview_failed": "无法播放通知声音。请与应用程序交互后重试。", "last_announced": "上次广播:{time}", "last_announced_never": "上次广播:从未", "display_name_placeholder": "显示名称", diff --git a/tests/backend/test_config_manager.py b/tests/backend/test_config_manager.py index 07156ec4..a5e39805 100644 --- a/tests/backend/test_config_manager.py +++ b/tests/backend/test_config_manager.py @@ -125,6 +125,10 @@ def test_all_telephony_settings_persist(db): config.ringtone_volume.set(75) config.ringtone_preferred_id.set(3) + config.notification_sound_enabled.set(True) + config.notification_sound_preferred_id.set(2) + config.notification_sound_volume.set(55) + # Desktop / misc config.desktop_open_calls_in_separate_window.set(True) @@ -159,6 +163,11 @@ def test_all_telephony_settings_persist(db): assert config2.ringtone_volume.get() == 75 assert config2.ringtone_preferred_id.get() == 3 + # Notification sound + assert config2.notification_sound_enabled.get() is True + assert config2.notification_sound_preferred_id.get() == 2 + assert config2.notification_sound_volume.get() == 55 + # Desktop assert config2.desktop_open_calls_in_separate_window.get() is True diff --git a/tests/backend/test_notification_sounds.py b/tests/backend/test_notification_sounds.py new file mode 100644 index 00000000..4c7f4358 --- /dev/null +++ b/tests/backend/test_notification_sounds.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: 0BSD + +import os +import tempfile +from unittest.mock import MagicMock + +import pytest + +from meshchatx.src.backend.config_manager import ConfigManager +from meshchatx.src.backend.database import Database +from meshchatx.src.backend.ringtone_manager import RingtoneManager + + +@pytest.fixture +def db(): + fd, path = tempfile.mkstemp() + os.close(fd) + database = Database(path) + database.initialize() + yield database + database.close() + if os.path.exists(path): + os.remove(path) + + +def test_notification_sound_config_defaults(db): + config = ConfigManager(db) + assert config.notification_sound_enabled.get() is False + assert config.notification_sound_preferred_id.get() == 0 + assert config.notification_sound_volume.get() == 100 + + +def test_notification_sound_config_persists(db): + config = ConfigManager(db) + config.notification_sound_enabled.set(True) + config.notification_sound_preferred_id.set(4) + config.notification_sound_volume.set(60) + + config2 = ConfigManager(db) + assert config2.notification_sound_enabled.get() is True + assert config2.notification_sound_preferred_id.get() == 4 + assert config2.notification_sound_volume.get() == 60 + + +def test_notification_sounds_dao_crud(db): + sound_id = db.notification_sounds.add( + filename="alert.mp3", + storage_filename="notification_abcd.opus", + display_name="Alert", + ) + assert sound_id > 0 + + row = db.notification_sounds.get_by_id(sound_id) + assert row["filename"] == "alert.mp3" + assert row["display_name"] == "Alert" + assert row["is_primary"] == 1 + + all_sounds = db.notification_sounds.get_all() + assert len(all_sounds) == 1 + + primary = db.notification_sounds.get_primary() + assert primary["id"] == sound_id + + db.notification_sounds.update(sound_id, display_name="Updated Alert") + updated = db.notification_sounds.get_by_id(sound_id) + assert updated["display_name"] == "Updated Alert" + + db.notification_sounds.delete(sound_id) + assert db.notification_sounds.get_by_id(sound_id) is None + + +def test_notification_sound_manager_uses_separate_storage_dir(tmp_path): + config = MagicMock() + manager = RingtoneManager( + config, + str(tmp_path), + asset_subdir="notification_sounds", + filename_prefix="notification", + ) + assert manager.storage_dir == os.path.join(str(tmp_path), "notification_sounds") + assert os.path.isdir(manager.storage_dir) diff --git a/tests/backend/test_telemetry_integration.py b/tests/backend/test_telemetry_integration.py index 8f7f8ed1..2be5d211 100644 --- a/tests/backend/test_telemetry_integration.py +++ b/tests/backend/test_telemetry_integration.py @@ -164,6 +164,5 @@ async def test_telemetry_request_no_location_does_not_call_handler(mock_app): mock_app.handle_telemetry_request.assert_not_called() - # We can't easily test the web endpoint here without more setup, # but we can test the logic it calls if it was refactored into a method. diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py index 501e0217..34b0f9f1 100644 --- a/tests/backend/test_translator_handler_extended.py +++ b/tests/backend/test_translator_handler_extended.py @@ -108,7 +108,6 @@ def test_translate_argos_cli(mock_run): ) assert result["translated_text"] == "Hola" - # _detect_language is private diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index 01d3af81..03c02f4f 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -122,7 +122,7 @@ describe("ConversationViewer.vue", () => { expect(wrapper.emitted("reload-conversations")).toBeFalsy(); }); - it("markConversationAsRead marks read and reloads once when conversation is unread", async () => { + it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => { const wrapper = mountConversationViewer(); await flushPromises(); axiosMock.post.mockClear(); @@ -135,7 +135,7 @@ describe("ConversationViewer.vue", () => { expect(conversation.is_unread).toBe(false); const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); expect(markCalls).toHaveLength(1); - expect(wrapper.emitted("reload-conversations")).toHaveLength(1); + expect(wrapper.emitted("reload-conversations")).toBeFalsy(); expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed"); }); diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js index bb91eaf2..4a51f4d4 100644 --- a/tests/frontend/MessagesPage.test.js +++ b/tests/frontend/MessagesPage.test.js @@ -1,4 +1,4 @@ -import { mount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import MessagesPage from "@/components/messages/MessagesPage.vue"; import GlobalEmitter from "@/js/GlobalEmitter"; @@ -69,6 +69,16 @@ describe("MessagesPage.vue", () => { expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object)); }); + it("does not fetch lxmf delivery announces until the announces tab is opened", async () => { + mountMessagesPage(); + await flushPromises(); + + const announceCalls = axiosMock.get.mock.calls.filter( + (call) => call[0] === "/api/v1/announces" && call[1]?.params?.aspect === "lxmf.delivery" + ); + expect(announceCalls).toHaveLength(0); + }); + it("debounces conversation search and sends search param to conversations API", async () => { vi.useFakeTimers(); axiosMock.isCancel = vi.fn(() => false); diff --git a/tests/frontend/MountLoadPerformance.test.js b/tests/frontend/MountLoadPerformance.test.js new file mode 100644 index 00000000..f0ece9df --- /dev/null +++ b/tests/frontend/MountLoadPerformance.test.js @@ -0,0 +1,234 @@ +import { mount, flushPromises } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import MessagesPage from "@/components/messages/MessagesPage.vue"; +import NomadNetworkBrowser from "@/components/nomadnetwork/NomadNetworkBrowser.vue"; +import { conversationListSignature, syncConversationListInPlace } from "@/js/lxmfConversationListSync"; + +const MAX_MESSAGES_MOUNT_MS = 2500; +const MAX_NOMAD_BROWSER_RESTORE_MS = 1500; + +vi.mock("@/js/GlobalEmitter", () => ({ + default: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, +})); + +vi.mock("@/js/WebSocketConnection", () => ({ + default: { on: vi.fn(), off: vi.fn() }, +})); + +vi.mock("@/components/nomadnetwork/NomadNetworkPage.vue", () => ({ + default: { + name: "NomadNetworkPage", + template: '
', + props: { + destinationHash: { type: String, default: "" }, + initialPath: { type: String, default: null }, + embedded: { type: Boolean, default: false }, + tabsEnabled: { type: Boolean, default: false }, + isActive: { type: Boolean, default: true }, + }, + emits: ["navigate", "open-node", "close-tab"], + }, +})); + +const MaterialDesignIconStub = { + name: "MaterialDesignIcon", + template: '
', + props: ["iconName"], +}; + +describe("Mount load performance regressions", () => { + describe("MessagesPage mount", () => { + let axiosMock; + + beforeEach(() => { + localStorage.clear(); + axiosMock = { + get: vi.fn(), + post: vi.fn(), + isCancel: vi.fn(() => false), + }; + window.api = axiosMock; + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } }); + } + if (url === "/api/v1/lxmf/conversations") { + return Promise.resolve({ data: { conversations: [] } }); + } + if (url === "/api/v1/lxmf/conversation-pins") { + return Promise.resolve({ data: { peer_hashes: [] } }); + } + if (url === "/api/v1/lxmf/folders") { + return Promise.resolve({ data: { folders: [] } }); + } + if (url === "/api/v1/announces") { + return Promise.resolve({ data: { announces: [] } }); + } + return Promise.resolve({ data: {} }); + }); + }); + + afterEach(() => { + delete window.api; + }); + + const mountMessagesPage = () => + mount(MessagesPage, { + props: { destinationHash: "" }, + global: { + mocks: { + $t: (key) => key, + $route: { query: {} }, + $router: { replace: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: MaterialDesignIconStub, + LoadingSpinner: true, + MessagesSidebar: { + template: '', + props: ["conversations", "selectedDestinationHash"], + }, + ConversationViewer: { + template: '
', + props: ["selectedPeer", "myLxmfAddressHash"], + }, + Modal: true, + }, + }, + }); + + it("does not fetch lxmf delivery announces on initial mount", async () => { + const wrapper = mountMessagesPage(); + await flushPromises(); + + const announceCalls = axiosMock.get.mock.calls.filter( + (call) => call[0] === "/api/v1/announces" && call[1]?.params?.aspect === "lxmf.delivery" + ); + expect(announceCalls).toHaveLength(0); + expect(wrapper.vm.announcesLoaded).toBe(false); + }); + + it("mounts within the messages page budget", async () => { + const start = performance.now(); + mountMessagesPage(); + await flushPromises(); + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(MAX_MESSAGES_MOUNT_MS); + }); + + it("skips redundant conversation list replacement when polling returns the same signature", async () => { + const conversations = Array.from({ length: 250 }, (_, index) => ({ + destination_hash: index.toString(16).padStart(32, "0"), + display_name: `Peer ${index}`, + updated_at: "2026-01-01T00:00:00Z", + is_unread: index % 7 === 0, + failed_messages_count: 0, + latest_message_created_at: index, + latest_message_preview: `Preview ${index}`, + })); + const signature = conversationListSignature(conversations); + const existing = conversations.map((conversation) => ({ ...conversation })); + const refsBefore = existing.map((conversation) => conversation); + + syncConversationListInPlace( + existing, + conversations.map((conversation) => ({ ...conversation })) + ); + + expect(conversationListSignature(existing)).toBe(signature); + expect(existing.every((conversation, index) => conversation === refsBefore[index])).toBe(true); + }); + }); + + describe("NomadNetworkBrowser restore", () => { + beforeEach(() => { + localStorage.clear(); + }); + + const mountBrowser = () => + mount(NomadNetworkBrowser, { + global: { + mocks: { + $t: (key) => key, + $route: { name: "nomadnetwork", params: {}, query: {} }, + $router: { replace: vi.fn(() => Promise.resolve()) }, + }, + stubs: { + MaterialDesignIcon: MaterialDesignIconStub, + NomadBrowserContextMenu: true, + }, + }, + }); + + it("only mounts the active tab page when multiple tabs are restored", async () => { + localStorage.setItem( + "meshchatx.nomadnet.tabs", + JSON.stringify({ + tabs: [ + { destinationHash: "a".repeat(32), path: null, title: "Alpha" }, + { destinationHash: "b".repeat(32), path: null, title: "Bravo" }, + { destinationHash: "c".repeat(32), path: null, title: "Charlie" }, + ], + activeIndex: 1, + }) + ); + + const wrapper = mountBrowser(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.tabs).toHaveLength(3); + expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1); + expect(wrapper.vm.isTabMounted(wrapper.vm.activeTabId)).toBe(true); + }); + + it("mounts inactive tabs lazily when they are selected", async () => { + localStorage.setItem( + "meshchatx.nomadnet.tabs", + JSON.stringify({ + tabs: [ + { destinationHash: "a".repeat(32), path: null, title: "Alpha" }, + { destinationHash: "b".repeat(32), path: null, title: "Bravo" }, + ], + activeIndex: 0, + }) + ); + + const wrapper = mountBrowser(); + await wrapper.vm.$nextTick(); + const secondTabId = wrapper.vm.tabs[1].id; + + wrapper.vm.selectTab(secondTabId); + await wrapper.vm.$nextTick(); + + expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(2); + expect(wrapper.vm.isTabMounted(secondTabId)).toBe(true); + }); + + it("restores multiple tabs within the browser mount budget", async () => { + localStorage.setItem( + "meshchatx.nomadnet.tabs", + JSON.stringify({ + tabs: Array.from({ length: 8 }, (_, index) => ({ + destinationHash: `${index}`.padStart(32, "a"), + path: null, + title: `Tab ${index}`, + })), + activeIndex: 3, + }) + ); + + const start = performance.now(); + const wrapper = mountBrowser(); + await wrapper.vm.$nextTick(); + const elapsed = performance.now() - start; + + expect(wrapper.vm.tabs).toHaveLength(8); + expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1); + expect(elapsed).toBeLessThan(MAX_NOMAD_BROWSER_RESTORE_MS); + }); + }); +}); diff --git a/tests/frontend/NomadNetworkBrowser.test.js b/tests/frontend/NomadNetworkBrowser.test.js index f43290aa..bb868aba 100644 --- a/tests/frontend/NomadNetworkBrowser.test.js +++ b/tests/frontend/NomadNetworkBrowser.test.js @@ -223,7 +223,27 @@ describe("NomadNetworkBrowser.vue", () => { expect(wrapper.vm.activeTab.destinationHash).toBe("b".repeat(32)); }); - it("renders one embedded NomadNetworkPage per tab", async () => { + it("only mounts the active tab page when multiple tabs are restored", async () => { + localStorage.setItem( + "meshchatx.nomadnet.tabs", + JSON.stringify({ + tabs: [ + { destinationHash: "a".repeat(32), path: null, title: "Alpha" }, + { destinationHash: "b".repeat(32), path: null, title: "Bravo" }, + ], + activeIndex: 1, + }) + ); + + const wrapper = mountBrowser({}, { params: { destinationHash: "b".repeat(32) }, query: {} }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.tabs).toHaveLength(2); + expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1); + expect(wrapper.vm.activeTab.destinationHash).toBe("b".repeat(32)); + }); + + it("renders one embedded NomadNetworkPage per activated tab", async () => { const wrapper = mountBrowser(); wrapper.vm.addTab("f".repeat(32)); await wrapper.vm.$nextTick(); diff --git a/tests/frontend/NotificationSoundUtils.test.js b/tests/frontend/NotificationSoundUtils.test.js new file mode 100644 index 00000000..39a4da9e --- /dev/null +++ b/tests/frontend/NotificationSoundUtils.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import NotificationSoundUtils from "../../meshchatx/src/frontend/js/NotificationSoundUtils"; + +describe("NotificationSoundUtils", () => { + let audioInstances; + let originalAudio; + + beforeEach(() => { + audioInstances = []; + originalAudio = globalThis.Audio; + globalThis.Audio = vi.fn(function (src) { + const player = { + src, + loop: false, + volume: 1, + currentTime: 0, + onended: null, + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + }; + audioInstances.push(player); + return player; + }); + globalThis.window = globalThis.window || {}; + globalThis.window.api = { + get: vi.fn(), + }; + NotificationSoundUtils.stop(); + NotificationSoundUtils.autoplayBlocked = false; + }); + + afterEach(() => { + globalThis.Audio = originalAudio; + vi.restoreAllMocks(); + }); + + it("shouldPlay returns false when disabled", () => { + expect(NotificationSoundUtils.shouldPlay({ notification_sound_enabled: false })).toBe(false); + expect(NotificationSoundUtils.shouldPlay({ notification_sound_enabled: true })).toBe(true); + expect(NotificationSoundUtils.shouldPlay(null)).toBe(false); + }); + + it("play skips when disabled", async () => { + const result = await NotificationSoundUtils.play({ notification_sound_enabled: false }); + expect(result).toBe(false); + expect(globalThis.window.api.get).not.toHaveBeenCalled(); + }); + + it("play skips when status has no sound", async () => { + globalThis.window.api.get.mockResolvedValue({ + data: { enabled: true, has_sound: false, id: null }, + }); + const result = await NotificationSoundUtils.play({ notification_sound_enabled: true }); + expect(result).toBe(false); + expect(globalThis.Audio).not.toHaveBeenCalled(); + }); + + it("play creates audio when configured", async () => { + globalThis.window.api.get.mockResolvedValue({ + data: { enabled: true, has_sound: true, id: 7, volume: 0.5 }, + }); + const result = await NotificationSoundUtils.play({ notification_sound_enabled: true }); + expect(result).toBe(true); + expect(globalThis.window.api.get).toHaveBeenCalledWith("/api/v1/notification-sounds/status"); + expect(globalThis.Audio).toHaveBeenCalledWith("/api/v1/notification-sounds/7/audio"); + expect(audioInstances[0].volume).toBe(0.5); + expect(audioInstances[0].loop).toBe(false); + expect(audioInstances[0].play).toHaveBeenCalled(); + }); + + it("play sets autoplayBlocked on NotAllowedError", async () => { + globalThis.window.api.get.mockResolvedValue({ + data: { enabled: true, has_sound: true, id: 2, volume: 1 }, + }); + globalThis.Audio = vi.fn(function () { + const player = { + loop: false, + volume: 1, + onended: null, + play: vi.fn().mockRejectedValue(Object.assign(new Error("blocked"), { name: "NotAllowedError" })), + pause: vi.fn(), + currentTime: 0, + }; + audioInstances.push(player); + return player; + }); + + const result = await NotificationSoundUtils.play({ notification_sound_enabled: true }); + expect(result).toBe(false); + expect(NotificationSoundUtils.autoplayBlocked).toBe(true); + }); + + it("play returns false while autoplayBlocked", async () => { + NotificationSoundUtils.autoplayBlocked = true; + const result = await NotificationSoundUtils.play({ notification_sound_enabled: true }); + expect(result).toBe(false); + expect(globalThis.window.api.get).not.toHaveBeenCalled(); + }); + + it("unlockAutoplay clears blocked flag", () => { + NotificationSoundUtils.autoplayBlocked = true; + NotificationSoundUtils.unlockAutoplay(); + expect(NotificationSoundUtils.autoplayBlocked).toBe(false); + }); + + it("stop pauses active player", async () => { + globalThis.window.api.get.mockResolvedValue({ + data: { enabled: true, has_sound: true, id: 1, volume: 1 }, + }); + await NotificationSoundUtils.play({ notification_sound_enabled: true }); + NotificationSoundUtils.stop(); + expect(audioInstances[0].pause).toHaveBeenCalled(); + }); + + it("preview plays selected sound", async () => { + const result = await NotificationSoundUtils.preview(9, 80); + expect(result).toBe(true); + expect(globalThis.Audio).toHaveBeenCalledWith("/api/v1/notification-sounds/9/audio"); + expect(audioInstances[0].volume).toBeCloseTo(0.8); + }); +}); diff --git a/tests/frontend/NotificationUtils.test.js b/tests/frontend/NotificationUtils.test.js index 1da598fb..bdfc742b 100644 --- a/tests/frontend/NotificationUtils.test.js +++ b/tests/frontend/NotificationUtils.test.js @@ -36,7 +36,12 @@ describe("NotificationUtils", () => { it("showNewMessageNotification delegates to electron", () => { NotificationUtils.showNewMessageNotification("Alice", "hello"); - expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello"); + expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", false); + }); + + it("showNewMessageNotification passes silent flag to electron", () => { + NotificationUtils.showNewMessageNotification("Alice", "hello", true); + expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", true); }); it("showIncomingCallNotification delegates to electron", () => { diff --git a/tests/frontend/lxmfConversationListSync.test.js b/tests/frontend/lxmfConversationListSync.test.js new file mode 100644 index 00000000..c93b3ef0 --- /dev/null +++ b/tests/frontend/lxmfConversationListSync.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { + conversationListSignature, + countUnreadConversations, + syncConversationListInPlace, +} from "../../meshchatx/src/frontend/js/lxmfConversationListSync.js"; + +describe("lxmfConversationListSync", () => { + it("builds a stable signature for conversation rows", () => { + const conversations = [ + { + destination_hash: "a".repeat(32), + updated_at: "2026-01-01T00:00:00Z", + is_unread: true, + failed_messages_count: 0, + latest_message_created_at: 1, + latest_message_preview: "hello", + }, + ]; + expect(conversationListSignature(conversations)).toBe(conversationListSignature(conversations.slice())); + }); + + it("counts unread conversations", () => { + expect(countUnreadConversations([{ is_unread: true }, { is_unread: false }, { is_unread: true }])).toBe(2); + }); + + it("syncs updated rows in place while preserving object identity", () => { + const existing = [ + { destination_hash: "a".repeat(32), display_name: "Alpha", is_unread: true }, + { destination_hash: "b".repeat(32), display_name: "Bravo", is_unread: false }, + ]; + const alphaRef = existing[0]; + const incoming = [ + { destination_hash: "a".repeat(32), display_name: "Alpha", is_unread: false }, + { destination_hash: "b".repeat(32), display_name: "Bravo", is_unread: false }, + ]; + + const changed = syncConversationListInPlace(existing, incoming); + + expect(changed).toBe(false); + expect(existing[0]).toBe(alphaRef); + expect(existing[0].is_unread).toBe(false); + expect(existing).toHaveLength(2); + }); + + it("reorders and appends rows when the server order changes", () => { + const existing = [ + { destination_hash: "a".repeat(32), display_name: "Alpha" }, + { destination_hash: "b".repeat(32), display_name: "Bravo" }, + ]; + const incoming = [ + { destination_hash: "c".repeat(32), display_name: "Charlie" }, + { destination_hash: "a".repeat(32), display_name: "Alpha" }, + ]; + + const changed = syncConversationListInPlace(existing, incoming); + + expect(changed).toBe(true); + expect(existing.map((row) => row.destination_hash)).toEqual(["c".repeat(32), "a".repeat(32)]); + expect(existing[1].display_name).toBe("Alpha"); + }); +}); diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js index 94617cba..55e5d4d5 100644 --- a/tests/frontend/settingsTabs.test.js +++ b/tests/frontend/settingsTabs.test.js @@ -39,6 +39,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [ "infrastructure", "csp", "messages", + "notificationSounds", "propagation", "shortcuts", ];