feat: integrate visualiser WASM build into Dockerfile and update frontend to handle codec2 availability

This commit is contained in:
Ivan 2026-07-16 16:17:54 -05:00
parent 58bfaf379e
commit 389034dbea
No known key found for this signature in database
44 changed files with 2192 additions and 3549 deletions

View file

@ -140,6 +140,10 @@ scripts/ci/
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
# Host-built visualiser WASM (Docker builds with Go from visualiser-wasm/)
meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm
meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js
.hypothesis
.hypothesis/

View file

@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file.
- CI benches use median-of-medians and quieter regression gates
- Backend tests can run sharded in CI
- Plugin strings live in plugin bundles, not main locale files
- Docker frontend build installs Go, builds visualiser WASM, and fails if WASM artifacts are missing
### Fixed
@ -37,10 +38,20 @@ All notable changes to this project will be documented in this file.
- Startup check and disable unsupported interfaces
- Nomad favourites: no more Unknown Node / lost custom sections
- Relay Chat message dedupe. Network visualiser faster on large meshes
- Bots and RNSh work in frozen macOS/Windows builds (`--meshchatx-run-module`)
- Bots and RNSh work in frozen macOS/Windows builds
- Sensitive config no longer mutable over WebSocket. Reticulum config repair on startup
- Paper message URI encoding for non-ASCII title and content
- Nightly releases and broader self-test / CI coverage
- LXMA contact import works with current RNS public-key loading and remembers the peer key before announce
- Android calls: overlay accept opens the phone tab so native audio attaches. Web-audio no longer permanently disabled after a bridge error
- Android Codec2: reliable libcodec2 preload, Gradle fails without Codec2 wheels or jniLibs, and unavailable Codec2 profiles are hidden
- Unknown meshchatx links return a clear error instead of falling through to LXMF
- NomadNet Micron copy no longer inserts a newline between every character
- Unread message count is a red pill on the Messages nav icon
- Notification bell removed from the header
- Unread badge stays circular and remains visible when the sidebar is collapsed
- Open conversations mark as read when a new message arrives without needing to reselect the thread
- Startup stage logs no longer print the same stage twice
## [4.7.2] - 2026-07-06

View file

@ -16,7 +16,8 @@ ARG PYTHON_HASH=sha256:dd4d2bd5b53d9b25a51da13addf2be586beebd5387e289e798e4083d9
# ---- STAGE 1: Frontend Build ----
FROM --platform=linux/amd64 ${NODE_IMAGE}@${NODE_HASH} AS build-frontend
WORKDIR /src
RUN apk add --no-cache git python3
# go is required to compile visualiser-wasm
RUN apk add --no-cache git python3 go
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml vite.config.js ./
COPY patches ./patches
COPY scripts/fetch-micron-wasm.mjs scripts/fetch-micron-wasm.mjs
@ -27,11 +28,19 @@ COPY scripts/sync-meshchatx-docs.js scripts/sync-meshchatx-docs.js
COPY scripts/pip_rns_remotes.py scripts/pip_rns_remotes.py
COPY scripts/build/fetch_reticulum_manual.py scripts/build/fetch_reticulum_manual.py
COPY docs ./docs
COPY visualiser-wasm ./visualiser-wasm
COPY meshchatx/src/frontend ./meshchatx/src/frontend
RUN npm install -g pnpm@11.1.2 && \
ENV GOCACHE=/tmp/go-cache
ENV GOTMPDIR=/tmp/go-tmp
RUN mkdir -p /tmp/go-cache /tmp/go-tmp && \
npm install -g pnpm@11.1.2 && \
pnpm config set verify-store-integrity true && \
pnpm install --frozen-lockfile && \
pnpm run build-frontend && \
MESHCHATX_REQUIRE_VISUALISER_WASM=1 pnpm run build-frontend && \
test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm && \
test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js && \
test -s meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm && \
test -s meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js && \
pnpm run build-docs
# ---- STAGE 2: Python Builder ----

View file

@ -13,7 +13,8 @@ ARG PYTHON_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev
FROM --platform=linux/amd64 ${NODE_IMAGE} AS build-frontend
USER root
WORKDIR /src
RUN apk add --no-cache git python3
# go is required to compile visualiser-wasm (gitignored binary; not in image context)
RUN apk add --no-cache git python3 go
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml vite.config.js ./
COPY patches ./patches
COPY scripts/fetch-micron-wasm.mjs scripts/fetch-micron-wasm.mjs
@ -24,11 +25,19 @@ COPY scripts/sync-meshchatx-docs.js scripts/sync-meshchatx-docs.js
COPY scripts/pip_rns_remotes.py scripts/pip_rns_remotes.py
COPY scripts/build/fetch_reticulum_manual.py scripts/build/fetch_reticulum_manual.py
COPY docs ./docs
COPY visualiser-wasm ./visualiser-wasm
COPY meshchatx/src/frontend ./meshchatx/src/frontend
RUN npm install -g pnpm@11.1.2 && \
ENV GOCACHE=/tmp/go-cache
ENV GOTMPDIR=/tmp/go-tmp
RUN mkdir -p /tmp/go-cache /tmp/go-tmp && \
npm install -g pnpm@11.1.2 && \
pnpm config set verify-store-integrity true && \
pnpm install --frozen-lockfile && \
pnpm run build-frontend && \
MESHCHATX_REQUIRE_VISUALISER_WASM=1 pnpm run build-frontend && \
test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm && \
test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js && \
test -s meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm && \
test -s meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js && \
pnpm run build-docs
FROM ${PYTHON_BUILD_IMAGE} AS builder

View file

@ -104,8 +104,20 @@ tasks.register("syncCodec2JniLibs", Exec) {
"${projectDir}/src/main/jniLibs",
abiArg
)
onlyIf {
vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("chaquopy_libcodec2-") }
doFirst {
if (!vendorWheelDir.isDirectory()) {
throw new org.gradle.api.GradleException(
"Missing android/vendor directory at ${vendorWheelDir}. " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
def wheels = vendorWheelDir.list()?.toList() ?: []
if (!wheels.any { it.startsWith("chaquopy_libcodec2-") }) {
throw new org.gradle.api.GradleException(
"Missing chaquopy_libcodec2 wheels in ${vendorWheelDir}. " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
}
}
@ -128,6 +140,25 @@ tasks.register("verifyVendorWheels") {
)
}
}
if (!wheels.any { it.startsWith("pycodec2-") && it.contains("android_24_${abiTag}") }) {
throw new org.gradle.api.GradleException(
"Missing pycodec2 wheel for ${abi} in ${vendorWheelDir}. " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
if (!wheels.any { it.startsWith("chaquopy_libcodec2-") && it.contains("android_24_${abiTag}") }) {
throw new org.gradle.api.GradleException(
"Missing chaquopy_libcodec2 wheel for ${abi} in ${vendorWheelDir}. " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
def jniLib = file("${projectDir}/src/main/jniLibs/${abi}/libcodec2.so")
if (!jniLib.isFile() || jniLib.length() < 100_000) {
throw new org.gradle.api.GradleException(
"Missing or tiny jniLibs/${abi}/libcodec2.so after sync. " +
"Expected a real Codec2 shared library for Android calls."
)
}
}
}
}
@ -142,8 +173,24 @@ tasks.register("repackAndroidPycodec2Wheels", Exec) {
workingDir = repoRoot
def pyExe = (System.getenv("MESHCHATX_REPACK_PYTHON") ?: "python3")
commandLine(pyExe, "scripts/repack-android-pycodec2-wheels.py", "--vendor-dir", vendorWheelDir.absolutePath)
onlyIf {
vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("pycodec2-") && it.endsWith(".whl") }
doFirst {
if (!vendorWheelDir.isDirectory()) {
throw new org.gradle.api.GradleException(
"Missing android/vendor directory at ${vendorWheelDir}"
)
}
def wheels = vendorWheelDir.list()?.toList() ?: []
if (!wheels.any { it.startsWith("pycodec2-") && it.endsWith(".whl") }) {
throw new org.gradle.api.GradleException(
"Missing pycodec2 wheels in ${vendorWheelDir}. " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
if (!wheels.any { it.startsWith("chaquopy_libcodec2-") && it.endsWith(".whl") }) {
throw new org.gradle.api.GradleException(
"Missing chaquopy_libcodec2 wheels needed to repack pycodec2 in ${vendorWheelDir}"
)
}
}
}
@ -151,6 +198,10 @@ tasks.named("syncCodec2JniLibs").configure {
dependsOn(tasks.named("repackAndroidPycodec2Wheels"))
}
tasks.named("verifyVendorWheels").configure {
dependsOn(tasks.named("syncCodec2JniLibs"))
}
tasks.register("fetchRepositoryBundledWheels", Exec) {
workingDir = repoRoot
def pyExe = (System.getenv("MESHCHATX_FETCH_PYTHON") ?: "python3")

Binary file not shown.

View file

@ -23,7 +23,24 @@ def _is_chaquopy_android() -> bool:
return True
def _cdll_load(path_or_name: str):
"""Load a shared library with RTLD_GLOBAL when the platform supports it.
pycodec2.so declares NEEDED libcodec2.so. Loading with RTLD_GLOBAL lets the
later dlopen of the extension resolve that dependency.
"""
mode = getattr(ctypes, "RTLD_GLOBAL", None)
if mode is None:
return ctypes.CDLL(path_or_name)
return ctypes.CDLL(path_or_name, mode=mode)
def _libcodec2_candidates() -> list[Path]:
"""Return candidate paths for libcodec2.so without importing pycodec2.
``import pycodec2`` loads the extension which already needs libcodec2.so.
Searching sys.path on disk avoids that chicken-and-egg failure.
"""
candidates: list[Path] = []
seen: set[str] = set()
@ -34,17 +51,12 @@ def _libcodec2_candidates() -> list[Path]:
seen.add(key)
candidates.append(path)
try:
import pycodec2
add(Path(pycodec2.__file__).resolve().parent / "libcodec2.so")
except Exception:
pass
for entry in sys.path:
if not entry:
continue
add(Path(entry) / "chaquopy" / "lib" / "libcodec2.so")
root = Path(entry)
add(root / "pycodec2" / "libcodec2.so")
add(root / "chaquopy" / "lib" / "libcodec2.so")
return candidates
@ -53,7 +65,7 @@ def ensure_codec2_native_library() -> bool:
"""Preload ``libcodec2.so`` so ``import pycodec2`` works on Android.
Chaquopy installs ``chaquopy-libcodec2`` separately from ``pycodec2``. The
extension module only declares a NEEDED entry for ``libcodec2.so``; without
extension module only declares a NEEDED entry for ``libcodec2.so``. Without
preloading or bundling the shared library next to ``pycodec2.so``, imports
fail at runtime with ``dlopen`` errors.
"""
@ -68,7 +80,7 @@ def ensure_codec2_native_library() -> bool:
return True
try:
ctypes.CDLL("libcodec2.so")
_cdll_load("libcodec2.so")
return True
except OSError:
pass
@ -78,7 +90,7 @@ def ensure_codec2_native_library() -> bool:
if not lib_path.is_file():
continue
try:
ctypes.CDLL(str(lib_path))
_cdll_load(str(lib_path))
logger.info("Loaded Codec2 native library from %s", lib_path)
return True
except OSError as exc:
@ -106,3 +118,10 @@ def probe_pycodec2() -> tuple[bool, str | None]:
def codec2_preload_error() -> str | None:
"""Return the last preload failure message, if any."""
return _codec2_preload_error
def reset_codec2_preload_state_for_tests() -> None:
"""Clear preload memoization (tests only)."""
global _codec2_preload_done, _codec2_preload_error
_codec2_preload_done = False
_codec2_preload_error = None

View file

@ -1394,10 +1394,14 @@ class ReticulumMeshChat:
ensure_safe_reticulum_runtime_flags(config_path)
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
previous = getattr(self, "_startup_stage", None)
self._startup_stage = stage
if error is not None:
self._startup_error = error
print(f"Startup stage: {stage}", flush=True)
# Same stage can be set from both the network-setup wrapper and
# setup_identity. Only log transitions to keep console noise down.
if previous != stage or error is not None:
print(f"Startup stage: {stage}", flush=True)
def _mark_network_ready(self) -> None:
self._network_ready = True
@ -1533,7 +1537,6 @@ class ReticulumMeshChat:
self._set_startup_stage("failed", "No identity available for network setup")
return
try:
self._set_startup_stage("rns")
self.setup_identity(identity)
if self.config is not None and getattr(self, "session_secret_key", None):
try:
@ -1603,7 +1606,6 @@ class ReticulumMeshChat:
loglevel=rns_loglevel,
)
_restore_rns_console_logging_after_reticulum_init(self)
self._set_startup_stage("identity")
self.page_node_manager.load_nodes()
self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
@ -10572,10 +10574,13 @@ class ReticulumMeshChat:
int(profile_id),
)
self.config.telephone_audio_profile_id.set(resolved)
requested = int(profile_id)
return web.json_response(
{
"message": f"Switched to profile {resolved}",
"profile_id": resolved,
"requested_profile_id": requested,
"remapped": requested != resolved,
},
)
except Exception as e:
@ -10586,17 +10591,25 @@ class ReticulumMeshChat:
async def telephone_codec2_status(request):
from meshchatx import android_codec2
available = await asyncio.to_thread(
self.telephone_manager.codec2_available,
)
return web.json_response(
{
def _status():
probe_ok, probe_error = android_codec2.probe_pycodec2()
lxst_ok = self.telephone_manager.codec2_available()
available = bool(probe_ok and lxst_ok)
return {
"codec2_available": available,
"preload_error": android_codec2.codec2_preload_error(),
"probe_error": None if probe_ok else probe_error,
"platform": (
"android"
if android_codec2._is_chaquopy_android()
else "desktop"
),
"preferred_profile_id": self.telephone_manager.preferred_profile_id,
"resolved_profile_id": self.telephone_manager.resolve_audio_profile_id(),
},
)
}
payload = await asyncio.to_thread(_status)
return web.json_response(payload)
# initiate a telephone call
# initiate outgoing telephone call
@ -10664,22 +10677,37 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/audio-profiles")
async def telephone_audio_profiles(request):
from LXST.Primitives.Telephony import Profiles
from meshchatx import android_codec2
# get audio profiles
audio_profiles = [
{
"id": available_profile,
"name": Profiles.profile_name(available_profile),
def _profiles():
probe_ok, _probe_err = android_codec2.probe_pycodec2()
codec2_ok = bool(probe_ok and self.telephone_manager.codec2_available())
codec2_ids = {
Profiles.BANDWIDTH_ULTRA_LOW,
Profiles.BANDWIDTH_VERY_LOW,
Profiles.BANDWIDTH_LOW,
}
for available_profile in Profiles.available_profiles()
]
return web.json_response(
{
"default_audio_profile_id": Profiles.DEFAULT_PROFILE,
audio_profiles = []
for profile_id in Profiles.available_profiles():
entry = {
"id": profile_id,
"name": Profiles.profile_name(profile_id),
"available": True,
}
if profile_id in codec2_ids and not codec2_ok:
entry["available"] = False
entry["unavailable_reason"] = "codec2"
audio_profiles.append(entry)
return {
"default_audio_profile_id": self.telephone_manager.resolve_audio_profile_id(
Profiles.DEFAULT_PROFILE,
),
"codec2_available": codec2_ok,
"audio_profiles": audio_profiles,
},
)
}
payload = await asyncio.to_thread(_profiles)
return web.json_response(payload)
# voicemail status
@routes.get("/api/v1/telephone/voicemail/status")
@ -19312,6 +19340,28 @@ class ReticulumMeshChat:
)
return
# Known hosts (map/docs) are handled above. Relay and app
# deep links are frontend-routed. Anything else must not
# fall through to LXMF ingest.
AsyncUtils.run_async(
client.send_str(
json.dumps(
{
"type": "lxm.ingest_uri.result",
"status": "error",
"message": (
f"Unknown or unsupported meshchatx link host "
f"'{_host or '(empty)'}'. "
"Supported hosts include map, docs, relay, and app."
),
"ingest_type": "unknown_meshchatx",
"host": _host,
},
),
),
)
return
# LXMA contact sharing URI:
# lxma://<destination_hash_hex>:<public_key_hex>
if uri.lower().startswith("lxma://"):
@ -19337,18 +19387,13 @@ class ReticulumMeshChat:
bytes.fromhex(destination_hash_hex)
raw_bytes = bytes.fromhex(public_key_hex)
identity = RNS.Identity(create_keys=False)
loaded = False
for candidate in (
raw_bytes,
raw_bytes[:32] if len(raw_bytes) > 32 else None,
):
if not candidate:
continue
if identity.load_public_key(candidate):
loaded = True
break
if not loaded:
# RNS Identity.load_public_key docs say True/False but the
# implementation returns None on success. Prefer full 64-byte
# keys; truncated 32-byte material is not a valid RNS pubkey.
identity = self._identity_from_public_key_bytes(raw_bytes)
if identity is None and len(raw_bytes) > 32:
identity = self._identity_from_public_key_bytes(raw_bytes[:32])
if identity is None:
raise ValueError("Invalid LXMA public key")
remote_identity_hash = identity.hash.hex()
@ -19369,6 +19414,20 @@ class ReticulumMeshChat:
lxmf_address=destination_hash_hex,
)
# Persist pubkey so outbound LXMF works before any announce.
try:
RNS.Identity.remember(
None,
bytes.fromhex(destination_hash_hex),
identity.get_public_key(),
None,
)
except Exception as remember_exc:
print(
f"LXMA remember failed for {destination_hash_hex}: "
f"{type(remember_exc).__name__}: {remember_exc!r}",
)
AsyncUtils.run_async(
client.send_str(
json.dumps(
@ -20368,8 +20427,8 @@ class ReticulumMeshChat:
if announce and announce.get("identity_public_key"):
public_key = base64.b64decode(announce["identity_public_key"])
identity = RNS.Identity(create_keys=False)
if identity.load_public_key(public_key):
identity = self._identity_from_public_key_bytes(public_key)
if identity is not None:
return identity
except Exception as e:
@ -20377,6 +20436,27 @@ class ReticulumMeshChat:
return None
@staticmethod
def _identity_from_public_key_bytes(public_key: bytes) -> RNS.Identity | None:
"""Load an RNS Identity from raw public-key bytes.
``Identity.load_public_key`` is documented as returning True/False, but
current RNS releases return ``None`` on both success and failure. Treat
a non-None ``identity.pub`` (and a computed hash) as success.
"""
if not public_key:
return None
identity = RNS.Identity(create_keys=False)
try:
identity.load_public_key(public_key)
except Exception:
return None
if getattr(identity, "pub", None) is None:
return None
if not getattr(identity, "hash", None):
return None
return identity
# convert an lxmf message to a dictionary, for sending over websocket
# convert database announce to a dictionary
@ -20546,9 +20626,7 @@ class ReticulumMeshChat:
if not identity and announce.get("identity_public_key"):
# Try to load from public key if recall failed
public_key = base64.b64decode(announce["identity_public_key"])
identity = RNS.Identity(create_keys=False)
if not identity.load_public_key(public_key):
identity = None
identity = self._identity_from_public_key_bytes(public_key)
if identity:
try:

View file

@ -98,6 +98,15 @@ class TelephoneManager:
@staticmethod
def codec2_available() -> bool:
"""Return whether LXST can construct Codec2 codecs (pycodec2 + libcodec2)."""
try:
from meshchatx import android_codec2
if android_codec2._is_chaquopy_android():
ok, _err = android_codec2.probe_pycodec2()
if not ok:
return False
except Exception:
pass
try:
from LXST.Codecs import Codec2

View file

@ -91,7 +91,6 @@
/>
</button>
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
<NotificationBell />
<button
type="button"
class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors relative"
@ -101,7 +100,7 @@
<MaterialDesignIcon icon-name="message-text" class="w-5 h-5" />
<span
v-if="unreadConversationsCount > 0"
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center leading-none"
class="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ unreadConversationsCount > 99 ? "99+" : unreadConversationsCount }}
</span>
@ -116,7 +115,7 @@
<MaterialDesignIcon icon-name="forum" class="w-5 h-5" />
<span
v-if="relayChatUnreadCount > 0"
class="absolute -top-0.5 -right-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
class="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ relayChatUnreadCount > 99 ? "99+" : relayChatUnreadCount }}
</span>
@ -238,10 +237,22 @@
<li v-for="item in visibleNavItems" :key="item.id">
<SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
:icon-name="item.icon"
class="w-6 h-6 text-gray-700 dark:text-white"
/>
<span class="relative inline-flex shrink-0">
<MaterialDesignIcon
:icon-name="item.icon"
class="w-6 h-6 text-gray-700 dark:text-white"
/>
<span
v-if="
isSidebarCollapsed &&
getNavBadgeCount(item) > 0 &&
item.badge?.pill
"
class="absolute -right-2 -top-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ formatNavBadgeCount(item) }}
</span>
</span>
</template>
<template #text>
<span>{{ item.label || $t(item.labelKey) }}</span>
@ -252,8 +263,12 @@
{{ getNavBadgeCount(item) }}
</span>
<span
v-else-if="getNavBadgeCount(item) > 0 && item.badge?.pill"
class="ml-auto mr-2 min-w-[1.25rem] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-xs font-bold text-white"
v-else-if="
!isSidebarCollapsed &&
getNavBadgeCount(item) > 0 &&
item.badge?.pill
"
class="ml-auto mr-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ formatNavBadgeCount(item) }}
</span>
@ -592,7 +607,6 @@ import PromptDialog from "./PromptDialog.vue";
import ToastUtils from "../js/ToastUtils";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
import CallOverlay from "./call/CallOverlay.vue";
import CommandPalette from "./CommandPalette.vue";
@ -626,7 +640,6 @@ export default {
ConfirmDialog,
PromptDialog,
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
CallOverlay,
CommandPalette,
@ -957,6 +970,7 @@ export default {
GlobalEmitter.on("block-status-changed", this.onBlockStatusChangedShell);
GlobalEmitter.on("show-changelog", this.onShowChangelogShell);
GlobalEmitter.on("show-tutorial", this.onShowTutorialShell);
GlobalEmitter.on("notifications-changed", this.updateUnreadConversationsCount);
this.getAppInfo();
this.getConfig();
@ -1031,6 +1045,7 @@ export default {
GlobalEmitter.off("block-status-changed", this.onBlockStatusChangedShell);
GlobalEmitter.off("show-changelog", this.onShowChangelogShell);
GlobalEmitter.off("show-tutorial", this.onShowTutorialShell);
GlobalEmitter.off("notifications-changed", this.updateUnreadConversationsCount);
this.clearWsShellUiTimers();
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
@ -1421,6 +1436,11 @@ export default {
this.toneGenerator.stop();
NotificationUtils.cancelIncomingCallNotification();
this.updateTelephoneStatus();
// Ensure CallPage is mounted so Android native audio / web
// audio can attach after answer from overlay or notification.
if (this.$route?.name !== "call" || this.$route?.query?.tab !== "phone") {
this.$router.push({ name: "call", query: { tab: "phone" } });
}
},
telephone_call_ended: () => {
this.stopRingtone();
@ -2122,6 +2142,19 @@ export default {
this.openRelayShareLink(normalizedUrl);
return;
}
if (/^(meshchatx|meshchat):\/\//i.test(normalizedUrl)) {
try {
const u = new URL(normalizedUrl);
const host = (u.hostname || "").toLowerCase();
if (host && !["map", "docs", "relay", "app"].includes(host)) {
ToastUtils.error(this.$t("messages.unknown_meshchatx_link", { host }));
return;
}
} catch {
ToastUtils.error(this.$t("messages.unknown_meshchatx_link_generic"));
return;
}
}
if (/^lxm(a|f)?:\/\//i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({

View file

@ -1,542 +0,0 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="relative">
<button
type="button"
class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
@click.stop="toggleDropdown"
>
<MaterialDesignIcon icon-name="bell" class="w-5 h-5 sm:w-6 sm:h-6" />
<span
v-if="unreadCount > 0"
class="absolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-semibold text-white"
>
{{ unreadCount > 9 ? "9+" : unreadCount }}
</span>
</button>
<Teleport to="body">
<div
v-if="isDropdownOpen"
ref="notificationDropdown"
v-click-outside="closeDropdown"
class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-9999 max-h-[min(500px,calc(100vh-2rem))] overflow-hidden flex flex-col"
:style="dropdownStyle"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<div class="flex items-center gap-2">
<button
v-if="notifications.length > 0 && !showHistory"
type="button"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
@click.stop="clearAllNotifications"
>
Clear
</button>
<button
type="button"
class="rounded-md p-1 transition-colors"
:class="
showHistory
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
:title="$t('app.notifications_history_title')"
:aria-label="$t('app.notifications_history_title')"
@click.stop="toggleHistory"
>
<MaterialDesignIcon icon-name="history" class="w-5 h-5" />
</button>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
</div>
</div>
</div>
<div class="overflow-y-auto flex-1">
<div v-if="isLoading" class="p-8 text-center">
<div class="inline-block animate-spin text-gray-400">
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
</div>
<div v-else-if="notifications.length === 0" class="p-8 text-center">
<MaterialDesignIcon
icon-name="bell-off"
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
/>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ showHistory ? $t("app.notifications_empty_history") : $t("app.notifications_no_new") }}
</div>
</div>
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
<button
v-for="notification in notifications"
:key="notification.id || notification.destination_hash"
type="button"
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
@click="onNotificationClick(notification)"
>
<div class="flex gap-3">
<div class="shrink-0">
<div
v-if="notification.lxmf_user_icon"
class="p-2 rounded-lg"
:style="{
color: notification.lxmf_user_icon.foreground_colour,
'background-color': notification.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon
:icon-name="notification.lxmf_user_icon.icon_name"
class="w-6 h-6"
/>
</div>
<div
v-else-if="notification.type === 'rrc_mention'"
class="bg-indigo-100 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 p-2 rounded-lg"
>
<MaterialDesignIcon icon-name="forum-outline" class="w-6 h-6" />
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-1">
<div
class="font-semibold text-gray-900 dark:text-white truncate"
:title="
notification.title ??
notification.custom_display_name ??
notification.display_name
"
>
{{
notification.title ??
notification.custom_display_name ??
notification.display_name
}}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0"
>
{{ formatTimeAgo(notification.updated_at) }}
</div>
</div>
<div
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
:title="
notification.latest_message_preview ?? notification.content ?? 'No preview'
"
>
{{
notification.latest_message_preview ?? notification.content ?? "No preview"
}}
</div>
</div>
</div>
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import GlobalEmitter from "../js/GlobalEmitter";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
export default {
name: "NotificationBell",
components: {
MaterialDesignIcon,
},
directives: {
"click-outside": {
mounted(el, binding) {
el.clickOutsideEvent = function (event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value();
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener("click", el.clickOutsideEvent);
},
},
},
emits: ["notifications-cleared"],
data() {
return {
isDropdownOpen: false,
isLoading: false,
notifications: [],
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
dropdownMaxHeight: null,
showHistory: false,
};
},
computed: {
dropdownStyle() {
const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
if (this.dropdownMaxHeight != null) {
style.maxHeight = `${this.dropdownMaxHeight}px`;
}
return style;
},
},
beforeUnmount() {
if (this.reloadInterval) {
clearInterval(this.reloadInterval);
}
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("notifications-changed", this.onNotificationsChanged);
},
mounted() {
this.loadNotifications();
WebSocketConnection.on("message", this.onWebsocketMessage);
GlobalEmitter.on("notifications-changed", this.onNotificationsChanged);
this.reloadInterval = setInterval(() => {
this.loadNotifications({ updateList: this.isDropdownOpen });
}, 5000);
},
methods: {
shouldFetchNotifications() {
if (!GlobalState.authSessionResolved) {
return false;
}
if (!GlobalState.authEnabled) {
return true;
}
return GlobalState.authenticated;
},
onNotificationsChanged() {
if (!this.shouldFetchNotifications()) {
return;
}
this.loadNotifications({ updateList: this.isDropdownOpen });
},
async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.showHistory = false;
this.updateDropdownPosition(event);
await this.loadNotifications();
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
await this.$nextTick();
this.clampNotificationDropdown();
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const isMobile = window.innerWidth < 640;
const dropdownWidth = isMobile ? 320 : 384;
this.dropdownMaxHeight = null;
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(16, rect.right - dropdownWidth),
};
this.$nextTick(() => this.clampNotificationDropdown());
},
clampNotificationDropdown() {
const panel = this.$refs.notificationDropdown;
if (!panel || !this.isDropdownOpen) return;
const pr = panel.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
this.dropdownPosition = { top, left };
this.dropdownMaxHeight = maxHeight;
},
closeDropdown() {
this.isDropdownOpen = false;
this.showHistory = false;
},
async toggleHistory() {
this.showHistory = !this.showHistory;
await this.loadNotifications();
if (!this.showHistory) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
if (this.isDropdownOpen) {
await this.$nextTick();
this.clampNotificationDropdown();
}
},
async loadNotifications(options = {}) {
const updateList = options.updateList !== false;
if (!this.shouldFetchNotifications()) {
this.notifications = [];
this.unreadCount = 0;
this.isLoading = false;
return;
}
if (updateList) {
this.isLoading = true;
}
try {
const response = await window.api.get(`/api/v1/notifications`, {
params: {
unread: !this.showHistory,
limit: 10,
},
});
const newNotifications = response.data.notifications || [];
if (updateList) {
this.notifications = newNotifications;
}
this.unreadCount = response.data.unread_count || 0;
} catch (e) {
console.error("Failed to load notifications", e);
if (updateList) {
this.notifications = [];
}
} finally {
if (updateList) {
this.isLoading = false;
}
}
},
async markNotificationsAsViewed() {
if (!this.shouldFetchNotifications()) {
return;
}
if (this.notifications.length === 0) {
return;
}
try {
const destination_hashes = this.notifications
.filter((n) => n.type === "lxmf_message")
.map((n) => n.destination_hash);
const notification_ids = this.notifications.filter((n) => n.type !== "lxmf_message").map((n) => n.id);
await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
notification_ids: notification_ids,
});
} catch (e) {
console.error("Failed to mark notifications as viewed", e);
}
},
async clearAllNotifications() {
if (!this.shouldFetchNotifications()) {
return;
}
try {
await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: [],
notification_ids: [],
});
const response = await window.api.get("/api/v1/lxmf/conversations");
const conversations = response.data.conversations || [];
for (const conversation of conversations) {
if (conversation.is_unread) {
try {
await window.api.post(
`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
);
} catch (e) {
console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
}
}
}
GlobalState.unreadConversationsCount = 0;
this.showHistory = false;
await this.loadNotifications();
this.$emit("notifications-cleared");
} catch (e) {
console.error("Failed to clear notifications", e);
}
},
async onNotificationClick(notification) {
this.closeDropdown();
if (!this.shouldFetchNotifications()) {
return;
}
// Mark this specific notification as viewed
try {
const destination_hashes = notification.type === "lxmf_message" ? [notification.destination_hash] : [];
const notification_ids = notification.type !== "lxmf_message" ? [notification.id] : [];
await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
notification_ids: notification_ids,
});
// reload to update unread count
await this.loadNotifications();
} catch (e) {
console.error("Failed to mark notification as viewed", e);
}
if (notification.type === "lxmf_message") {
this.$router.push({
name: "messages",
params: { destinationHash: notification.destination_hash },
});
} else if (notification.type === "telephone_missed_call") {
this.$router.push({
name: "call",
query: { tab: "history" },
});
} else if (notification.type === "telephone_voicemail") {
this.$router.push({
name: "call",
query: { tab: "voicemail" },
});
} else if (notification.type === "rrc_mention") {
const remote = notification.destination_hash || "";
const sep = remote.indexOf(":");
if (sep > 0) {
const hub = remote.slice(0, sep);
const room = decodeURIComponent(remote.slice(sep + 1));
try {
await window.api.post(`/api/v1/rrc/hubs/${hub}/rooms/${encodeURIComponent(room)}/read`);
} catch (e) {
console.error("Failed to mark relay room read", e);
}
this.$router.push({
name: "relay-chat",
query: { hub, room },
});
} else {
this.$router.push({ name: "relay-chat" });
}
}
},
formatTimeAgo(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
isUserFacingLxmfDelivery(lxmfMessage) {
if (!lxmfMessage || lxmfMessage.is_incoming !== true) {
return false;
}
// Reactions never count as a notification.
if (lxmfMessage.is_reaction === true) {
return false;
}
const fields = lxmfMessage.fields || {};
const reaction = fields.reaction;
if (
reaction &&
typeof reaction === "object" &&
Object.prototype.hasOwnProperty.call(reaction, "reaction_to")
) {
return false;
}
const content = (lxmfMessage.content || "").toString().trim();
const title = (lxmfMessage.title || "").toString().trim();
if (content.length > 0 || title.length > 0) {
return true;
}
const image = fields.image;
if (image && (image.image_size || image.image_bytes)) {
return true;
}
const audio = fields.audio;
if (audio && (audio.audio_size || audio.audio_bytes)) {
return true;
}
const fileAttachments = fields.file_attachments;
if (Array.isArray(fileAttachments) && fileAttachments.length > 0) {
return true;
}
return false;
},
async onWebsocketMessage(message) {
if (!this.shouldFetchNotifications()) {
return;
}
let json;
try {
json = JSON.parse(message.data);
} catch {
return;
}
if (json.type === "lxmf.delivery") {
if (!this.isUserFacingLxmfDelivery(json.lxmf_message)) {
return;
}
await this.loadNotifications();
if (this.isDropdownOpen) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
return;
}
if (json.type === "telephone_missed_call" || json.type === "new_voicemail") {
await this.loadNotifications();
if (this.isDropdownOpen) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
return;
}
if (json.type === "rrc.message" && (json.mention || json.message?.mention)) {
await this.loadNotifications();
if (this.isDropdownOpen) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
}
},
},
};
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -9,8 +9,9 @@
isActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
isCollapsed ? 'overflow-visible' : 'overflow-hidden',
]"
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
@click="handleNavigate($event, navigate)"
>
<span class="my-auto shrink-0">

View file

@ -419,6 +419,12 @@ export default {
async answerCall() {
try {
await window.api.get("/api/v1/telephone/answer");
// Native Android audio (and desktop web-audio) only attach from
// CallPage. Overlay accept must open the phone tab or the call
// stays silent after answer.
if (this.$route?.name !== "call" || this.$route?.query?.tab !== "phone") {
await this.$router.push({ name: "call", query: { tab: "phone" } });
}
} catch {
ToastUtils.error(this.$t("call.failed_to_answer_call"));
}

View file

@ -2903,13 +2903,18 @@ export default {
async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") {
this.logWebAudioFailure(stage, error);
ToastUtils.error(this.$t(errorKey));
if (this.config) {
this.config.telephone_web_audio_enabled = false;
}
try {
await this.updateConfig({ telephone_web_audio_enabled: false });
} catch (updateError) {
this.logWebAudioFailure("disable-config-update", updateError);
// On Android the backend forces web_audio.enabled while Chaquopy is
// present. Permanently clearing the config flag just creates a 1s
// retry/toast loop without helping recovery.
if (!this.isMeshChatXAndroid()) {
if (this.config) {
this.config.telephone_web_audio_enabled = false;
}
try {
await this.updateConfig({ telephone_web_audio_enabled: false });
} catch (updateError) {
this.logWebAudioFailure("disable-config-update", updateError);
}
}
this.stopWebAudio();
},
@ -3509,8 +3514,15 @@ export default {
async getAudioProfiles() {
try {
const response = await window.api.get("/api/v1/telephone/audio-profiles");
this.audioProfiles = response.data.audio_profiles;
const profiles = Array.isArray(response.data.audio_profiles) ? response.data.audio_profiles : [];
this.audioProfiles = profiles.filter((p) => p && p.available !== false);
this.selectedAudioProfileId = response.data.default_audio_profile_id;
if (response.data.codec2_available === false) {
const hadCodec2 = profiles.some((p) => p && p.unavailable_reason === "codec2");
if (hadCodec2) {
ToastUtils.warning(this.$t("call.codec2_unavailable"));
}
}
} catch (e) {
console.log(e);
}
@ -4354,7 +4366,14 @@ export default {
},
async switchAudioProfile(audioProfileId) {
try {
await window.api.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
const response = await window.api.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
const resolved = response.data?.profile_id;
if (resolved != null) {
this.selectedAudioProfileId = resolved;
}
if (response.data?.remapped) {
ToastUtils.warning(this.$t("call.codec2_profile_remapped"));
}
} catch {
ToastUtils.error(this.$t("call.failed_to_switch_audio_profile"));
}

View file

@ -3611,8 +3611,11 @@ export default {
});
const conversation = this.findConversation(this.selectedPeer.destination_hash);
if (conversation) {
this.markConversationAsRead(conversation);
const target = conversation || this.selectedPeer;
if (target) {
// Force mark even when local is_unread is still false. Delivery bumps the
// nav badge from the server before the conversation list refreshes.
this.markConversationAsRead(target, { force: true });
}
if (this.autoScrollOnNewMessage) {
@ -7151,9 +7154,13 @@ export default {
return conversation.destination_hash === destinationHash;
});
},
async markConversationAsRead(conversation) {
async markConversationAsRead(conversation, { force = false } = {}) {
if (!conversation?.destination_hash) {
return;
}
const wasUnread = conversation.is_unread === true;
if (!wasUnread) {
if (!wasUnread && !force) {
return;
}
@ -7163,11 +7170,11 @@ 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) {
if (wasUnread && GlobalState.unreadConversationsCount > 0) {
GlobalState.unreadConversationsCount -= 1;
}
} catch (e) {
conversation.is_unread = true;
conversation.is_unread = wasUnread;
console.log(e);
}
},

View file

@ -209,6 +209,61 @@ export default class MicronParser extends BaseMicronParser {
}
`;
document.head.appendChild(styleEl);
MicronParser.installMicronCopyFix();
}
/**
* Browsers insert newlines between adjacent ``inline-block`` Mu-mnt cells
* when copying. Rebuild clipboard text without those spurious breaks while
* keeping intentional block-level line breaks.
*/
static installMicronCopyFix() {
if (typeof document === "undefined" || window.__meshchatxMicronCopyFix) {
return;
}
window.__meshchatxMicronCopyFix = true;
document.addEventListener("copy", (event) => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) {
return;
}
const anchor = sel.anchorNode;
const focus = sel.focusNode;
const anchorEl = anchor && anchor.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement;
const focusEl = focus && focus.nodeType === Node.ELEMENT_NODE ? focus : focus?.parentElement;
const inMicron = (el) => Boolean(el?.closest?.(".Mu-mws, .Mu-mnt, .Mu-mnt-full, .Mu-mnt-group, .Mu-nl"));
if (!inMicron(anchorEl) && !inMicron(focusEl)) {
return;
}
try {
const range = sel.getRangeAt(0);
const fragment = range.cloneContents();
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ALL);
let out = "";
let node = walker.nextNode();
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
out += node.nodeValue || "";
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName;
if (tag === "BR" || tag === "DIV" || tag === "P" || tag === "PRE") {
if (out.length && !out.endsWith("\n")) {
out += "\n";
}
}
}
node = walker.nextNode();
}
// Collapse newlines that came only from adjacent inline-block cells.
const cleaned = out.replace(/([^\n])\n(?!\n)/g, "$1");
if (cleaned && event.clipboardData) {
event.clipboardData.setData("text/plain", cleaned);
event.preventDefault();
}
} catch {
/* leave default copy behaviour */
}
});
}
convertMicronToHtmlWasmHybrid(markup, partialContents = {}) {

View file

@ -21,7 +21,7 @@ export const CORE_NAV_ENTRIES = [
route: { name: "messages" },
icon: "message-text",
labelKey: "app.messages",
badge: { source: "unreadConversationsCount" },
badge: { source: "unreadConversationsCount", pill: true, cap: 99 },
},
{
id: "call",

View file

@ -355,9 +355,6 @@
"announce_limit_prop": "Prop-Knoten",
"failed_announce": "Ankündigung fehlgeschlagen",
"announce_sent": "Ankündigung gesendet",
"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.",
@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} hat {count} Dateien gesendet",
"message_not_found_in_cache": "Nachricht nicht im Cache gefunden",
"failed_to_send": "Nachricht konnte nicht gesendet werden",
"failed_to_send_image": "Bild {index} konnte nicht gesendet werden: {detail}"
"failed_to_send_image": "Bild {index} konnte nicht gesendet werden: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"nomadnet": {
"remove_favourite": "Favorit entfernen",
@ -2975,7 +2974,9 @@
"failed_to_send_to_voicemail": "Fehler beim Senden des Anrufs an Voicemail",
"failed_load_audio_edit": "Fehler beim Laden des Audios zur Bearbeitung",
"ringtone_saved": "Klingelton erfolgreich gespeichert",
"failed_save_ringtone": "Fehler beim Speichern des bearbeiteten Klingeltons"
"failed_save_ringtone": "Fehler beim Speichern des bearbeiteten Klingeltons",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Erste Schritte",

View file

@ -89,9 +89,6 @@
"show_qr": "Show QR Code",
"failed_announce": "failed to announce",
"announce_sent": "Announcement sent",
"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.",
@ -1656,6 +1653,8 @@
"relay_link_invalid": "Invalid relay link",
"relay_link_failed": "Could not open relay link",
"relay_link_disabled": "Relay Chat is disabled in settings",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link",
"map_link_ping_title": "Map ping",
"map_link_open": "Open on map",
"map_link_copy_uri": "Copy meshchatx link",
@ -3366,6 +3365,8 @@
"failed_to_toggle_microphone": "Failed to toggle microphone",
"failed_to_toggle_speaker": "Failed to toggle speaker",
"failed_to_switch_audio_profile": "Failed to switch audio profile",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus.",
"failed_to_initiate_call": "Failed to initiate call",
"enter_identity_hash_to_call_error": "Enter an identity to call",
"failed_to_save_settings": "Failed to save settings",

View file

@ -88,9 +88,6 @@
"show_qr": "Mostrar código QR",
"failed_announce": "anuncio fallido",
"announce_sent": "Anuncio enviado",
"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.",
@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} envió {count} archivos",
"message_not_found_in_cache": "Mensaje no encontrado en caché",
"failed_to_send": "Error al enviar el mensaje",
"failed_to_send_image": "Error al enviar la imagen {index}: {detail}"
"failed_to_send_image": "Error al enviar la imagen {index}: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Guardado a mano",
@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Error al enviar la llamada al buzón de voz",
"failed_load_audio_edit": "No se puede cargar audio para editar",
"ringtone_saved": "Ringtone se salvó con éxito",
"failed_save_ringtone": "Error al guardar el tono editado"
"failed_save_ringtone": "Error al guardar el tono editado",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Contactos",

View file

@ -89,9 +89,6 @@
"show_qr": "Näytä QR-koodi",
"failed_announce": "Kuulutus epäonnistui",
"announce_sent": "Kuulutus lähetetty",
"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.",
@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} lähetti {count} tiedostoa",
"message_not_found_in_cache": "Viestiä ei löytynyt välimuistista",
"failed_to_send": "Viestin lähettäminen epäonnistui",
"failed_to_send_image": "Kuvan {index} lähettäminen epäonnistui: {detail}"
"failed_to_send_image": "Kuvan {index} lähettäminen epäonnistui: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"tabs": {
@ -3358,7 +3357,9 @@
"failed_to_send_to_voicemail": "Puhelun lähettäminen vastaajaan epäonnistui",
"failed_load_audio_edit": "Äänen lataaminen muokkausta varten epäonnistui",
"ringtone_saved": "Soittoääni tallennettu onnistuneesti",
"failed_save_ringtone": "Muokatun soittoäänen tallentaminen epäonnistui"
"failed_save_ringtone": "Muokatun soittoäänen tallentaminen epäonnistui",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Yhteystiedot",

View file

@ -88,9 +88,6 @@
"show_qr": "Afficher le code QR",
"failed_announce": "a échoué à annoncer",
"announce_sent": "Avis envoyé",
"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.",
@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} a envoyé {count} fichiers",
"message_not_found_in_cache": "Message non trouvé dans cache",
"failed_to_send": "Échec de l'envoi du message",
"failed_to_send_image": "Échec de l'envoi de l'image {index} : {detail}"
"failed_to_send_image": "Échec de l'envoi de l'image {index} : {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Raccourci enregistré",
@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Échec de l'envoi de l'appel à la messagerie vocale",
"failed_load_audio_edit": "Impossible de charger l'audio pour l'édition",
"ringtone_saved": "Sonnerie enregistrée avec succès",
"failed_save_ringtone": "Impossible d'enregistrer la sonnerie éditée"
"failed_save_ringtone": "Impossible d'enregistrer la sonnerie éditée",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Personnes-ressources",

View file

@ -88,9 +88,6 @@
"show_qr": "Mostra Codice QR",
"failed_announce": "impossibile annunciare",
"announce_sent": "Annuncio inviato",
"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.",
@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} ha inviato {count} file",
"message_not_found_in_cache": "Messaggio non trovato nella cache",
"failed_to_send": "Impossibile inviare il messaggio",
"failed_to_send_image": "Impossibile inviare l'immagine {index}: {detail}"
"failed_to_send_image": "Impossibile inviare l'immagine {index}: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Scorciatoia salvata",
@ -3220,7 +3219,9 @@
"failed_to_send_to_voicemail": "Impossibile inviare la chiamata alla segreteria",
"failed_load_audio_edit": "Impossibile caricare l'audio per la modifica",
"ringtone_saved": "Suoneria salvata con successo",
"failed_save_ringtone": "Impossibile salvare la suoneria modificata"
"failed_save_ringtone": "Impossibile salvare la suoneria modificata",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Guida Introduttiva",

View file

@ -88,9 +88,6 @@
"show_qr": "QR-code tonen",
"failed_announce": "kon niet aankondigen",
"announce_sent": "Mededeling verzonden",
"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.",
@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} heeft {count} bestanden gestuurd",
"message_not_found_in_cache": "Bericht niet gevonden in cache",
"failed_to_send": "Bericht verzenden mislukt",
"failed_to_send_image": "Afbeelding {index} verzenden mislukt: {detail}"
"failed_to_send_image": "Afbeelding {index} verzenden mislukt: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Sneltoets opgeslagen",
@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Kon gesprek naar voicemail niet versturen",
"failed_load_audio_edit": "Kon audio niet laden om te bewerken",
"ringtone_saved": "Ringtone is succesvol opgeslagen",
"failed_save_ringtone": "Opslaan van bewerkte ringtone mislukt"
"failed_save_ringtone": "Opslaan van bewerkte ringtone mislukt",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Contacten",

View file

@ -355,9 +355,6 @@
"announce_limit_prop": "Prop-узлы",
"failed_announce": "ошибка анонса",
"announce_sent": "Анонс отправлен",
"notifications_no_new": "Нет новых уведомлений",
"notifications_empty_history": "Нет истории уведомлений",
"notifications_history_title": "Недавняя история уведомлений",
"notifications": "Уведомления",
"notification_sound_settings": "Звук уведомления о сообщении",
"notification_sound_settings_description": "Воспроизводит пользовательский звук при получении нового сообщения, пока MeshChat открыт. Сначала загрузите аудиофайл, затем включите воспроизведение.",
@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} отправил(а) файлов: {count}",
"message_not_found_in_cache": "Сообщение не найдено в кэше",
"failed_to_send": "Не удалось отправить сообщение",
"failed_to_send_image": "Не удалось отправить изображение {index}: {detail}"
"failed_to_send_image": "Не удалось отправить изображение {index}: {detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"nomadnet": {
"remove_favourite": "Удалить из избранного",
@ -2975,7 +2974,9 @@
"failed_to_send_to_voicemail": "Не удалось отправить вызов на голосовую почту",
"failed_load_audio_edit": "Не удалось загрузить аудио для редактирования",
"ringtone_saved": "Рингтон успешно сохранён",
"failed_save_ringtone": "Не удалось сохранить отредактированный рингтон"
"failed_save_ringtone": "Не удалось сохранить отредактированный рингтон",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Начало работы",

View file

@ -88,9 +88,6 @@
"show_qr": "显示二维码",
"failed_announce": "广播失败",
"announce_sent": "广播已发送",
"notifications_no_new": "没有新通知",
"notifications_empty_history": "无通知历史",
"notifications_history_title": "最近通知历史",
"notifications": "通知",
"notification_sound_settings": "消息通知声音",
"notification_sound_settings_description": "在 MeshChat 打开时收到新消息播放自定义声音。请先上传音频文件,然后启用播放。",
@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} 发送了 {count} 个文件",
"message_not_found_in_cache": "缓存中未找到消息",
"failed_to_send": "发送消息失败",
"failed_to_send_image": "发送图片 {index} 失败:{detail}"
"failed_to_send_image": "发送图片 {index} 失败:{detail}",
"unknown_meshchatx_link": "Unknown meshchatx link ({host})",
"unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "快捷键已保存",
@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "发送通话到语音信箱失败",
"failed_load_audio_edit": "加载用于编辑的音频失败",
"ringtone_saved": "铃声保存成功",
"failed_save_ringtone": "保存编辑的铃声失败"
"failed_save_ringtone": "保存编辑的铃声失败",
"codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
"codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "联系人",

View file

@ -1,6 +1,6 @@
{
"version": "1.2.0",
"wasm": "sha384-kx5+sGvflCkoO65dt5IcMJVB/8y85IAwT6zUbAKjha1XK/8p3tMp+QFwX5hd2DYN",
"wasm": "sha384-qIr9tHzeIj1WPDNVA/HjxTBvvKMXeIFMWdq3S6ZGXljC/VWgs1IQ9mCbjyVw7NBD",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

View file

@ -50,8 +50,17 @@ function main() {
process.exit(0);
}
const requireWasm =
process.env.MESHCHATX_REQUIRE_VISUALISER_WASM === "1" ||
process.env.MESHCHATX_REQUIRE_VISUALISER_WASM === "true";
if (!fs.existsSync(path.join(GO_MOD_DIR, "go.mod"))) {
console.warn("build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.");
const msg = "build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.";
if (requireWasm) {
console.error(msg);
process.exit(1);
}
console.warn(msg);
process.exit(0);
}
@ -67,7 +76,12 @@ function main() {
console.error("build-visualiser-wasm: MESHCHATX_OFFLINE_BUILD=1 but artifacts missing and go unavailable.");
process.exit(1);
}
console.warn("build-visualiser-wasm: go not found, skipping (JS fallback will be used).");
const msg = "build-visualiser-wasm: go not found, skipping (JS fallback will be used).";
if (requireWasm) {
console.error(msg);
process.exit(1);
}
console.warn(msg);
process.exit(0);
}

File diff suppressed because it is too large Load diff

View file

@ -751,6 +751,8 @@ TELEPHONE_CODEC2_STATUS_SCHEMA: dict = {
"properties": {
"codec2_available": {"type": "boolean"},
"preload_error": {"type": ["string", "null"]},
"probe_error": {"type": ["string", "null"]},
"platform": {"type": "string"},
"preferred_profile_id": {"type": ["integer", "null"]},
"resolved_profile_id": {"type": ["integer", "null"]},
},

View file

@ -9,8 +9,7 @@ from meshchatx import android_codec2
def test_ensure_codec2_skips_non_android():
android_codec2._codec2_preload_done = False
android_codec2._codec2_preload_error = None
android_codec2.reset_codec2_preload_state_for_tests()
with patch.object(android_codec2, "_is_chaquopy_android", return_value=False):
assert android_codec2.ensure_codec2_native_library() is True
assert android_codec2.codec2_preload_error() is None
@ -20,14 +19,13 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
lib = tmp_path / "libcodec2.so"
lib.write_bytes(b"\x7fELF")
android_codec2._codec2_preload_done = False
android_codec2._codec2_preload_error = None
android_codec2.reset_codec2_preload_state_for_tests()
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
patch.object(
android_codec2.ctypes,
"CDLL",
android_codec2,
"_cdll_load",
side_effect=[OSError(), None],
) as cdll,
patch.object(
@ -37,17 +35,43 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
),
):
assert android_codec2.ensure_codec2_native_library() is True
cdll.assert_called_with(str(lib))
assert cdll.call_count == 2
assert cdll.call_args_list[0].args[0] == "libcodec2.so"
assert cdll.call_args_list[1].args[0] == str(lib)
def test_libcodec2_candidates_find_without_importing_pycodec2(tmp_path, monkeypatch):
"""Discovery must not import pycodec2 (extension needs libcodec2 already loaded)."""
site = tmp_path / "site-packages"
pkg = site / "pycodec2"
pkg.mkdir(parents=True)
lib = pkg / "libcodec2.so"
lib.write_bytes(b"\x7fELF")
monkeypatch.syspath_prepend(str(site))
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pycodec2" or name.startswith("pycodec2."):
raise ImportError("pycodec2 must not be imported during candidate search")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=fake_import):
candidates = android_codec2._libcodec2_candidates()
assert lib.resolve() in [c.resolve() for c in candidates]
def test_probe_pycodec2_reports_failure_when_import_breaks():
android_codec2.reset_codec2_preload_state_for_tests()
android_codec2._codec2_preload_done = True
android_codec2._codec2_preload_error = None
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=False),
patch.dict("sys.modules", {"pycodec2": None}),
):
# Force ImportError path by making import raise
import builtins
real_import = builtins.__import__
@ -66,8 +90,6 @@ def test_probe_pycodec2_reports_failure_when_import_breaks():
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
import zipfile
import pytest
repo = Path(__file__).resolve().parents[2]
vendor = repo / "android" / "vendor"
if not vendor.is_dir():
@ -100,8 +122,6 @@ def test_jni_libs_synced_for_all_abis():
def test_android_lxst_wheel_get_codec_guards_missing_codec2():
import zipfile
import pytest
repo = Path(__file__).resolve().parents[2]
whl = repo / "android" / "vendor" / "lxst-0.4.8-py3-none-any.whl"
if not whl.is_file():

View file

@ -367,7 +367,9 @@ async def test_lxm_ingest_docs_hostname_spoof_not_docs_view(mock_app):
payload = json.loads(mock_client.send_str.call_args[0][0])
assert payload.get("ingest_type") != "docs_view"
mock_app.message_router.ingest_lxm_uri.assert_called()
assert payload.get("ingest_type") == "unknown_meshchatx"
assert payload.get("status") == "error"
mock_app.message_router.ingest_lxm_uri.assert_not_called()
@pytest.mark.parametrize(
@ -443,6 +445,30 @@ def test_meshchatx_docs_query_tail_fuzzing(mock_app, tail):
assert payload["ingest_type"] == "docs_view"
@pytest.mark.asyncio
async def test_unknown_meshchatx_host_does_not_fall_through_to_lxmf(mock_app):
mock_client = MagicMock()
mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
mock_app.message_router.ingest_lxm_uri = MagicMock()
with patch(
"meshchatx.meshchat.AsyncUtils.run_async",
side_effect=lambda coro: asyncio.create_task(coro),
):
await mock_app.on_websocket_data_received(
mock_client,
{"type": "lxm.ingest_uri", "uri": "meshchatx://not-a-real-host?x=1"},
)
await asyncio.sleep(0)
mock_app.message_router.ingest_lxm_uri.assert_not_called()
payload = json.loads(mock_client.send_str.call_args[0][0])
assert payload["type"] == "lxm.ingest_uri.result"
assert payload["status"] == "error"
assert payload["ingest_type"] == "unknown_meshchatx"
assert payload["host"] == "not-a-real-host"
def test_telemetry_pack_location_xss_like_strings_return_none():
from meshchatx.src.backend.telemetry_utils import Telemeter

View file

@ -114,7 +114,13 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
fake_identity = MagicMock()
fake_identity.hash = bytes.fromhex("bb" * 16)
fake_identity.load_public_key.return_value = True
fake_identity.pub = object()
fake_identity.get_public_key.return_value = b"\x11" * 64
def load_public_key(_key_bytes):
return None
fake_identity.load_public_key.side_effect = load_public_key
mock_app.config.auth_enabled.get.return_value = False
@ -124,6 +130,7 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
side_effect=lambda coro: asyncio.create_task(coro),
),
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
patch("meshchatx.meshchat.RNS.Identity.remember") as remember_mock,
):
await mock_app.on_websocket_data_received(
mock_client,
@ -139,6 +146,7 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
lxmf_address="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
remember_mock.assert_called_once()
mock_app.message_router.ingest_lxm_uri.assert_not_called()
mock_client.send_str.assert_called_once()
payload = json.loads(mock_client.send_str.call_args[0][0])
@ -160,9 +168,18 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
fake_identity = MagicMock()
fake_identity.hash = bytes.fromhex("bb" * 16)
fake_identity.pub = object()
fake_identity.get_public_key.return_value = b"\x01" * 64
def load_public_key(key_bytes):
return len(key_bytes) == 64
# Match real RNS: return None even on success.
if len(key_bytes) != 64:
fake_identity.pub = None
fake_identity.hash = None
return None
fake_identity.pub = object()
fake_identity.hash = bytes.fromhex("bb" * 16)
return None
fake_identity.load_public_key.side_effect = load_public_key
@ -174,6 +191,7 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
side_effect=lambda coro: asyncio.create_task(coro),
),
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
patch("meshchatx.meshchat.RNS.Identity.remember"),
):
await mock_app.on_websocket_data_received(
mock_client,
@ -192,6 +210,19 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
assert len(fake_identity.load_public_key.call_args[0][0]) == 64
def test_identity_from_public_key_bytes_accepts_real_rns_none_return():
"""Regression for issue #21: RNS load_public_key returns None on success."""
import RNS
from meshchatx.meshchat import ReticulumMeshChat
source = RNS.Identity()
pub = source.get_public_key()
loaded = ReticulumMeshChat._identity_from_public_key_bytes(pub)
assert loaded is not None
assert loaded.hash == source.hash
assert ReticulumMeshChat._identity_from_public_key_bytes(b"\x00" * 8) is None
@pytest.mark.asyncio
async def test_on_lxmf_sending_state_updated(mock_app):
mock_msg = MagicMock()

View file

@ -1,11 +1,7 @@
const { test, expect } = require("@playwright/test");
const { prepareE2eSession } = require("./helpers");
function topChrome(page) {
return page.locator("div.sticky.top-0.z-\\[100\\]").first();
}
test.describe("Shell: sidebar, theme, notifications, call, search", () => {
test.describe("Shell: sidebar, theme, call, search", () => {
test.beforeEach(async ({ request }) => {
await prepareE2eSession(request);
});
@ -34,23 +30,6 @@ test.describe("Shell: sidebar, theme, notifications, call, search", () => {
await expect.poll(async () => shell.evaluate((el) => el.classList.contains("dark"))).toBe(initialDark);
});
test("notification bell opens panel and closes from header", async ({ page }) => {
await page.goto("/#/messages");
await topChrome(page)
.locator("button")
.filter({ has: page.locator('svg[aria-label="bell"]') })
.click();
await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeVisible({
timeout: 15000,
});
await expect(page.getByText("No new notifications", { exact: true })).toBeVisible({ timeout: 10000 });
const panel = page.locator("div.fixed").filter({ hasText: "Notifications" }).first();
await panel.locator('svg[aria-label="close"]').click();
await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeHidden({
timeout: 5000,
});
});
test("call route shows Phone tab", async ({ page }) => {
await page.goto("/#/call");
await expect(page).toHaveURL(/#\/call/);

View file

@ -120,7 +120,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@ -181,7 +180,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@ -216,7 +214,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@ -262,7 +259,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,

View file

@ -54,7 +54,6 @@ const routes = [
const appStubs = {
MaterialDesignIcon: { template: '<span class="md-stub" />' },
LxmfUserIcon: { template: "<div />" },
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,

View file

@ -229,7 +229,6 @@ describe("App telephone_ringing policy regressions", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,

View file

@ -157,4 +157,34 @@ describe("CallOverlay.vue", () => {
expect(wrapper.text()).toContain("5 GB");
expect(wrapper.text()).toContain("500 MB");
});
it("navigates to Call phone tab after answering", async () => {
const push = vi.fn().mockResolvedValue(undefined);
global.api = { get: vi.fn().mockResolvedValue({}) };
const wrapper = mount(CallOverlay, {
props: {
...defaultProps,
activeCall: {
...defaultProps.activeCall,
is_incoming: true,
status: 4,
},
},
global: {
mocks: {
$t: (key) => key,
$router: { push },
$route: { name: "messages", query: {} },
},
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
AudioWaveformPlayer: true,
},
},
});
await wrapper.vm.answerCall();
expect(global.api.get).toHaveBeenCalledWith("/api/v1/telephone/answer");
expect(push).toHaveBeenCalledWith({ name: "call", query: { tab: "phone" } });
});
});

View file

@ -122,6 +122,44 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.emitted("reload-conversations")).toBeFalsy();
});
it("markConversationAsRead force marks read even when local conversation looks already read", async () => {
const wrapper = mountConversationViewer();
await flushPromises();
axiosMock.post.mockClear();
GlobalEmitter.emit.mockClear();
const conversation = { destination_hash: "open-hash", is_unread: false };
await wrapper.vm.markConversationAsRead(conversation, { force: true });
await flushPromises();
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(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
});
it("onLxmfMessageReceived force marks the open conversation as read", async () => {
const conversations = [{ destination_hash: "open-peer", is_unread: false }];
const wrapper = mountConversationViewer({
selectedPeer: { destination_hash: "open-peer", display_name: "Open" },
conversations,
});
await flushPromises();
axiosMock.post.mockClear();
wrapper.vm.onLxmfMessageReceived({
source_hash: "open-peer",
hash: "msg-1",
content: "hello",
timestamp: 1,
});
await flushPromises();
const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
expect(markCalls).toHaveLength(1);
expect(conversations[0].is_unread).toBe(false);
});
it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => {
const wrapper = mountConversationViewer();
await flushPromises();

View file

@ -606,7 +606,7 @@ describe("MessagesPage.vue", () => {
expect(wrapper.vm.selectedPeer.display_name).toBe("Existing Peer");
});
it("onBulkMarkAsRead notifies notification bell after server confirms", async () => {
it("onBulkMarkAsRead emits notifications-changed after server confirms", async () => {
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
axiosMock.post.mockResolvedValue({ data: {} });

View file

@ -1,940 +0,0 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
let wsHandlers = {};
let emitterHandlers = {};
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
on: vi.fn((event, handler) => {
wsHandlers[event] = wsHandlers[event] || [];
wsHandlers[event].push(handler);
}),
off: vi.fn((event, handler) => {
if (wsHandlers[event]) {
wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
}
}),
},
}));
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
default: {
on: vi.fn((event, handler) => {
emitterHandlers[event] = emitterHandlers[event] || [];
emitterHandlers[event].push(handler);
}),
off: vi.fn((event, handler) => {
if (emitterHandlers[event]) {
emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
}
}),
emit: vi.fn((event, payload) => {
(emitterHandlers[event] || []).forEach((h) => h(payload));
}),
},
}));
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: (d) => "1h ago" },
}));
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
function mountBell(options = {}) {
return mount(NotificationBell, {
global: {
components: { MaterialDesignIcon },
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
mocks: {
$router: { push: vi.fn() },
$t: (key) => {
const map = {
"app.notifications_no_new": "No new notifications",
"app.notifications_empty_history": "No notification history",
"app.notifications_history_title": "Recent notification history",
};
return map[key] || key;
},
},
},
...options,
});
}
function simulateWsMessage(type, extra = {}) {
const payload = { type, ...extra };
if (type === "lxmf.delivery" && payload.lxmf_message === undefined) {
// Default to a user-facing inbound text message so the bell will
// reload. Tests that want to exercise the false-trigger path pass an
// explicit lxmf_message override (reaction, telemetry, empty, etc.).
payload.lxmf_message = { is_incoming: true, content: "hello", title: "", fields: {} };
}
const data = JSON.stringify(payload);
(wsHandlers["message"] || []).forEach((h) => h({ data }));
}
describe("NotificationBell UI", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("renders bell button", () => {
const wrapper = mountBell();
const btn = wrapper.find("button.relative.rounded-full");
expect(btn.exists()).toBe(true);
});
it("shows unread badge when unreadCount > 0", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
wrapper.vm.unreadCount = 5;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("5");
});
it("shows 9+ when unreadCount > 9", async () => {
const wrapper = mountBell();
wrapper.vm.unreadCount = 12;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("9+");
});
it("opens dropdown on button click", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.vm.isDropdownOpen).toBe(true);
expect(document.body.textContent).toContain("Notifications");
wrapper.unmount();
});
it("shows Clear button when dropdown open and notifications exist", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{ destination_hash: "h1", display_name: "A", updated_at: new Date().toISOString(), content: "Hi" },
],
unread_count: 1,
},
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(document.body.textContent).toContain("Clear");
wrapper.unmount();
});
it("shows No new notifications when empty", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 150));
expect(document.body.textContent).toContain("No new notifications");
wrapper.unmount();
});
it("opening empty dropdown adds one notifications fetch after mount", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: { notifications: [], unread_count: 0 },
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.vm.$nextTick();
const notifGetsAfterMount = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 150));
const notifGetsAfterOpen = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(notifGetsAfterOpen - notifGetsAfterMount).toBe(1);
wrapper.unmount();
});
it("dropdown has Notifications heading when open", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await wrapper.vm.$nextTick();
const h3 = document.body.querySelector("h3");
expect(h3?.textContent).toBe("Notifications");
wrapper.unmount();
});
});
describe("NotificationBell websocket reliability", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("reloads on lxmf.delivery websocket event", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
global.api.get = vi.fn().mockResolvedValue({
data: { notifications: [{ destination_hash: "d1", display_name: "X", content: "msg" }], unread_count: 1 },
});
simulateWsMessage("lxmf.delivery");
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(1);
});
it("reloads on telephone_missed_call websocket event", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{
id: 1,
type: "telephone_missed_call",
destination_hash: "c1",
display_name: "Caller",
content: "Missed",
},
],
unread_count: 1,
},
});
simulateWsMessage("telephone_missed_call", { remote_identity_hash: "c1" });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(1);
});
it("reloads on new_voicemail websocket event", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{
id: 2,
type: "telephone_voicemail",
destination_hash: "v1",
display_name: "VM",
content: "Voicemail",
},
],
unread_count: 1,
},
});
simulateWsMessage("new_voicemail", { remote_identity_hash: "v1" });
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(1);
});
it("does NOT reload on unrelated websocket events", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const callsBefore = global.api.get.mock.calls.length;
simulateWsMessage("telephone_ringing");
simulateWsMessage("telephone_call_ended");
simulateWsMessage("lxmf_message_state_updated");
simulateWsMessage("lxmf.delivery", { lxmf_message: { is_incoming: false } });
await new Promise((r) => setTimeout(r, 50));
expect(global.api.get.mock.calls.length).toBe(callsBefore);
});
it("does NOT reload on outbound lxmf.delivery (delivery confirmation path)", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const callsBefore = global.api.get.mock.calls.length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: false, state: "delivered" },
});
await new Promise((r) => setTimeout(r, 50));
expect(global.api.get.mock.calls.length).toBe(callsBefore);
});
it("reloads on inbound lxmf.delivery", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: { notifications: [], unread_count: 0 },
});
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const callsAfterMount = global.api.get.mock.calls.length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: true, content: "hi", title: "", fields: {} },
});
await new Promise((r) => setTimeout(r, 50));
expect(global.api.get.mock.calls.length).toBeGreaterThan(callsAfterMount);
const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
expect(notifCalls.length).toBeGreaterThan(0);
});
it("rapid sequential websocket events all trigger reloads", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const initialCalls = global.api.get.mock.calls.length;
for (let i = 0; i < 5; i++) {
simulateWsMessage("lxmf.delivery");
}
await new Promise((r) => setTimeout(r, 100));
expect(global.api.get.mock.calls.length).toBeGreaterThan(initialCalls);
});
it("ignores malformed websocket payload without crashing", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const initialCalls = global.api.get.mock.calls.length;
(wsHandlers["message"] || []).forEach((h) => h({ data: "not-json{" }));
await new Promise((r) => setTimeout(r, 30));
expect(global.api.get.mock.calls.length).toBe(initialCalls);
});
});
describe("NotificationBell false-trigger suppression", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
function expectNoNotificationsReload(callsBefore) {
const notifGets = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
expect(notifGets.length).toBe(callsBefore);
}
it("does NOT reload on inbound reaction (is_reaction flag)", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
is_reaction: true,
content: "",
fields: { reaction: { reaction_to: "abc", reaction_content: "fire" } },
},
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload on inbound reaction signaled only via fields.reaction", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
fields: { reaction: { reaction_to: "abc", reaction_content: "\u{1F44D}" } },
},
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload on inbound telemetry-only message", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
title: "",
fields: { telemetry: { something: 1 } },
},
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload on inbound icon-only / empty payload message", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: true, content: "", title: "", fields: {} },
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload when content is whitespace only", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: true, content: " \n\t ", title: "", fields: {} },
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload on lxmf.delivery without lxmf_message field", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
(wsHandlers["message"] || []).forEach((h) => h({ data: JSON.stringify({ type: "lxmf.delivery" }) }));
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("does NOT reload on lxmf_message_state_updated (delivery status)", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf_message_state_updated", {
lxmf_message: { is_incoming: false, state: "delivered" },
});
await new Promise((r) => setTimeout(r, 30));
expectNoNotificationsReload(before);
});
it("DOES reload on real inbound text message", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
});
await new Promise((r) => setTimeout(r, 30));
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(after).toBeGreaterThan(before);
});
it("DOES reload on inbound title-only message", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: { is_incoming: true, content: "", title: "Subject", fields: {} },
});
await new Promise((r) => setTimeout(r, 30));
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(after).toBeGreaterThan(before);
});
it("DOES reload on inbound image attachment", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
fields: { image: { image_size: 1024, image_type: "png" } },
},
});
await new Promise((r) => setTimeout(r, 30));
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(after).toBeGreaterThan(before);
});
it("DOES reload on inbound audio attachment", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
fields: { audio: { audio_size: 4242, audio_mode: 1 } },
},
});
await new Promise((r) => setTimeout(r, 30));
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(after).toBeGreaterThan(before);
});
it("DOES reload on inbound file attachment", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
fields: { file_attachments: [{ file_name: "x.txt", file_size: 5 }] },
},
});
await new Promise((r) => setTimeout(r, 30));
const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
expect(after).toBeGreaterThan(before);
});
it("isUserFacingLxmfDelivery method directly classifies common payloads", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const fn = wrapper.vm.isUserFacingLxmfDelivery;
expect(fn(null)).toBe(false);
expect(fn(undefined)).toBe(false);
expect(fn({ is_incoming: false, content: "hello" })).toBe(false);
expect(fn({ is_incoming: true, content: "hello" })).toBe(true);
expect(fn({ is_incoming: true, content: "", title: "" })).toBe(false);
expect(fn({ is_incoming: true, content: "", title: "Subject" })).toBe(true);
expect(fn({ is_incoming: true, is_reaction: true, content: "still ignored" })).toBe(false);
expect(
fn({
is_incoming: true,
content: "",
fields: { reaction: { reaction_to: "x", reaction_content: "\u{1F44D}" } },
})
).toBe(false);
expect(fn({ is_incoming: true, content: "", fields: { telemetry: { x: 1 } } })).toBe(false);
expect(fn({ is_incoming: true, content: "", fields: { image: { image_size: 1 } } })).toBe(true);
expect(fn({ is_incoming: true, content: "", fields: { audio: { audio_size: 1 } } })).toBe(true);
expect(
fn({
is_incoming: true,
content: "",
fields: { file_attachments: [{ file_name: "a", file_size: 1 }] },
})
).toBe(true);
expect(fn({ is_incoming: true, content: "", fields: { file_attachments: [] } })).toBe(false);
});
it("badge stays at zero through a flood of reaction events", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
for (let i = 0; i < 25; i++) {
simulateWsMessage("lxmf.delivery", {
lxmf_message: {
is_incoming: true,
content: "",
fields: { reaction: { reaction_to: `m${i}`, reaction_content: "\u{1F44D}" } },
},
});
}
await new Promise((r) => setTimeout(r, 80));
expect(wrapper.vm.unreadCount).toBe(0);
});
});
describe("NotificationBell badge accuracy", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("badge hidden when unread count is 0", async () => {
const wrapper = mountBell();
await wrapper.vm.$nextTick();
const badge = wrapper.find("span.bg-red-500");
expect(badge.exists()).toBe(false);
});
it("badge shows exact count for 1-9", async () => {
for (let n = 1; n <= 9; n++) {
const wrapper = mountBell();
wrapper.vm.unreadCount = n;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(String(n));
}
});
it("badge shows 9+ for counts above 9", async () => {
for (const n of [10, 50, 100, 999]) {
const wrapper = mountBell();
wrapper.vm.unreadCount = n;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("9+");
expect(wrapper.text()).not.toContain(String(n));
}
});
it("badge updates reactively when unreadCount changes", async () => {
const wrapper = mountBell();
wrapper.vm.unreadCount = 3;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("3");
wrapper.vm.unreadCount = 0;
await wrapper.vm.$nextTick();
expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
wrapper.vm.unreadCount = 15;
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("9+");
});
it("opening dropdown syncs unread count from server after mark-as-viewed", async () => {
global.api.get = vi
.fn()
.mockResolvedValueOnce({
data: {
notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
unread_count: 3,
},
})
.mockResolvedValueOnce({
data: {
notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
unread_count: 3,
},
})
.mockResolvedValue({
data: { notifications: [], unread_count: 0 },
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.vm.$nextTick();
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 80));
expect(wrapper.vm.unreadCount).toBe(0);
wrapper.unmount();
});
it("API failure does not cause false badge", async () => {
global.api.get = vi.fn().mockRejectedValue(new Error("Network error"));
const wrapper = mountBell();
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(0);
expect(wrapper.vm.notifications).toEqual([]);
});
it("API returning null/empty fields does not cause false badge", async () => {
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: null, unread_count: null } });
const wrapper = mountBell();
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(0);
});
});
describe("NotificationBell mark-as-viewed", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("calls mark-as-viewed API when dropdown is opened", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{ type: "lxmf_message", destination_hash: "abc", display_name: "A", content: "x" },
{ type: "telephone_missed_call", id: 42, destination_hash: "mc", display_name: "B", content: "y" },
],
unread_count: 2,
},
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 100));
const postCalls = global.api.post.mock.calls;
const markCall = postCalls.find((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
expect(markCall).toBeTruthy();
expect(markCall[1].destination_hashes).toContain("abc");
expect(markCall[1].notification_ids).toContain(42);
wrapper.unmount();
});
it("skips mark-as-viewed when no notifications", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 50));
const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
expect(markCalls.length).toBe(0);
wrapper.unmount();
});
});
describe("NotificationBell history", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("shows history control when dropdown is open", async () => {
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button.relative.rounded-full").trigger("click");
await wrapper.vm.$nextTick();
const historyBtn = document.body.querySelector('[aria-label="Recent notification history"]');
expect(historyBtn).toBeTruthy();
wrapper.unmount();
});
it("requests unread=false when toggling history on", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{
id: 9,
type: "telephone_missed_call",
destination_hash: "ab",
display_name: "X",
content: "missed",
},
],
unread_count: 0,
},
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.vm.$nextTick();
await wrapper.find("button.relative.rounded-full").trigger("click");
await new Promise((r) => setTimeout(r, 120));
global.api.get.mockClear();
await wrapper.vm.toggleHistory();
await wrapper.vm.$nextTick();
const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
expect(notifCalls.length).toBeGreaterThan(0);
const lastParams = notifCalls[notifCalls.length - 1][1].params;
expect(lastParams.unread).toBe(false);
expect(wrapper.vm.showHistory).toBe(true);
wrapper.unmount();
});
it("resets history mode when dropdown closes", async () => {
const wrapper = mountBell({ attachTo: document.body });
wrapper.vm.showHistory = true;
wrapper.vm.closeDropdown();
expect(wrapper.vm.showHistory).toBe(false);
});
it("shows empty history copy in history mode", async () => {
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button.relative.rounded-full").trigger("click");
await new Promise((r) => setTimeout(r, 120));
await wrapper.vm.toggleHistory();
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(document.body.textContent).toContain("No notification history");
wrapper.unmount();
});
});
describe("NotificationBell clear all", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("clears all notifications and marks conversations as read", async () => {
let callCount = 0;
global.api.get = vi.fn().mockImplementation((url) => {
if (url === "/api/v1/notifications") {
callCount++;
if (callCount <= 2) {
return Promise.resolve({
data: {
notifications: [{ destination_hash: "x", display_name: "X", content: "m" }],
unread_count: 1,
},
});
}
return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
}
if (url === "/api/v1/lxmf/conversations") {
return Promise.resolve({
data: {
conversations: [
{ destination_hash: "conv1", is_unread: true },
{ destination_hash: "conv2", is_unread: false },
],
},
});
}
return Promise.resolve({ data: {} });
});
const wrapper = mountBell({ attachTo: document.body });
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 100));
await wrapper.vm.clearAllNotifications();
await new Promise((r) => setTimeout(r, 100));
const readCalls = global.api.post.mock.calls.filter((c) => c[0]?.includes("/mark-as-read"));
expect(readCalls.length).toBe(1);
expect(readCalls[0][0]).toContain("conv1");
wrapper.unmount();
});
});
describe("NotificationBell live sync", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
emitterHandlers = {};
global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
global.api.post = vi.fn().mockResolvedValue({ data: {} });
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
});
it("refreshes badge when conversations are marked read elsewhere", async () => {
let callCount = 0;
global.api.get = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: { notifications: [], unread_count: 3 } });
}
return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
});
const wrapper = mountBell();
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(3);
(emitterHandlers["notifications-changed"] || []).forEach((h) => h());
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.vm.unreadCount).toBe(0);
expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
wrapper.unmount();
});
it("subscribes to notifications-changed on mount and unsubscribes on destroy", () => {
const wrapper = mountBell();
expect(emitterHandlers["notifications-changed"]?.length).toBeGreaterThan(0);
const handler = emitterHandlers["notifications-changed"][0];
wrapper.unmount();
expect(emitterHandlers["notifications-changed"] || []).not.toContain(handler);
});
it("polls unread count every 5s while dropdown is closed", async () => {
vi.useFakeTimers();
let unread = 4;
global.api.get = vi
.fn()
.mockImplementation(() => Promise.resolve({ data: { notifications: [], unread_count: unread } }));
const wrapper = mountBell();
await vi.runOnlyPendingTimersAsync();
expect(wrapper.vm.unreadCount).toBe(4);
unread = 0;
await vi.advanceTimersByTimeAsync(5000);
await vi.runOnlyPendingTimersAsync();
expect(wrapper.vm.unreadCount).toBe(0);
expect(wrapper.vm.isDropdownOpen).toBe(false);
expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
vi.useRealTimers();
wrapper.unmount();
});
it("keeps badge visible until server confirms read (no optimistic clear)", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: {
notifications: [
{ type: "lxmf_message", destination_hash: "d1", display_name: "A", latest_message_preview: "hi" },
],
unread_count: 1,
},
});
const wrapper = mountBell();
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
(emitterHandlers["notifications-changed"] || []).forEach((h) => h());
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
global.api.get.mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
(emitterHandlers["notifications-changed"] || []).forEach((h) => h());
await new Promise((r) => setTimeout(r, 50));
expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
wrapper.unmount();
});
it("opening bell with already-read server state shows empty list and clears stale badge", async () => {
global.api.get = vi.fn().mockResolvedValue({
data: { notifications: [], unread_count: 0 },
});
const wrapper = mountBell({ attachTo: document.body });
wrapper.vm.unreadCount = 5;
await wrapper.vm.$nextTick();
expect(wrapper.find("span.bg-red-500").text()).toBe("5");
await wrapper.find("button").trigger("click");
await new Promise((r) => setTimeout(r, 80));
expect(wrapper.vm.unreadCount).toBe(0);
expect(wrapper.vm.notifications).toEqual([]);
expect(document.body.textContent).toContain("No new notifications");
const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
expect(markCalls).toHaveLength(0);
wrapper.unmount();
});
});

View file

@ -1,313 +0,0 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
let wsHandlers = {};
let emitterHandlers = {};
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
default: {
on: vi.fn((event, handler) => {
wsHandlers[event] = wsHandlers[event] || [];
wsHandlers[event].push(handler);
}),
off: vi.fn((event, handler) => {
if (wsHandlers[event]) {
wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
}
}),
},
}));
vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
default: {
on: vi.fn((event, handler) => {
emitterHandlers[event] = emitterHandlers[event] || [];
emitterHandlers[event].push(handler);
}),
off: vi.fn((event, handler) => {
if (emitterHandlers[event]) {
emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
}
}),
emit: vi.fn((event, payload) => {
(emitterHandlers[event] || []).forEach((h) => h(payload));
}),
},
}));
vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
default: { formatTimeAgo: () => "1h ago" },
}));
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
import ConversationViewer from "../../meshchatx/src/frontend/components/messages/ConversationViewer.vue";
const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
const PEER_HASH = "bb".repeat(16);
function simulateWsDelivery() {
const data = JSON.stringify({
type: "lxmf.delivery",
lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
});
(wsHandlers["message"] || []).forEach((h) => h({ data }));
}
function mountBell() {
return mount(NotificationBell, {
global: {
components: { MaterialDesignIcon },
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
mocks: {
$router: { push: vi.fn() },
$t: (key) => {
const map = {
"app.notifications_no_new": "No new notifications",
"app.notifications_empty_history": "No notification history",
"app.notifications_history_title": "Recent notification history",
};
return map[key] || key;
},
},
},
});
}
function mountViewer() {
return mount(ConversationViewer, {
props: {
selectedPeer: { destination_hash: PEER_HASH, display_name: "Peer" },
myLxmfAddressHash: "aa".repeat(16),
conversations: [{ destination_hash: PEER_HASH, display_name: "Peer", is_unread: true }],
},
global: {
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
mocks: {
$t: (key) => key,
$route: { meta: {} },
$router: { push: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
AddImageButton: true,
AddAudioButton: true,
SendMessageButton: true,
ConversationDropDownMenu: true,
PaperMessageModal: true,
AudioWaveformPlayer: true,
LxmfUserIcon: true,
ConversationPeerHeader: true,
ConversationMessageEntry: true,
ConversationMessageListVirtual: true,
},
},
});
}
function createNotificationsApiMock() {
let conversationRead = false;
const get = vi.fn().mockImplementation((_url, config) => {
const unreadOnly = config?.params?.unread === true;
if (!conversationRead) {
const item = {
type: "lxmf_message",
destination_hash: PEER_HASH,
display_name: "Peer",
latest_message_preview: "hello",
updated_at: new Date().toISOString(),
};
return Promise.resolve({
data: {
notifications: unreadOnly ? [item] : [item],
unread_count: 1,
},
});
}
return Promise.resolve({
data: {
notifications: [],
unread_count: 0,
},
});
});
const post = vi.fn().mockImplementation((url) => {
if (String(url).includes("/mark-as-read")) {
conversationRead = true;
}
return Promise.resolve({ data: {} });
});
return {
get,
post,
markRead: () => {
conversationRead = true;
},
isRead: () => conversationRead,
};
}
describe("NotificationBell conversation read sync", () => {
beforeEach(() => {
vi.clearAllMocks();
wsHandlers = {};
emitterHandlers = {};
window.URL.createObjectURL = vi.fn(() => "blob:mock");
vi.stubGlobal(
"FileReader",
vi.fn(function () {
return { readAsDataURL: vi.fn() };
})
);
vi.stubGlobal("localStorage", {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
});
});
afterEach(() => {
wsHandlers = {};
emitterHandlers = {};
vi.unstubAllGlobals();
});
it("reproduces stale badge: delivery raises count, read in Messages clears without opening bell", async () => {
const api = createNotificationsApiMock();
window.api = { get: api.get, post: api.post };
const bell = mountBell();
await flushPromises();
expect(bell.vm.unreadCount).toBe(1);
expect(bell.find("span.bg-red-500").exists()).toBe(true);
simulateWsDelivery();
await flushPromises();
expect(bell.vm.unreadCount).toBe(1);
const viewer = mountViewer();
await flushPromises();
const conversation = { destination_hash: PEER_HASH, is_unread: true };
await viewer.vm.markConversationAsRead(conversation);
await flushPromises();
expect(api.post).toHaveBeenCalledWith(`/api/v1/lxmf/conversations/${PEER_HASH}/mark-as-read`);
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
expect(bell.vm.unreadCount).toBe(0);
expect(bell.find("span.bg-red-500").exists()).toBe(false);
bell.unmount();
viewer.unmount();
});
it("stale badge clears on bell click when server already has zero unread (empty dropdown)", async () => {
const api = createNotificationsApiMock();
api.markRead();
window.api = { get: api.get, post: api.post };
const bell = mountBell();
bell.vm.unreadCount = 3;
await bell.vm.$nextTick();
expect(bell.find("span.bg-red-500").text()).toBe("3");
await bell.find("button").trigger("click");
await flushPromises();
await new Promise((r) => setTimeout(r, 80));
expect(bell.vm.unreadCount).toBe(0);
expect(bell.vm.notifications).toEqual([]);
expect(document.body.textContent).toContain("No new notifications");
const markCalls = api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
expect(markCalls).toHaveLength(0);
bell.unmount();
});
it("background poll refreshes badge while dropdown stays closed", async () => {
vi.useFakeTimers();
const api = createNotificationsApiMock();
window.api = { get: api.get, post: api.post };
const bell = mountBell();
await flushPromises();
expect(bell.vm.unreadCount).toBe(1);
api.markRead();
await vi.advanceTimersByTimeAsync(5000);
await flushPromises();
expect(bell.vm.unreadCount).toBe(0);
expect(bell.vm.isDropdownOpen).toBe(false);
vi.useRealTimers();
bell.unmount();
});
it("does not emit notifications-changed when mark-as-read API fails", async () => {
const api = createNotificationsApiMock();
api.post.mockImplementation((url) => {
if (String(url).includes("/mark-as-read")) {
return Promise.reject(new Error("network"));
}
return Promise.resolve({ data: {} });
});
window.api = { get: api.get, post: api.post };
const viewer = mountViewer();
await flushPromises();
GlobalEmitter.emit.mockClear();
const conversation = { destination_hash: PEER_HASH, is_unread: true };
await viewer.vm.markConversationAsRead(conversation);
await flushPromises();
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
expect(conversation.is_unread).toBe(true);
viewer.unmount();
});
it("badge count tracks API unread_count after websocket delivery", async () => {
let unread = 0;
window.api = {
get: vi.fn().mockImplementation(() =>
Promise.resolve({
data: {
notifications:
unread > 0
? [
{
type: "lxmf_message",
destination_hash: PEER_HASH,
display_name: "Peer",
latest_message_preview: "ping",
},
]
: [],
unread_count: unread,
},
})
),
post: vi.fn().mockResolvedValue({ data: {} }),
};
const bell = mountBell();
await flushPromises();
expect(bell.vm.unreadCount).toBe(0);
unread = 2;
simulateWsDelivery();
await flushPromises();
expect(bell.vm.unreadCount).toBe(2);
expect(bell.find("span.bg-red-500").text()).toBe("2");
unread = 0;
GlobalEmitter.emit("notifications-changed");
await flushPromises();
expect(bell.vm.unreadCount).toBe(0);
bell.unmount();
});
});

View file

@ -6,7 +6,6 @@ import SettingsPage from "../../meshchatx/src/frontend/components/settings/Setti
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
import ChangelogModal from "../../meshchatx/src/frontend/components/ChangelogModal.vue";
import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue";
vi.mock("vuetify", () => ({
@ -119,7 +118,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -149,7 +147,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -179,7 +176,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -224,7 +220,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -270,7 +265,6 @@ describe("Theme Switching", () => {
props: ["iconName"],
},
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -501,7 +495,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -529,7 +522,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -557,7 +549,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -583,7 +574,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: '<div data-icon-name="{{ iconName }}"></div>' },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@ -614,7 +604,6 @@ describe("Dark Mode Class Application", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],

View file

@ -80,11 +80,6 @@ describe("clampFloatingToViewport wiring", () => {
it.each([
["DropDownMenu.vue", "meshchatx/src/frontend/components/DropDownMenu.vue", 'ref="dropdownPanel"'],
["LanguageSelector.vue", "meshchatx/src/frontend/components/LanguageSelector.vue", 'ref="languageDropdown"'],
[
"NotificationBell.vue",
"meshchatx/src/frontend/components/NotificationBell.vue",
'ref="notificationDropdown"',
],
[
"ConversationViewer.vue",
"meshchatx/src/frontend/components/messages/ConversationViewer.vue",