mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
fix: RRC message stacking, NomadNet favourites/sections, and desktop close prompt
This commit is contained in:
parent
1a6f7f337f
commit
d2456c8ac3
33 changed files with 1206 additions and 75 deletions
121
electron/closeBehavior.js
Normal file
121
electron/closeBehavior.js
Normal 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,
|
||||
};
|
||||
129
electron/main.js
129
electron/main.js
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
25
meshchatx/src/backend/favourite_display_names.py
Normal file
25
meshchatx/src/backend/favourite_display_names.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
68
meshchatx/src/frontend/js/nomadUnknownNodeName.js
Normal file
68
meshchatx/src/frontend/js/nomadUnknownNodeName.js
Normal 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";
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Открыть",
|
||||
|
|
|
|||
|
|
@ -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": "打开",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
tests/backend/test_favourite_display_names.py
Normal file
21
tests/backend/test_favourite_display_names.py
Normal 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
|
||||
74
tests/electron/closeBehavior.test.js
Normal file
74
tests/electron/closeBehavior.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
53
tests/frontend/nomadUnknownNodeName.test.js
Normal file
53
tests/frontend/nomadUnknownNodeName.test.js
Normal 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("Неизвестный узел");
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue