fix: RRC message stacking, NomadNet favourites/sections, and desktop close prompt

This commit is contained in:
Ivan 2026-07-09 03:46:25 -05:00
parent 1a6f7f337f
commit d2456c8ac3
No known key found for this signature in database
33 changed files with 1206 additions and 75 deletions

121
electron/closeBehavior.js Normal file
View file

@ -0,0 +1,121 @@
const fs = require("fs");
const path = require("node:path");
const CLOSE_BEHAVIORS = new Set(["ask", "quit", "background"]);
function defaultCloseSettings() {
return {
closeBehavior: "ask",
trayEnabled: true,
};
}
function closeSettingsPath(storageDir) {
return path.join(storageDir, "desktop-close-settings.json");
}
function normalizeCloseSettings(raw) {
const defaults = defaultCloseSettings();
if (!raw || typeof raw !== "object") {
return defaults;
}
const closeBehavior = CLOSE_BEHAVIORS.has(raw.closeBehavior) ? raw.closeBehavior : defaults.closeBehavior;
const trayEnabled = typeof raw.trayEnabled === "boolean" ? raw.trayEnabled : defaults.trayEnabled;
return { closeBehavior, trayEnabled };
}
function loadCloseSettings(storageDir) {
try {
const filePath = closeSettingsPath(storageDir);
if (!fs.existsSync(filePath)) {
return defaultCloseSettings();
}
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
return normalizeCloseSettings(parsed);
} catch {
return defaultCloseSettings();
}
}
function saveCloseSettings(storageDir, partial) {
const current = loadCloseSettings(storageDir);
const next = normalizeCloseSettings({
...current,
...(partial && typeof partial === "object" ? partial : {}),
});
try {
fs.mkdirSync(storageDir, { recursive: true });
fs.writeFileSync(closeSettingsPath(storageDir), JSON.stringify(next, null, 2), "utf8");
} catch {
// ignore persistence failures; in-memory choice still applies for this session
}
return next;
}
/**
* Resolve what to do on window close.
* @returns {"ask"|"quit"|"background"|"minimize"}
*/
function resolveCloseAction(settings) {
const normalized = normalizeCloseSettings(settings);
if (normalized.closeBehavior === "ask") {
return "ask";
}
if (normalized.closeBehavior === "quit") {
return "quit";
}
if (!normalized.trayEnabled) {
return "minimize";
}
return "background";
}
/**
* Map a chosen close action + remember checkbox into persisted settings.
* Non-quit choices are stored as "background" and resolved to minimize when tray is off.
* @param {"quit"|"background"|"minimize"} action
* @param {boolean} remember
* @returns {{ closeBehavior: "quit"|"background" }|null}
*/
function rememberedCloseSettings(action, remember) {
if (!remember) {
return null;
}
if (action === "quit") {
return { closeBehavior: "quit" };
}
if (action === "background" || action === "minimize") {
return { closeBehavior: "background" };
}
return null;
}
/**
* Simple re-entrancy guard for async close handling.
*/
function createCloseRequestGuard() {
let inFlight = false;
return {
tryEnter() {
if (inFlight) {
return false;
}
inFlight = true;
return true;
},
leave() {
inFlight = false;
},
};
}
module.exports = {
CLOSE_BEHAVIORS,
defaultCloseSettings,
loadCloseSettings,
saveCloseSettings,
resolveCloseAction,
normalizeCloseSettings,
rememberedCloseSettings,
createCloseRequestGuard,
};

View file

@ -27,9 +27,18 @@ const {
} = require("./mainHelpers");
const { isAllowedShellPath } = require("./shellPathGuard");
const { normalizeExternalUrlForOpen } = require("./safeExternalUrl");
const {
loadCloseSettings,
saveCloseSettings,
resolveCloseAction,
rememberedCloseSettings,
createCloseRequestGuard,
} = require("./closeBehavior");
// remember main window
var mainWindow = null;
var closeSettings = null;
var closeRequestGuard = createCloseRequestGuard();
function getDialogParentWindow() {
const focused = BrowserWindow.getFocusedWindow();
@ -339,6 +348,14 @@ ipcMain.handle("shutdown", () => {
quit();
});
ipcMain.handle("get-close-settings", () => {
return getCloseSettings();
});
ipcMain.handle("set-close-settings", (_event, partial) => {
return updateCloseSettings(partial || {});
});
ipcMain.handle("get-memory-usage", async () => {
return process.getProcessMemoryInfo();
});
@ -648,7 +665,30 @@ function getBackendManager() {
return backendManager;
}
function getCloseSettings() {
if (!closeSettings) {
closeSettings = loadCloseSettings(getDefaultStorageDir());
}
return closeSettings;
}
function updateCloseSettings(partial) {
closeSettings = saveCloseSettings(getDefaultStorageDir(), partial);
syncTrayWithSettings();
return closeSettings;
}
function destroyTray() {
if (tray && !tray.isDestroyed()) {
tray.destroy();
}
tray = null;
}
function createTray() {
if (tray && !tray.isDestroyed()) {
return;
}
tray = new Tray(getAppIconPath());
const contextMenu = Menu.buildFromTemplate([
{
@ -682,6 +722,78 @@ function createTray() {
});
}
function syncTrayWithSettings() {
const settings = getCloseSettings();
if (settings.trayEnabled) {
createTray();
} else {
destroyTray();
}
}
async function promptCloseAction() {
const settings = getCloseSettings();
const backgroundLabel = settings.trayEnabled ? "Keep running in background" : "Minimize to taskbar";
const backgroundDetail = settings.trayEnabled
? "Hide the window and keep MeshChatX in the system tray."
: "Minimize MeshChatX to the taskbar.";
const result = await dialog.showMessageBox(getDialogParentWindow() || undefined, {
type: "question",
title: "Close MeshChatX?",
message: "Close MeshChatX?",
detail: `Choose whether to quit the application or keep it running.\n\n${backgroundDetail}`,
buttons: ["Cancel", "Quit application", backgroundLabel],
defaultId: 2,
cancelId: 0,
checkboxLabel: "Remember my choice",
checkboxChecked: false,
});
if (result.response === 0) {
return null;
}
const action = result.response === 1 ? "quit" : settings.trayEnabled ? "background" : "minimize";
const remembered = rememberedCloseSettings(action, result.checkboxChecked);
if (remembered) {
updateCloseSettings(remembered);
}
return action;
}
async function handleWindowCloseRequest(event) {
if (isQuiting) {
return;
}
event.preventDefault();
if (!closeRequestGuard.tryEnter()) {
return;
}
try {
const settings = getCloseSettings();
let action = resolveCloseAction(settings);
if (action === "ask") {
action = await promptCloseAction();
if (!action) {
return;
}
}
if (action === "quit") {
isQuiting = true;
quit();
return;
}
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (action === "minimize") {
mainWindow.minimize();
return;
}
mainWindow.hide();
} finally {
closeRequestGuard.leave();
}
}
app.whenReady().then(async () => {
app.on("browser-window-created", (event, browserWindow) => {
attachDefaultContextMenu(browserWindow);
@ -721,8 +833,8 @@ app.whenReady().then(async () => {
const isHardwareAccelerationEnabled = app.isHardwareAccelerationEnabled();
log(`Hardware Acceleration Enabled: ${isHardwareAccelerationEnabled}`);
// Create system tray
createTray();
// Create system tray when enabled in desktop close settings
syncTrayWithSettings();
// get arguments passed to application, and remove the provided application path
const userProvidedArguments = getUserProvidedArguments(process.argv);
@ -779,13 +891,9 @@ app.whenReady().then(async () => {
}
);
// minimize to tray behavior
// quit / minimize / hide-to-tray based on remembered close settings
mainWindow.on("close", (event) => {
if (!isQuiting) {
event.preventDefault();
mainWindow.hide();
return false;
}
void handleWindowCloseRequest(event);
});
// navigate to loading page
@ -938,10 +1046,7 @@ app.on("before-quit", () => {
isQuiting = true;
}
// Ensure tray is destroyed to prevent it from keeping the app alive
if (tray && !tray.isDestroyed()) {
tray.destroy();
tray = null;
}
destroyTray();
});
// quit electron if all windows are closed

View file

@ -70,6 +70,14 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("shutdown");
},
getCloseSettings: async function () {
return await ipcRenderer.invoke("get-close-settings");
},
setCloseSettings: async function (partial) {
return await ipcRenderer.invoke("set-close-settings", partial);
},
// allow getting memory usage in electron browser window
getMemoryUsage: async function () {
return await ipcRenderer.invoke("get-memory-usage");

View file

@ -187,14 +187,32 @@ class AnnounceDAO:
# Favourites
def upsert_favourite(self, destination_hash, display_name, aspect):
from meshchatx.src.backend.favourite_display_names import (
is_unknown_favourite_display_name,
)
now = datetime.now(UTC)
preserve_unknown = is_unknown_favourite_display_name(display_name)
self.provider.execute(
"""
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
ON CONFLICT(destination_hash) DO UPDATE SET
display_name = CASE
WHEN ? THEN favourite_destinations.display_name
ELSE EXCLUDED.display_name
END,
aspect = EXCLUDED.aspect,
updated_at = EXCLUDED.updated_at
""",
(destination_hash, display_name, aspect, now, now),
(
destination_hash,
display_name,
aspect,
now,
now,
1 if preserve_unknown else 0,
),
)
def get_favourite_by_destination_hash(self, destination_hash):

View file

@ -0,0 +1,25 @@
"""Shared favourite display-name sentinels that must not clobber stored names."""
# Keep in sync with meshchatx/src/frontend/js/nomadUnknownNodeName.js
UNKNOWN_FAVOURITE_NAMES = frozenset(
{
"",
"Unknown Node",
"Anonymous Node",
"Unbekannter Knoten",
"Nodo desconocido",
"Tuntematon solmu",
"Noeud inconnu",
"Nodo Sconosciuto",
"Onbekende knoop",
"Неизвестный узел",
"未知节点",
}
)
def is_unknown_favourite_display_name(name) -> bool:
"""Return True when ``name`` is empty or a known unknown-node placeholder."""
if not isinstance(name, str):
return True
return name.strip() in UNKNOWN_FAVOURITE_NAMES

View file

@ -621,6 +621,7 @@ import {
} from "../../js/MicronWasmLoader";
import { VTooltip } from "vuetify/components/VTooltip";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore";
import { isUnknownNodeDisplayName, resolveFavouriteUpsertDisplayName } from "../../js/nomadUnknownNodeName.js";
export default {
name: "NomadNetworkPage",
@ -1071,15 +1072,7 @@ export default {
(async () => {
await this.getNomadnetworkNodeAnnounce(bootstrapHash);
if (this.nodes[bootstrapHash]) {
this.selectedNode = this.nodes[bootstrapHash];
} else {
this.selectedNode = {
destination_hash: bootstrapHash,
display_name: "Unknown Node",
aspect: "nomadnetwork.node",
};
}
this.selectedNode = this.resolveNodeForHash(bootstrapHash);
this.getNodePath(bootstrapHash);
@ -1114,11 +1107,7 @@ export default {
}
try {
await this.getNomadnetworkNodeAnnounce(hash);
this.selectedNode = this.nodes[hash] || {
destination_hash: hash,
display_name: this.$t("nomadnet.unknown_node"),
aspect: "nomadnetwork.node",
};
this.selectedNode = this.resolveNodeForHash(hash);
const path = typeof pagePath === "string" && pagePath.length > 0 ? pagePath : this.defaultNodePagePath;
await this.loadNodePage(hash, path, null, false, true);
} catch (e) {
@ -1584,6 +1573,48 @@ export default {
console.log(e);
}
},
isUnknownNodeName(name) {
return isUnknownNodeDisplayName(name, this.$t("nomadnet.unknown_node"));
},
resolveNodeForHash(destinationHash) {
const hash = (destinationHash || "").trim();
if (!hash) {
return null;
}
const cached = this.nodes[hash];
const favourite = this.favourites.find((f) => f.destination_hash === hash);
const favouriteName = favourite?.custom_display_name || favourite?.display_name || "";
if (cached) {
const cachedName = cached.custom_display_name || cached.display_name || "";
if (this.isUnknownNodeName(cachedName) && favouriteName && !this.isUnknownNodeName(favouriteName)) {
return {
...cached,
display_name: favouriteName,
custom_display_name: favourite?.custom_display_name || favouriteName,
};
}
return cached;
}
if (favouriteName && !this.isUnknownNodeName(favouriteName)) {
return {
...favourite,
display_name: favouriteName,
aspect: favourite.aspect || "nomadnetwork.node",
};
}
const selectedHash = this.selectedNode?.destination_hash;
if (selectedHash && Object.is(selectedHash, hash)) {
const existingName = this.selectedNode.custom_display_name || this.selectedNode.display_name;
if (existingName && !this.isUnknownNodeName(existingName)) {
return this.selectedNode;
}
}
return {
destination_hash: hash,
display_name: this.$t("nomadnet.unknown_node"),
aspect: "nomadnetwork.node",
};
},
isFavourite(destinationHash) {
return (
this.favourites.find((favourite) => {
@ -1593,9 +1624,13 @@ export default {
},
async addFavourite(node) {
try {
const existing = this.favourites.find(
(favourite) => favourite.destination_hash === node.destination_hash
);
const displayName = resolveFavouriteUpsertDisplayName(node, existing, this.$t("nomadnet.unknown_node"));
await window.api.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
display_name: displayName,
aspect: "nomadnetwork.node",
});
await this.getFavourites();
@ -1643,9 +1678,10 @@ export default {
continue;
}
try {
const displayName = resolveFavouriteUpsertDisplayName(node, null, this.$t("nomadnet.unknown_node"));
await window.api.post("/api/v1/favourites/add", {
destination_hash: node.destination_hash,
display_name: node.display_name,
display_name: displayName,
aspect: "nomadnetwork.node",
});
added += 1;
@ -2472,10 +2508,7 @@ export default {
}
// update selected node, so relative urls work correctly when returned by the new node
this.selectedNode = this.nodes[destinationHash] || {
display_name: this.$t("nomadnet.unknown_node"),
destination_hash: destinationHash,
};
this.selectedNode = this.resolveNodeForHash(destinationHash);
// navigate to node page
this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache, navOptions);
@ -2492,18 +2525,21 @@ export default {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function (node) {
if (this.shouldOpenInNewTab(node.destination_hash, {})) {
this.emitOpenNode(
node.destination_hash,
this.defaultNodePagePath,
node.custom_display_name || node.display_name || null,
{ activate: true }
);
const hash = node?.destination_hash;
const resolved = hash ? this.resolveNodeForHash(hash) : node;
const title =
resolved?.custom_display_name ||
resolved?.display_name ||
node?.custom_display_name ||
node?.display_name ||
null;
if (this.shouldOpenInNewTab(hash, {})) {
this.emitOpenNode(hash, this.defaultNodePagePath, title, { activate: true });
return;
}
this.selectedNode = node;
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
this.selectedNode = resolved || node;
this.loadNodePage(hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {
// ask user for new display name

View file

@ -57,7 +57,7 @@
? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
: 'hover:bg-white/10'
"
:title="fav.display_name"
:title="favouriteDisplayName(fav)"
@click="onFavouriteClick(fav)"
>
<MaterialDesignIcon icon-name="server-network" class="size-6 text-gray-600 dark:text-gray-300" />
@ -348,9 +348,9 @@
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="favourite.display_name"
:title="favouriteDisplayName(favourite)"
>
{{ favourite.display_name }}
{{ favouriteDisplayName(favourite) }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer inline-flex items-center"
@ -715,6 +715,7 @@ import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import { isUnknownNodeDisplayName } from "../../js/nomadUnknownNodeName.js";
export default {
name: "NomadNetworkSidebar",
@ -1192,6 +1193,9 @@ export default {
}
},
ensureFavouriteLayout() {
if (!Array.isArray(this.favourites) || this.favourites.length === 0) {
return;
}
if (this.sections.length === 0) {
this.resetDefaultSections();
}
@ -1214,13 +1218,12 @@ export default {
sanitizedSections.unshift(defaultSection);
sectionIds.add(defaultSection.id);
}
this.sections = sanitizedSections;
const existingOrder = Array.isArray(this.sectionOrder) ? this.sectionOrder : [];
const filteredOrder = existingOrder.filter((id) => sectionIds.has(id));
const remaining = sanitizedSections
.map((section) => section.id)
.filter((id) => !filteredOrder.includes(id));
this.sectionOrder = [...filteredOrder, ...remaining];
const nextSectionOrder = [...filteredOrder, ...remaining];
const nextFavouritesBySection = {};
sanitizedSections.forEach((section) => {
@ -1234,8 +1237,17 @@ export default {
assigned.add(hash);
}
});
const sectionsChanged = JSON.stringify(this.sections) !== JSON.stringify(sanitizedSections);
const orderChanged = JSON.stringify(this.sectionOrder) !== JSON.stringify(nextSectionOrder);
const favouritesChanged =
JSON.stringify(this.favouritesBySection) !== JSON.stringify(nextFavouritesBySection);
this.sections = sanitizedSections;
this.sectionOrder = nextSectionOrder;
this.favouritesBySection = nextFavouritesBySection;
this.persistFavouriteLayout();
if (sectionsChanged || orderChanged || favouritesChanged) {
this.persistFavouriteLayout();
}
},
isBlocked(identityHash) {
return this.blockedDestinations.some((b) => b.destination_hash === identityHash);
@ -1243,6 +1255,22 @@ export default {
isFavourite(destinationHash) {
return this.favourites.some((f) => f.destination_hash === destinationHash);
},
favouriteDisplayName(favourite) {
if (!favourite) {
return "";
}
const hash = favourite.destination_hash;
const cached = hash ? this.nodes?.[hash] : null;
const cachedName = cached?.custom_display_name || cached?.display_name || "";
if (cachedName && !isUnknownNodeDisplayName(cachedName, this.$t("nomadnet.unknown_node"))) {
return cachedName;
}
const favouriteName = favourite.custom_display_name || favourite.display_name || "";
if (favouriteName && !isUnknownNodeDisplayName(favouriteName, this.$t("nomadnet.unknown_node"))) {
return favouriteName;
}
return favouriteName || this.$t("nomadnet.unknown_node");
},
addFavouriteFromContext() {
const node = this.announceContextMenu.node;
if (!node) {

View file

@ -412,7 +412,10 @@
{{ $t("relay_chat.load_previous") }}
</button>
<template v-if="!useVirtualMessageList">
<template v-for="entry in messageTimeline" :key="timelineEntryKey(entry)">
<template
v-for="(entry, entryIndex) in messageTimeline"
:key="timelineEntryKey(entry, entryIndex)"
>
<RelayMessageEntry :entry="entry" :page="relayChatPageSelf" />
</template>
</template>
@ -1102,7 +1105,12 @@ import Utils from "../../js/Utils";
import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconNames.js";
import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
import {
buildRelayMessageTimeline,
mergeRelayMessages,
relayMessageAlreadyPresent,
relayMessageKey,
} from "../../js/relayMessageTimeline.js";
import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
@ -1822,11 +1830,12 @@ export default {
messageKey(msg) {
return relayMessageKey(msg);
},
timelineEntryKey(entry) {
timelineEntryKey(entry, index = 0) {
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}`;
return `date-${entry.dayKey}-${index}`;
}
return this.messageKey(entry.msg);
const msgKey = this.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
},
formatDateDividerLabel(dayKey) {
if (!dayKey || typeof dayKey !== "string") {
@ -1946,6 +1955,9 @@ export default {
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
this.hasMorePrevious = false;
// Clear before fetch so only websocket arrivals during the request are merged back.
this.messages = [];
this.members = [];
const seq = ++this.roomSelectSequence;
try {
const response = await window.api.get(
@ -1955,7 +1967,8 @@ export default {
if (seq !== this.roomSelectSequence) {
return;
}
this.messages = response.data?.messages || [];
const loaded = response.data?.messages || [];
this.messages = mergeRelayMessages(loaded, this.messages);
this.members = response.data?.members || [];
this.hasMorePrevious = Boolean(response.data?.has_more);
this.scrollToBottom();
@ -1967,8 +1980,6 @@ export default {
if (seq !== this.roomSelectSequence) {
return;
}
this.messages = [];
this.members = [];
this.hasMorePrevious = false;
}
},
@ -1999,10 +2010,23 @@ export default {
if (older.length === 0) {
return;
}
const existingSeqs = new Set(this.messages.filter((m) => m && m.seq != null).map((m) => m.seq));
const uniqueOlder = older.filter((m) => {
if (!m) {
return false;
}
if (m.seq != null && existingSeqs.has(m.seq)) {
return false;
}
return !relayMessageAlreadyPresent(this.messages, m);
});
if (uniqueOlder.length === 0) {
return;
}
const scrollEl = this.useVirtualMessageList ? null : this.$refs.messageList;
const prevScrollHeight = scrollEl ? scrollEl.scrollHeight : 0;
const prevScrollTop = scrollEl ? scrollEl.scrollTop : 0;
this.messages = [...older, ...this.messages];
this.messages = [...uniqueOlder, ...this.messages];
if (scrollEl) {
nextTick(() => {
const delta = scrollEl.scrollHeight - prevScrollHeight;
@ -2475,8 +2499,10 @@ export default {
this.fetchHubs();
} else if (json.type === "rrc.message") {
if (json.hub_hash === this.selectedHubHash && json.room === this.selectedRoom && json.message) {
this.messages.push(json.message);
this.scrollToBottom();
if (!relayMessageAlreadyPresent(this.messages, json.message)) {
this.messages.push(json.message);
this.scrollToBottom();
}
if (json.message.kind === "system" || json.message.kind === "notice") {
this.refreshMembers();
}

View file

@ -50,12 +50,13 @@ const totalSize = computed(() => virtualizer.value.getTotalSize());
function entryKey(entry, index) {
if (!entry) {
return index;
return `idx-${index}`;
}
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}`;
return `date-${entry.dayKey}-${index}`;
}
return props.page.messageKey(entry.msg);
const msgKey = props.page.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
}
function measureElement(el) {

View file

@ -13,9 +13,10 @@ export function estimateRelayEntryHeight(entry) {
return 44;
}
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
let height = 28;
let height = 32;
if (text) {
height += Math.ceil(text.length / 80) * 18;
const lines = text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / 72)), 0);
height += Math.max(0, lines - 1) * 20;
}
return height;
}

View file

@ -841,6 +841,45 @@
<span class="setting-toggle__hint">{{ $t("app.requires_restart") }}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="desktop-tray-enabled"
v-model="desktopCloseSettings.trayEnabled"
@update:model-value="onDesktopTrayEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.desktop_tray_enabled") }}</span>
<span class="setting-toggle__description">{{
$t("app.desktop_tray_enabled_description")
}}</span>
</span>
</label>
<label class="flex flex-col gap-2">
<span class="text-sm font-medium text-sem-fg">{{
$t("app.desktop_close_behavior")
}}</span>
<span class="text-xs text-sem-fg-muted">{{
$t("app.desktop_close_behavior_description")
}}</span>
<select
id="desktop-close-behavior"
v-model="desktopCloseSettings.closeBehavior"
class="input-field"
@change="onDesktopCloseBehaviorChange"
>
<option value="ask">{{ $t("app.desktop_close_behavior_ask") }}</option>
<option value="quit">{{ $t("app.desktop_close_behavior_quit") }}</option>
<option value="background">
{{
desktopCloseSettings.trayEnabled
? $t("app.desktop_close_behavior_background")
: $t("app.desktop_close_behavior_background_no_tray")
}}
</option>
</select>
</label>
</div>
</section>
@ -3019,6 +3058,10 @@ export default {
visualiserShowDiscoveredInterfaces: false,
selfTestRunning: false,
selfTestResults: null,
desktopCloseSettings: {
closeBehavior: "ask",
trayEnabled: true,
},
};
},
computed: {
@ -3146,8 +3189,58 @@ export default {
this.loadStickerCount();
this.loadGifCount();
this.loadVisualiserDisplayPrefsFromStorage();
this.loadDesktopCloseSettings();
},
methods: {
async loadDesktopCloseSettings() {
if (!ElectronUtils.isElectron()) {
return;
}
try {
const settings = await ElectronUtils.getCloseSettings();
if (settings && typeof settings === "object") {
this.desktopCloseSettings = {
closeBehavior: settings.closeBehavior || "ask",
trayEnabled: settings.trayEnabled !== false,
};
}
} catch (e) {
console.log(e);
}
},
async onDesktopTrayEnabledChange(value) {
this.desktopCloseSettings.trayEnabled = value === true;
try {
const settings = await ElectronUtils.setCloseSettings({
trayEnabled: this.desktopCloseSettings.trayEnabled,
});
if (settings && typeof settings === "object") {
this.desktopCloseSettings = {
closeBehavior: settings.closeBehavior || this.desktopCloseSettings.closeBehavior,
trayEnabled: settings.trayEnabled !== false,
};
}
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("common.save_failed"));
}
},
async onDesktopCloseBehaviorChange() {
try {
const settings = await ElectronUtils.setCloseSettings({
closeBehavior: this.desktopCloseSettings.closeBehavior,
});
if (settings && typeof settings === "object") {
this.desktopCloseSettings = {
closeBehavior: settings.closeBehavior || this.desktopCloseSettings.closeBehavior,
trayEnabled: settings.trayEnabled !== false,
};
}
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("common.save_failed"));
}
},
async runSelfTest() {
if (this.selfTestRunning) {
return;

View file

@ -17,6 +17,20 @@ class ElectronUtils {
}
}
static async getCloseSettings() {
if (window.electron?.getCloseSettings) {
return await window.electron.getCloseSettings();
}
return null;
}
static async setCloseSettings(partial) {
if (window.electron?.setCloseSettings) {
return await window.electron.setCloseSettings(partial);
}
return null;
}
static async getMemoryUsage() {
if (window.electron) {
return await window.electron.getMemoryUsage();

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: 0BSD
/**
* Canonical and localized placeholder names that must not overwrite a stored
* favourite display name. Keep in sync with backend UNKNOWN_FAVOURITE_NAMES.
*/
export const UNKNOWN_NODE_DISPLAY_NAMES = Object.freeze([
"Unknown Node",
"Anonymous Node",
"Unbekannter Knoten",
"Nodo desconocido",
"Tuntematon solmu",
"Noeud inconnu",
"Nodo Sconosciuto",
"Onbekende knoop",
"Неизвестный узел",
"未知节点",
]);
const UNKNOWN_NODE_NAME_SET = new Set(UNKNOWN_NODE_DISPLAY_NAMES.map((n) => n.toLowerCase()));
/**
* @param {unknown} name
* @param {string} [localizedUnknown]
* @returns {boolean}
*/
export function isUnknownNodeDisplayName(name, localizedUnknown = "") {
if (typeof name !== "string") {
return true;
}
const trimmed = name.trim();
if (!trimmed) {
return true;
}
if (UNKNOWN_NODE_NAME_SET.has(trimmed.toLowerCase())) {
return true;
}
if (typeof localizedUnknown === "string" && localizedUnknown.trim()) {
return trimmed.toLowerCase() === localizedUnknown.trim().toLowerCase();
}
return false;
}
/**
* Prefer a meaningful name for favourite upserts; never send a localized
* unknown sentinel that the backend would treat as a real rename.
* @param {object|null|undefined} node
* @param {object|null|undefined} existingFavourite
* @param {string} [localizedUnknown]
* @returns {string}
*/
export function resolveFavouriteUpsertDisplayName(node, existingFavourite = null, localizedUnknown = "") {
const candidate =
(typeof node?.custom_display_name === "string" && node.custom_display_name) ||
(typeof node?.display_name === "string" && node.display_name) ||
"";
if (!isUnknownNodeDisplayName(candidate, localizedUnknown)) {
return candidate.trim();
}
const existing =
(typeof existingFavourite?.custom_display_name === "string" && existingFavourite.custom_display_name) ||
(typeof existingFavourite?.display_name === "string" && existingFavourite.display_name) ||
"";
if (existing && !isUnknownNodeDisplayName(existing, localizedUnknown)) {
return existing.trim();
}
return "Unknown Node";
}

View file

@ -121,6 +121,18 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.desktop_open_calls_in_separate_window_description",
"app.desktop_hardware_acceleration_enabled",
"app.desktop_hardware_acceleration_enabled_description",
"app.desktop_tray_enabled",
"app.desktop_tray_enabled_description",
"app.desktop_close_behavior",
"app.desktop_close_behavior_description",
"app.desktop_close_behavior_ask",
"app.desktop_close_behavior_quit",
"app.desktop_close_behavior_background",
"app.desktop_close_behavior_background_no_tray",
"tray",
"close",
"quit",
"taskbar",
],
android: [
"Android",

View file

@ -10,8 +10,53 @@ export function relayMessageKey(msg) {
if (!msg) {
return "";
}
if (msg.seq != null && msg.seq !== "") {
return `seq-${msg.seq}`;
}
const src = msg.src || "";
return `${msg.kind || "msg"}-${msg.ts || 0}-${src}`;
const text = typeof msg.text === "string" ? msg.text : "";
return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${text.slice(0, 24)}`;
}
/**
* True when an incoming relay message is already present in the list.
* Prefers seq; falls back to kind/ts/src/text for older payloads without seq.
* @param {object[]} messages
* @param {object} incoming
* @returns {boolean}
*/
export function relayMessageAlreadyPresent(messages, incoming) {
if (!Array.isArray(messages) || !incoming) {
return false;
}
const incomingSeq = incoming.seq;
if (incomingSeq != null && incomingSeq !== "") {
return messages.some((m) => m && m.seq === incomingSeq);
}
const key = relayMessageKey(incoming);
if (!key) {
return false;
}
return messages.some((m) => relayMessageKey(m) === key);
}
/**
* Append extras that are not already represented in base (by seq / fallback key).
* @param {object[]} base
* @param {object[]} extras
* @returns {object[]}
*/
export function mergeRelayMessages(base, extras) {
const out = Array.isArray(base) ? [...base] : [];
if (!Array.isArray(extras) || extras.length === 0) {
return out;
}
for (const msg of extras) {
if (msg && !relayMessageAlreadyPresent(out, msg)) {
out.push(msg);
}
}
return out;
}
/**

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Relais-Chat"
"relay_chat": "Relais-Chat",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Öffnen",

View file

@ -319,6 +319,14 @@
"desktop_open_calls_in_separate_window_description": "When a call is active, it will be shown in a separate pop-out window rather than inside the app.",
"desktop_hardware_acceleration_enabled": "Hardware Acceleration",
"desktop_hardware_acceleration_enabled_description": "Disable this if you experience flickering, black screens, or other graphical issues.",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar",
"translator": "Translator",
"translator_enabled": "Enable Translator",
"translator_description": "Enable translation features in conversations.",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Chat de retransmision"
"relay_chat": "Chat de retransmision",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Abierto",

View file

@ -423,7 +423,15 @@
"telemetry_trust_failed": "Käyttötietojen luottamuksen päivitys epäonnistui.",
"telemetry_trust_revoke": "Poista käyttötietojen luottamus",
"telemetry_trust_grant": "Luota käyttötietojen välitystä varten",
"location_manage_desc": "Hallinnoi sijainnin jakamisen asetuksia."
"location_manage_desc": "Hallinnoi sijainnin jakamisen asetuksia.",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Avaa",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Chat relais"
"relay_chat": "Chat relais",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Ouvrir",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Chat relay"
"relay_chat": "Chat relay",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Apri",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Relaychat"
"relay_chat": "Relaychat",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Open",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "Релейный чат"
"relay_chat": "Релейный чат",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "Открыть",

View file

@ -423,7 +423,15 @@
"flood_threshold": "Messages per minute threshold",
"flood_max_stamp_cost": "Maximum stamp cost during flood",
"flood_cooldown": "Cooldown before lowering cost (seconds)",
"relay_chat": "中继聊天"
"relay_chat": "中继聊天",
"desktop_tray_enabled": "System tray integration",
"desktop_tray_enabled_description": "Keep a tray icon so MeshChatX can hide to the background instead of quitting.",
"desktop_close_behavior": "When closing the window",
"desktop_close_behavior_description": "Choose whether closing the window quits, hides to the tray/taskbar, or asks each time.",
"desktop_close_behavior_ask": "Ask every time",
"desktop_close_behavior_quit": "Quit application",
"desktop_close_behavior_background": "Keep running in background",
"desktop_close_behavior_background_no_tray": "Minimize to taskbar"
},
"common": {
"open": "打开",

View file

@ -154,3 +154,26 @@ def test_get_favourite_by_destination_hash(announce_dao):
announce_dao.upsert_favourite("dh1", "Renamed", row["aspect"])
row2 = announce_dao.get_favourite_by_destination_hash("dh1")
assert row2["display_name"] == "Renamed"
def test_upsert_favourite_preserves_name_for_unknown_sentinel(announce_dao):
announce_dao.upsert_favourite("dh1", "Kept Name", "nomadnetwork.node")
announce_dao.upsert_favourite("dh1", "Unknown Node", "nomadnetwork.node")
row = announce_dao.get_favourite_by_destination_hash("dh1")
assert row["display_name"] == "Kept Name"
announce_dao.upsert_favourite("dh1", "New Real Name", "nomadnetwork.node")
row2 = announce_dao.get_favourite_by_destination_hash("dh1")
assert row2["display_name"] == "New Real Name"
def test_upsert_favourite_preserves_name_for_localized_unknown(announce_dao):
announce_dao.upsert_favourite("dh1", "Kept Name", "nomadnetwork.node")
for sentinel in (
"Unbekannter Knoten",
"未知节点",
"Anonymous Node",
"Неизвестный узел",
):
announce_dao.upsert_favourite("dh1", sentinel, "nomadnetwork.node")
row = announce_dao.get_favourite_by_destination_hash("dh1")
assert row["display_name"] == "Kept Name", sentinel

View file

@ -0,0 +1,21 @@
from meshchatx.src.backend.favourite_display_names import (
UNKNOWN_FAVOURITE_NAMES,
is_unknown_favourite_display_name,
)
def test_unknown_favourite_names_include_localized_placeholders():
assert "" in UNKNOWN_FAVOURITE_NAMES
assert "Unknown Node" in UNKNOWN_FAVOURITE_NAMES
assert "Anonymous Node" in UNKNOWN_FAVOURITE_NAMES
assert "Unbekannter Knoten" in UNKNOWN_FAVOURITE_NAMES
assert "未知节点" in UNKNOWN_FAVOURITE_NAMES
def test_is_unknown_favourite_display_name():
assert is_unknown_favourite_display_name(None) is True
assert is_unknown_favourite_display_name("") is True
assert is_unknown_favourite_display_name(" ") is True
assert is_unknown_favourite_display_name("Unknown Node") is True
assert is_unknown_favourite_display_name("Неизвестный узел") is True
assert is_unknown_favourite_display_name("Real Node") is False

View file

@ -0,0 +1,74 @@
import { createRequire } from "module";
import path from "path";
import fs from "fs";
import os from "os";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const {
defaultCloseSettings,
normalizeCloseSettings,
resolveCloseAction,
loadCloseSettings,
saveCloseSettings,
rememberedCloseSettings,
createCloseRequestGuard,
} = require("../../electron/closeBehavior.js");
describe("electron/closeBehavior", () => {
const tempDirs = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("defaults to ask with tray enabled", () => {
expect(defaultCloseSettings()).toEqual({
closeBehavior: "ask",
trayEnabled: true,
});
});
it("normalizes invalid values", () => {
expect(
normalizeCloseSettings({
closeBehavior: "nope",
trayEnabled: "yes",
})
).toEqual(defaultCloseSettings());
});
it("resolveCloseAction maps background to minimize when tray is off", () => {
expect(resolveCloseAction({ closeBehavior: "ask", trayEnabled: true })).toBe("ask");
expect(resolveCloseAction({ closeBehavior: "quit", trayEnabled: true })).toBe("quit");
expect(resolveCloseAction({ closeBehavior: "background", trayEnabled: true })).toBe("background");
expect(resolveCloseAction({ closeBehavior: "background", trayEnabled: false })).toBe("minimize");
});
it("persists and reloads settings from storage dir", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-close-"));
tempDirs.push(dir);
const saved = saveCloseSettings(dir, { closeBehavior: "quit", trayEnabled: false });
expect(saved).toEqual({ closeBehavior: "quit", trayEnabled: false });
expect(loadCloseSettings(dir)).toEqual({ closeBehavior: "quit", trayEnabled: false });
expect(fs.existsSync(path.join(dir, "desktop-close-settings.json"))).toBe(true);
});
it("rememberedCloseSettings maps minimize/background to background", () => {
expect(rememberedCloseSettings("quit", true)).toEqual({ closeBehavior: "quit" });
expect(rememberedCloseSettings("background", true)).toEqual({ closeBehavior: "background" });
expect(rememberedCloseSettings("minimize", true)).toEqual({ closeBehavior: "background" });
expect(rememberedCloseSettings("quit", false)).toBeNull();
});
it("createCloseRequestGuard blocks re-entrant close handling", () => {
const guard = createCloseRequestGuard();
expect(guard.tryEnter()).toBe(true);
expect(guard.tryEnter()).toBe(false);
guard.leave();
expect(guard.tryEnter()).toBe(true);
guard.leave();
});
});

View file

@ -89,6 +89,26 @@ describe("electron/preload", () => {
expect(cb).toHaveBeenCalledWith({ code: 255 });
});
it("exposes close settings IPC helpers", async () => {
const exposeInMainWorld = vi.fn();
const invoke = vi.fn();
loadPreloadWithElectronMock({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on: vi.fn() },
});
const api = exposeInMainWorld.mock.calls[0][1];
invoke.mockResolvedValueOnce({ closeBehavior: "ask", trayEnabled: true });
await expect(api.getCloseSettings()).resolves.toEqual({ closeBehavior: "ask", trayEnabled: true });
expect(invoke).toHaveBeenCalledWith("get-close-settings");
invoke.mockResolvedValueOnce({ closeBehavior: "quit", trayEnabled: false });
await expect(api.setCloseSettings({ closeBehavior: "quit" })).resolves.toEqual({
closeBehavior: "quit",
trayEnabled: false,
});
expect(invoke).toHaveBeenCalledWith("set-close-settings", { closeBehavior: "quit" });
});
it("subscribes to log channel on load", () => {
const exposeInMainWorld = vi.fn();
const on = vi.fn();

View file

@ -670,6 +670,82 @@ describe("NomadNetworkPage.vue", () => {
expect(ToastUtils.success).toHaveBeenCalledWith("nomadnet.favourite_added");
});
it("resolveNodeForHash prefers favourite name over Unknown Node stub", () => {
const wrapper = mountNomadNetworkPage();
const hash = "a".repeat(32);
wrapper.vm.nodes = {};
wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Saved Favourite" }];
const resolved = wrapper.vm.resolveNodeForHash(hash);
expect(resolved.display_name).toBe("Saved Favourite");
expect(resolved.destination_hash).toBe(hash);
});
it("addFavourite does not overwrite existing favourite with Unknown Node", async () => {
axiosMock.post.mockResolvedValueOnce({ data: {} });
axiosMock.get.mockResolvedValueOnce({ data: { favourites: [] } });
const wrapper = mountNomadNetworkPage();
const hash = "a".repeat(32);
wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Kept Name" }];
await wrapper.vm.addFavourite({
destination_hash: hash,
display_name: "Unknown Node",
});
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
destination_hash: hash,
display_name: "Kept Name",
aspect: "nomadnetwork.node",
});
});
it("addFavourite canonicalizes localized unknown names for new favourites", async () => {
axiosMock.post.mockResolvedValueOnce({ data: {} });
axiosMock.get.mockResolvedValueOnce({ data: { favourites: [] } });
const wrapper = mountNomadNetworkPage();
const hash = "b".repeat(32);
wrapper.vm.favourites = [];
await wrapper.vm.addFavourite({
destination_hash: hash,
display_name: "Unbekannter Knoten",
});
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
destination_hash: hash,
display_name: "Unknown Node",
aspect: "nomadnetwork.node",
});
});
it("onNodeClick resolves favourite names through announce cache", async () => {
const wrapper = mountNomadNetworkPage();
const hash = "c".repeat(32);
wrapper.vm.nodes = {
[hash]: {
destination_hash: hash,
display_name: "From Announce",
aspect: "nomadnetwork.node",
},
};
wrapper.vm.favourites = [{ destination_hash: hash, display_name: "Unknown Node" }];
const loadSpy = vi.spyOn(wrapper.vm, "loadNodePage").mockResolvedValue();
wrapper.vm.onNodeClick({ destination_hash: hash, display_name: "Unknown Node" });
expect(wrapper.vm.selectedNode.display_name).toBe("From Announce");
expect(loadSpy).toHaveBeenCalledWith(hash, wrapper.vm.defaultNodePagePath);
loadSpy.mockRestore();
});
it("onBulkAddFavouritesFromAnnounces uses canonical unknown sentinel", async () => {
axiosMock.post.mockResolvedValue({ data: {} });
axiosMock.get.mockResolvedValue({ data: { favourites: [] } });
const wrapper = mountNomadNetworkPage();
wrapper.vm.favourites = [];
const hash = "d".repeat(32);
await wrapper.vm.onBulkAddFavouritesFromAnnounces([{ destination_hash: hash, display_name: "未知节点" }]);
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/favourites/add", {
destination_hash: hash,
display_name: "Unknown Node",
aspect: "nomadnetwork.node",
});
});
it("toggleFavouriteFromContext reports API failures", async () => {
axiosMock.post.mockRejectedValueOnce(new Error("network"));
const wrapper = mountNomadNetworkPage();

View file

@ -298,4 +298,54 @@ describe("NomadNetworkSidebar.vue", () => {
expect(wrapper.text()).toContain("nomadnet.no_announces_yet");
expect(wrapper.text()).not.toContain("nomadnet.no_search_results_peers");
});
it("favouriteDisplayName prefers announce cache over unknown favourite label", async () => {
const favHash = defaultFavourite.destination_hash;
const wrapper = mountSidebar({
favourites: [{ destination_hash: favHash, display_name: "Unknown Node" }],
nodes: {
[favHash]: {
destination_hash: favHash,
display_name: "Live Announce Name",
},
[defaultNode.destination_hash]: defaultNode,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.favouriteDisplayName(wrapper.vm.favourites[0])).toBe("Live Announce Name");
expect(wrapper.text()).toContain("Live Announce Name");
});
it("does not wipe persisted favourite section layout when favourites are still empty", async () => {
const favHash = defaultFavourite.destination_hash;
const layout = {
sections: [
{ id: "default", name: "Favourites", collapsed: false },
{ id: "custom", name: "Custom", collapsed: false },
],
sectionOrder: ["default", "custom"],
favouritesBySection: {
default: [],
custom: [favHash],
},
};
localStorage.getItem.mockImplementation((key) => {
if (key === "meshchat.nomadnet.favourites.layout") {
return JSON.stringify(layout);
}
return null;
});
const wrapper = mountSidebar({ favourites: [] });
await wrapper.vm.$nextTick();
expect(wrapper.vm.favouritesBySection.custom).toEqual([favHash]);
expect(localStorage.setItem).not.toHaveBeenCalled();
await wrapper.setProps({ favourites: [defaultFavourite] });
await wrapper.vm.$nextTick();
expect(wrapper.vm.favouritesBySection.custom).toContain(favHash);
expect(wrapper.vm.favouritesBySection.default || []).not.toContain(favHash);
});
});

View file

@ -189,6 +189,112 @@ describe("RelayChatPage.vue", () => {
expect(wrapper.text()).toContain("hello");
});
it("keeps websocket messages that arrive while selectRoom is loading", async () => {
let resolveMessages;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/rrc/hubs") {
return Promise.resolve({ data: { hubs: [makeHub()] } });
}
if (url === "/api/v1/rrc/servers") {
return Promise.resolve({ data: { hubs: [makeHostedHub()] } });
}
if (url === "/api/v1/announces") {
return Promise.resolve({ data: { announces: [makeAnnounce()] } });
}
if (url.includes("/rooms/") && url.endsWith("/messages")) {
return new Promise((resolve) => {
resolveMessages = resolve;
});
}
return Promise.resolve({ data: {} });
});
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
const selectPromise = wrapper.vm.selectRoom(HUB_HASH, "lobby");
await vi.waitFor(() => expect(typeof resolveMessages).toBe("function"));
wrapper.vm.onWebsocketMessage({
data: JSON.stringify({
type: "rrc.message",
hub_hash: HUB_HASH,
room: "lobby",
message: {
kind: "msg",
room: "lobby",
src: "live",
nick: "live",
text: "during-load",
ts: 99,
seq: 99,
mention: false,
},
}),
});
expect(wrapper.vm.messages.some((m) => m.text === "during-load")).toBe(true);
resolveMessages({
data: {
messages: [
{
kind: "msg",
room: "lobby",
src: "aabb",
nick: "carol",
text: "hello",
ts: 1,
seq: 1,
mention: false,
},
],
members: [{ hash: "aabb", name: "carol" }],
has_more: false,
},
});
await selectPromise;
expect(wrapper.vm.messages.map((m) => m.text)).toEqual(["hello", "during-load"]);
});
it("dedupes websocket messages that already exist by seq", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
await wrapper.vm.selectRoom(HUB_HASH, "lobby");
wrapper.vm.messages = [
{
kind: "msg",
room: "lobby",
src: "aabb",
nick: "carol",
text: "hello",
ts: 1,
seq: 5,
mention: false,
},
];
wrapper.vm.onWebsocketMessage({
data: JSON.stringify({
type: "rrc.message",
hub_hash: HUB_HASH,
room: "lobby",
message: {
kind: "msg",
room: "lobby",
src: "aabb",
nick: "carol",
text: "hello",
ts: 1,
seq: 5,
mention: false,
},
}),
});
expect(wrapper.vm.messages).toHaveLength(1);
});
it("sends a message via the API", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
isUnknownNodeDisplayName,
resolveFavouriteUpsertDisplayName,
UNKNOWN_NODE_DISPLAY_NAMES,
} from "@/js/nomadUnknownNodeName.js";
describe("nomadUnknownNodeName", () => {
it("treats empty and known placeholders as unknown", () => {
expect(isUnknownNodeDisplayName("")).toBe(true);
expect(isUnknownNodeDisplayName(" ")).toBe(true);
expect(isUnknownNodeDisplayName(null)).toBe(true);
expect(isUnknownNodeDisplayName("Unknown Node")).toBe(true);
expect(isUnknownNodeDisplayName("Anonymous Node")).toBe(true);
expect(isUnknownNodeDisplayName("Unbekannter Knoten")).toBe(true);
expect(isUnknownNodeDisplayName("未知节点")).toBe(true);
expect(isUnknownNodeDisplayName("Real Node")).toBe(false);
});
it("honors a localized unknown string from i18n", () => {
expect(isUnknownNodeDisplayName("Custom Unknown", "Custom Unknown")).toBe(true);
expect(isUnknownNodeDisplayName("Custom Unknown", "Other")).toBe(false);
});
it("resolveFavouriteUpsertDisplayName keeps existing real names", () => {
expect(
resolveFavouriteUpsertDisplayName(
{ display_name: "Unknown Node" },
{ display_name: "Kept" },
"Unknown Node"
)
).toBe("Kept");
expect(
resolveFavouriteUpsertDisplayName(
{ display_name: "Unbekannter Knoten" },
{ display_name: "Kept" },
"Unbekannter Knoten"
)
).toBe("Kept");
});
it("resolveFavouriteUpsertDisplayName canonicalizes unknown for new favourites", () => {
expect(resolveFavouriteUpsertDisplayName({ display_name: "未知节点" }, null, "未知节点")).toBe("Unknown Node");
expect(resolveFavouriteUpsertDisplayName({ display_name: "Fresh Name" }, null, "Unknown Node")).toBe(
"Fresh Name"
);
});
it("exports the shared sentinel list", () => {
expect(UNKNOWN_NODE_DISPLAY_NAMES).toContain("Unknown Node");
expect(UNKNOWN_NODE_DISPLAY_NAMES).toContain("Неизвестный узел");
});
});

View file

@ -5,7 +5,12 @@ import {
parseRelaySearchQuery,
parseDateSearchToken,
} from "@/js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "@/js/relayMessageTimeline.js";
import {
buildRelayMessageTimeline,
mergeRelayMessages,
relayMessageAlreadyPresent,
relayMessageKey,
} from "@/js/relayMessageTimeline.js";
const displayName = (msg) => msg.nick || "anon";
@ -80,4 +85,34 @@ describe("relayMessageTimeline", () => {
const msg = { kind: "msg", ts: 1, src: "ab", text: "x" };
expect(relayMessageKey(msg)).toBe(relayMessageKey(msg));
});
it("relayMessageKey prefers seq when present", () => {
const msg = { kind: "msg", ts: 1, src: "ab", text: "x", seq: 42 };
expect(relayMessageKey(msg)).toBe("seq-42");
});
it("relayMessageAlreadyPresent matches by seq", () => {
const messages = [{ kind: "msg", ts: 1, src: "ab", text: "x", seq: 7 }];
expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 99, src: "cd", text: "y", seq: 7 })).toBe(true);
expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "x", seq: 8 })).toBe(false);
});
it("relayMessageAlreadyPresent falls back when seq is missing", () => {
const messages = [{ kind: "msg", ts: 1, src: "ab", text: "hello" }];
expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "hello" })).toBe(true);
expect(relayMessageAlreadyPresent(messages, { kind: "msg", ts: 1, src: "ab", text: "other" })).toBe(false);
});
it("mergeRelayMessages keeps websocket arrivals not in the loaded page", () => {
const loaded = [
{ kind: "msg", ts: 1, src: "ab", text: "a", seq: 1 },
{ kind: "msg", ts: 2, src: "ab", text: "b", seq: 2 },
];
const live = [
{ kind: "msg", ts: 2, src: "ab", text: "b", seq: 2 },
{ kind: "msg", ts: 3, src: "cd", text: "c", seq: 3 },
];
const merged = mergeRelayMessages(loaded, live);
expect(merged.map((m) => m.seq)).toEqual([1, 2, 3]);
});
});