feat(notification-sounds): implement notification sound management and settings and faster conversations/nomadnet browser loading

This commit is contained in:
Ivan 2026-07-08 09:25:00 -05:00
parent 89477ff424
commit 6008b2205c
No known key found for this signature in database
40 changed files with 1730 additions and 46 deletions

View file

@ -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(),

View file

@ -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,

View file

@ -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)

View file

@ -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,),
)

View file

@ -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
)
""")

View file

@ -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(

View file

@ -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

View file

@ -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
);
}
},

View file

@ -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) {

View file

@ -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"
/>
<div
v-if="!pane.peer"
@ -331,6 +332,11 @@ import {
startCameraStream,
} from "../../js/qrScannerUtils";
import { lxmfConversationListPreview } from "../../js/lxmfConversationPreview";
import {
conversationListSignature,
countUnreadConversations,
syncConversationListInPlace,
} from "../../js/lxmfConversationListSync";
import {
loadMessagePanes,
saveMessagePanes,
@ -381,6 +387,8 @@ export default {
threePaneViewportListener: null,
conversations: [],
conversationListSignature: "",
announcesLoaded: false,
folders: [],
selectedFolderId: null,
pageSize: 50,
@ -492,10 +500,7 @@ export default {
saveFeatureSidebarCollapsed("messages", collapsed);
},
conversations() {
// update global state
GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
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) {

View file

@ -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") {

View file

@ -50,21 +50,22 @@
</div>
<div class="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
<NomadNetworkPage
v-for="tab in tabs"
v-show="tab.id === activeTabId"
:key="tab.id"
:ref="(el) => setPageRef(tab.id, el)"
class="absolute inset-0 flex min-h-0 min-w-0"
embedded
:tabs-enabled="tabsEnabled"
:is-active="tab.id === activeTabId"
:destination-hash="tab.destinationHash"
:initial-path="tab.initialPath"
@navigate="onTabNavigate(tab.id, $event)"
@open-node="onOpenNode"
@close-tab="closeTab(tab.id)"
/>
<template v-for="tab in tabs" :key="tab.id">
<NomadNetworkPage
v-if="isTabMounted(tab.id)"
v-show="tab.id === activeTabId"
:ref="(el) => setPageRef(tab.id, el)"
class="absolute inset-0 flex min-h-0 min-w-0"
embedded
:tabs-enabled="tabsEnabled"
:is-active="tab.id === activeTabId"
:destination-hash="tab.destinationHash"
:initial-path="tab.initialPath"
@navigate="onTabNavigate(tab.id, $event)"
@open-node="onOpenNode"
@close-tab="closeTab(tab.id)"
/>
</template>
</div>
<NomadBrowserContextMenu
@ -136,6 +137,7 @@ export default {
mediaQueryListener: null,
dragTabIndex: null,
pageRefs: {},
mountedTabIds: {},
contextMenu: {
show: false,
justOpened: false,
@ -216,6 +218,7 @@ export default {
}
GlobalEmitter.on("nomad-open-node", this.handleNomadOpenNode);
this.mountTab(this.activeTabId);
},
activated() {
if (this.$route?.query?.newTab === "1") {
@ -256,6 +259,24 @@ export default {
this.mediaQuery = null;
this.mediaQueryListener = null;
},
mountTab(tabId) {
if (tabId == null || this.mountedTabIds[tabId]) {
return;
}
this.mountedTabIds = { ...this.mountedTabIds, [tabId]: true };
},
isTabMounted(tabId) {
return Boolean(this.mountedTabIds[tabId]);
},
unmountTab(tabId) {
if (tabId == null || !this.mountedTabIds[tabId]) {
return;
}
delete this.pageRefs[tabId];
const nextMounted = { ...this.mountedTabIds };
delete nextMounted[tabId];
this.mountedTabIds = nextMounted;
},
addTab(destinationHash = "", initialPath = null, title = null, activate = true) {
const id = this.nextTabId++;
this.tabs.push({
@ -267,6 +288,7 @@ export default {
});
if (activate) {
this.activeTabId = id;
this.mountTab(id);
this.syncRoute();
}
return id;
@ -444,6 +466,7 @@ export default {
return;
}
this.activeTabId = tabId;
this.mountTab(tabId);
this.syncRoute();
this.$nextTick(() => {
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();
},

View file

@ -0,0 +1,266 @@
<!-- SPDX-License-Identifier: 0BSD AND MIT -->
<template>
<section v-show="showSection" class="settings-section break-inside-avoid">
<header class="settings-section__header">
<div>
<div class="settings-section__eyebrow">{{ $t("app.notifications") }}</div>
<h2>{{ $t("app.notification_sound_settings") }}</h2>
<p>{{ $t("app.notification_sound_settings_description") }}</p>
</div>
</header>
<div class="settings-section__body space-y-4">
<label class="setting-toggle">
<Toggle
id="notification-sound-enabled"
:model-value="config.notification_sound_enabled"
@update:model-value="onEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.enable_notification_sound") }}</span>
<span class="setting-toggle__description">{{
$t("app.enable_notification_sound_description")
}}</span>
</span>
</label>
<div v-if="config.notification_sound_enabled" class="space-y-4">
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
{{ $t("app.notification_sound_volume") }}
</label>
<span class="text-xs font-mono text-gray-400">{{ config.notification_sound_volume }}%</span>
</div>
<input
:value="config.notification_sound_volume"
type="range"
min="0"
max="100"
class="w-full h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
@input="onVolumeChange"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
{{ $t("app.notification_sound_default") }}
</label>
<select
:value="config.notification_sound_preferred_id"
class="input-field py-1.5! px-3! text-sm! rounded-xl! border-gray-200! dark:border-zinc-800! w-full max-w-md"
@change="onPreferredChange"
>
<option :value="0">{{ $t("app.notification_sound_primary_default") }}</option>
<option v-for="sound in sounds" :key="sound.id" :value="sound.id">
{{ sound.display_name }}
</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm font-semibold text-gray-700 dark:text-zinc-300">
{{ $t("app.notification_sounds") }}
</label>
<button
type="button"
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
:disabled="isUploading"
@click="$refs.soundUpload.click()"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ isUploading ? $t("app.notification_sound_uploading") : $t("app.notification_sound_upload") }}
</button>
<input
ref="soundUpload"
type="file"
accept=".mp3,.ogg,.wav,.m4a,.flac,audio/*"
class="hidden"
@change="uploadSound"
/>
</div>
<p v-if="sounds.length === 0" class="text-sm text-gray-500 dark:text-zinc-400">
{{ $t("app.notification_sound_none_uploaded") }}
</p>
<div v-else class="grid gap-3">
<div
v-for="sound in sounds"
:key="sound.id"
class="flex items-center justify-between gap-3 rounded-xl border border-gray-200 dark:border-zinc-800 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold truncate">{{ sound.display_name }}</div>
<div v-if="sound.is_primary" class="text-xs text-blue-600 dark:text-blue-400">
{{ $t("app.notification_sound_primary") }}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800"
:title="$t('app.notification_sound_preview')"
@click="previewSound(sound)"
>
<MaterialDesignIcon
:icon-name="playingSoundId === sound.id ? 'stop' : 'play'"
class="size-4"
/>
</button>
<button
v-if="!sound.is_primary"
type="button"
class="rounded-lg px-2 py-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20"
@click="setPrimarySound(sound)"
>
{{ $t("app.notification_sound_set_primary") }}
</button>
<button
type="button"
class="rounded-lg p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
:title="$t('app.notification_sound_remove')"
@click="deleteSound(sound)"
>
<MaterialDesignIcon icon-name="delete" class="size-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Toggle from "../forms/Toggle.vue";
import ToastUtils from "../../js/ToastUtils";
import NotificationSoundUtils from "../../js/NotificationSoundUtils";
export default {
name: "NotificationSoundSettings",
components: {
MaterialDesignIcon,
Toggle,
},
props: {
config: {
type: Object,
required: true,
},
showSection: {
type: Boolean,
default: true,
},
updateConfig: {
type: Function,
required: true,
},
},
emits: ["sounds-changed"],
data() {
return {
sounds: [],
isUploading: false,
playingSoundId: null,
};
},
mounted() {
this.loadSounds();
},
methods: {
async loadSounds() {
try {
const response = await window.api.get("/api/v1/notification-sounds");
this.sounds = response.data ?? [];
} catch (error) {
console.error("Failed to load notification sounds:", error);
this.sounds = [];
}
},
onEnabledChange(value) {
this.updateConfig({ notification_sound_enabled: value }, "notification_sound_enabled");
},
onVolumeChange(event) {
const value = Number(event.target.value);
this.updateConfig({ notification_sound_volume: value }, "notification_sound_volume");
},
onPreferredChange(event) {
const value = Number(event.target.value);
this.updateConfig({ notification_sound_preferred_id: value }, "notification_sound_preferred_id");
},
async uploadSound(event) {
const file = event.target.files?.[0];
if (!file) {
return;
}
this.isUploading = true;
const formData = new FormData();
formData.append("file", file);
try {
await window.api.post("/api/v1/notification-sounds/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
ToastUtils.success(this.$t("app.notification_sound_uploaded"));
await this.loadSounds();
this.$emit("sounds-changed");
} catch (error) {
console.error(error);
ToastUtils.error(error.response?.data?.message || this.$t("app.notification_sound_upload_failed"));
} finally {
this.isUploading = false;
event.target.value = "";
}
},
async deleteSound(sound) {
if (!confirm(this.$t("common.delete_confirm"))) {
return;
}
try {
await window.api.delete(`/api/v1/notification-sounds/${sound.id}`);
ToastUtils.success(this.$t("app.notification_sound_deleted"));
if (this.playingSoundId === sound.id) {
NotificationSoundUtils.stop();
this.playingSoundId = null;
}
await this.loadSounds();
this.$emit("sounds-changed");
} catch (error) {
console.error(error);
ToastUtils.error(this.$t("app.notification_sound_delete_failed"));
}
},
async setPrimarySound(sound) {
try {
await window.api.patch(`/api/v1/notification-sounds/${sound.id}`, {
is_primary: true,
});
ToastUtils.success(this.$t("app.notification_sound_primary_set"));
await this.loadSounds();
} catch (error) {
console.error(error);
ToastUtils.error(this.$t("app.notification_sound_primary_set_failed"));
}
},
async previewSound(sound) {
if (this.playingSoundId === sound.id) {
NotificationSoundUtils.stop();
this.playingSoundId = null;
return;
}
const played = await NotificationSoundUtils.preview(sound.id, this.config.notification_sound_volume ?? 100);
if (played) {
this.playingSoundId = sound.id;
return;
}
ToastUtils.warning(this.$t("app.notification_sound_preview_failed"));
},
},
};
</script>

View file

@ -2311,6 +2311,13 @@
</div>
</section>
<NotificationSoundSettings
v-show="showSection('notificationSounds')"
:config="config"
:show-section="showSection('notificationSounds')"
:update-config="updateConfig"
/>
<!-- Messages (LXMF delivery, retries, inbound stamps) -->
<section v-show="showSection('messages')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
@ -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 {

View file

@ -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;

View file

@ -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;
}

View file

@ -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<object>} existing
* @param {Array<object>} 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;
}

View file

@ -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",

View file

@ -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",

View file

@ -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)",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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)",

View file

@ -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": "显示名称",

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -108,7 +108,6 @@ def test_translate_argos_cli(mock_run):
)
assert result["translated_text"] == "Hola"
# _detect_language is private

View file

@ -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");
});

View file

@ -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);

View file

@ -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: '<div class="nnp-stub" :data-hash="destinationHash" :data-active="isActive ? \'1\' : \'0\'"></div>',
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: '<div class="mdi-stub"></div>',
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: '<div class="sidebar-stub"></div>',
props: ["conversations", "selectedDestinationHash"],
},
ConversationViewer: {
template: '<div class="viewer-stub"></div>',
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);
});
});
});

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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", () => {

View file

@ -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");
});
});

View file

@ -39,6 +39,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
"infrastructure",
"csp",
"messages",
"notificationSounds",
"propagation",
"shortcuts",
];