mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: integrate visualiser WASM build into Dockerfile and update frontend to handle codec2 availability
This commit is contained in:
parent
58bfaf379e
commit
389034dbea
44 changed files with 2192 additions and 3549 deletions
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Начало работы",
|
||||
|
|
|
|||
|
|
@ -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": "联系人",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"]},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -229,7 +229,6 @@ describe("App telephone_ringing policy regressions", () => {
|
|||
stubs: {
|
||||
MaterialDesignIcon: true,
|
||||
LxmfUserIcon: true,
|
||||
NotificationBell: true,
|
||||
LanguageSelector: true,
|
||||
CallOverlay: true,
|
||||
CommandPalette: true,
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {} });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue