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" />
{{ $t("app.notification_sound_settings_description") }}
++ {{ $t("app.notification_sound_none_uploaded") }} +
+ +