mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(notification-sounds): implement notification sound management and settings and faster conversations/nomadnet browser loading
This commit is contained in:
parent
89477ff424
commit
6008b2205c
40 changed files with 1730 additions and 46 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
84
meshchatx/src/backend/database/notification_sounds.py
Normal file
84
meshchatx/src/backend/database/notification_sounds.py
Normal 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,),
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
123
meshchatx/src/frontend/js/NotificationSoundUtils.js
Normal file
123
meshchatx/src/frontend/js/NotificationSoundUtils.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
94
meshchatx/src/frontend/js/lxmfConversationListSync.js
Normal file
94
meshchatx/src/frontend/js/lxmfConversationListSync.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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": "显示名称",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
81
tests/backend/test_notification_sounds.py
Normal file
81
tests/backend/test_notification_sounds.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -108,7 +108,6 @@ def test_translate_argos_cli(mock_run):
|
|||
)
|
||||
assert result["translated_text"] == "Hola"
|
||||
|
||||
|
||||
# _detect_language is private
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
234
tests/frontend/MountLoadPerformance.test.js
Normal file
234
tests/frontend/MountLoadPerformance.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
121
tests/frontend/NotificationSoundUtils.test.js
Normal file
121
tests/frontend/NotificationSoundUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
62
tests/frontend/lxmfConversationListSync.test.js
Normal file
62
tests/frontend/lxmfConversationListSync.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
|
|||
"infrastructure",
|
||||
"csp",
|
||||
"messages",
|
||||
"notificationSounds",
|
||||
"propagation",
|
||||
"shortcuts",
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue