feat(android): refactor RNode interface handling and add support for Android storage management

This commit is contained in:
Ivan 2026-06-05 18:30:56 -05:00
parent d3dfa648a3
commit 7eea421940
No known key found for this signature in database
GPG key ID: B84314E2D9332AE0
34 changed files with 2385 additions and 1074 deletions

View file

@ -783,44 +783,14 @@ class ReticulumMeshChat:
@staticmethod
def _disable_rnode_interfaces_on_android(config_path: str) -> bool:
"""If running on Android, disable RNode* interfaces in Reticulum config.
Returns True if any interfaces were disabled.
"""
"""Disable enabled RNode* interfaces in Reticulum config (Android recovery helper)."""
if not _is_chaquopy_android():
return False
if not os.path.isfile(config_path):
return False
try:
from RNS.vendor.configobj import ConfigObj
from meshchatx.src.backend.rnode_support import (
disable_rnode_interfaces_in_config,
)
cfg = ConfigObj(config_path)
except Exception:
return False
modified = False
interfaces = cfg.get("interfaces")
if not isinstance(interfaces, dict):
return False
for _iface_name, iface in interfaces.items():
if not isinstance(iface, dict):
continue
iface_type = iface.get("type", "")
if isinstance(iface_type, str) and iface_type.startswith("RNode"):
if str(iface.get("interface_enabled", "")).lower() in (
"true",
"yes",
"1",
"on",
):
iface["interface_enabled"] = "false"
modified = True
if modified:
try:
cfg.write()
except Exception:
pass
return modified
return disable_rnode_interfaces_in_config(config_path)
def _ensure_reticulum_config(self, materialize: bool = True):
"""Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists.
@ -863,13 +833,11 @@ class ReticulumMeshChat:
cfg.write()
except Exception:
pass
# Android: RNodeInterface crashes because serial port access isn't available
if _is_chaquopy_android():
disabled = self._disable_rnode_interfaces_on_android(config_path)
if disabled:
logging.getLogger(__name__).warning(
"RNodeInterface is not supported on Android; disabled in config.",
)
from meshchatx.src.backend.rnode_support import (
guard_rnode_interfaces_on_android,
)
guard_rnode_interfaces_on_android(config_path)
def setup_identity(self, identity: RNS.Identity):
identity_hash = identity.hash.hex()
@ -2655,7 +2623,45 @@ class ReticulumMeshChat:
ctx = context or self.current_context
if not ctx or not ctx.message_router:
return
ctx.message_router.cancel_propagation_node_requests()
router = ctx.message_router
with contextlib.suppress(Exception):
router.cancel_propagation_node_requests()
# cancel_propagation_node_requests resets via acknowledge_sync_completion,
# but a blocked RNS.Identity.recall can leave the router in an active state.
with contextlib.suppress(Exception):
active_states = {
router.PR_PATH_REQUESTED,
router.PR_LINK_ESTABLISHING,
router.PR_LINK_ESTABLISHED,
router.PR_REQUEST_SENT,
router.PR_RECEIVING,
router.PR_RESPONSE_RECEIVED,
}
if router.propagation_transfer_state in active_states:
router.propagation_transfer_state = router.PR_IDLE
router.propagation_transfer_progress = 0.0
async def _request_propagation_node_messages(self, context=None):
ctx = context or self.current_context
if not ctx or not ctx.message_router:
return
router = ctx.message_router
def _request():
try:
router.request_messages_from_propagation_node(ctx.identity)
except (EOFError, BrokenPipeError, ConnectionResetError, OSError):
with contextlib.suppress(Exception):
router.propagation_transfer_state = router.PR_IDLE
router.propagation_transfer_progress = 0.0
except Exception:
logging.getLogger("meshchatx").exception(
"Propagation node message request failed",
)
await asyncio.to_thread(_request)
await self.send_config_to_websocket_clients(context=ctx)
def _get_propagation_sync_metrics(self, context=None):
ctx = context or self.current_context
@ -4848,6 +4854,24 @@ class ReticulumMeshChat:
# update interface details
interface_details["type"] = interface_type
if interface_type in (
"RNodeInterface",
"RNodeIPInterface",
"RNodeMultiInterface",
):
from meshchatx.src.backend.rnode_support import rnode_serial_supported
if not rnode_serial_supported():
return web.json_response(
{
"message": (
"RNode serial and Bluetooth are not available on this device. "
"On Android, the app must include usbserial4a (see MeshChatX issue #6)."
),
},
status=422,
)
# if interface doesn't have enabled or interface_enabled setting already, enable it by default
if (
"enabled" not in interface_details
@ -14180,8 +14204,10 @@ class ReticulumMeshChat:
await self.send_config_to_websocket_clients(context=ctx)
return
# request messages from propagation node
router.request_messages_from_propagation_node(ctx.identity)
# Kick off the LXMF request on a worker thread. Identity.recall and link
# setup can block on multiprocessing pipes; running inline would stall the
# HTTP handler and race with cancel_propagation_node_requests (EOFError).
asyncio.create_task(self._request_propagation_node_messages(context=ctx))
# send config to websocket clients (used to tell ui last synced at)
await self.send_config_to_websocket_clients(context=ctx)
@ -18018,8 +18044,6 @@ class ReticulumMeshChat:
should_update_message = True
while should_update_message:
await asyncio.sleep(1)
progress_pct = round(lxmf_message.progress * 100, 2)
ctx.database.messages.update_lxmf_message_state(
message_hash=lxmf_message.hash.hex(),
@ -18069,6 +18093,8 @@ class ReticulumMeshChat:
# check if we should stop updating
if has_delivered or has_propagated or has_failed or is_cancelled:
should_update_message = False
else:
await asyncio.sleep(1)
def on_telephone_announce_received(
self,

View file

@ -0,0 +1,100 @@
# SPDX-License-Identifier: 0BSD
"""RNode USB serial / BLE UART support checks for desktop and Android."""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def _is_chaquopy_android() -> bool:
try:
from meshchatx.android_push_bridge import _is_chaquopy_android as _check
return _check()
except ImportError:
return False
def android_usbserial4a_available() -> bool:
"""True when Chaquopy can import usbserial4a (RNS RNode on Android)."""
if not _is_chaquopy_android():
return False
try:
import usbserial4a # noqa: F401
except ImportError:
return False
return True
def desktop_serial_stack_available() -> bool:
try:
from serial.tools import list_ports # noqa: F401
except ImportError:
return False
return True
def rnode_serial_supported() -> bool:
"""Whether RNode serial and ble:// UART ports can be opened on this platform."""
if _is_chaquopy_android():
return android_usbserial4a_available()
return desktop_serial_stack_available()
def disable_rnode_interfaces_in_config(config_path: str) -> bool:
"""Disable enabled RNode* interfaces in a Reticulum config file.
Returns True if any interfaces were disabled.
"""
import os
if not os.path.isfile(config_path):
return False
try:
from RNS.vendor.configobj import ConfigObj
cfg = ConfigObj(config_path)
except Exception:
return False
modified = False
interfaces = cfg.get("interfaces")
if not isinstance(interfaces, dict):
return False
for _iface_name, iface in interfaces.items():
if not isinstance(iface, dict):
continue
iface_type = iface.get("type", "")
if isinstance(iface_type, str) and iface_type.startswith("RNode"):
if str(iface.get("interface_enabled", "")).lower() in (
"true",
"yes",
"1",
"on",
):
iface["interface_enabled"] = "false"
modified = True
if modified:
try:
cfg.write()
except Exception:
pass
return modified
def guard_rnode_interfaces_on_android(config_path: str) -> bool:
"""On Android without usbserial4a, disable RNode interfaces to avoid startup crashes."""
if not _is_chaquopy_android():
return False
if rnode_serial_supported():
return False
disabled = disable_rnode_interfaces_in_config(config_path)
if disabled:
logger.warning(
"RNode interfaces were disabled because usbserial4a is not installed. "
"Rebuild the Android app with usbserial4a or remove RNode entries from config.",
)
return disabled

View file

@ -108,6 +108,7 @@ class RRCHubServer:
self.destination = None
self.running = False
self._started_at = None
self._lock = threading.RLock()
self._sessions = {}
@ -145,6 +146,7 @@ class RRCHubServer:
)
self.destination.set_link_established_callback(self._on_link)
self.running = True
self._started_at = time.time()
if self.announce:
self.announce_now()
self._log("hub started at " + self.dest_hash.hex())
@ -165,6 +167,7 @@ class RRCHubServer:
dest = self.destination
self.destination = None
self.running = False
self._started_at = None
for link in links:
with contextlib.suppress(Exception):
link.teardown()
@ -905,12 +908,16 @@ class RRCHubServer:
cfg["members"] = len(self._room_members.get(name, set()))
rooms.append(cfg)
policy = self.policy.to_dict()
uptime_seconds = 0
if self.running and self._started_at is not None:
uptime_seconds = max(0, int(time.time() - self._started_at))
return {
"id": self.hub_id,
"name": self.name,
"dest_hash": self.dest_hash.hex() if self.dest_hash else None,
"enabled": self.enabled,
"running": self.running,
"uptime_seconds": uptime_seconds,
"announce": self.announce,
"greeting": self.greeting,
"clients": sum(1 for s in self._sessions.values() if s.welcomed),

View file

@ -0,0 +1,199 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<AppUpdatePrompt
:model-value="visible"
:title="promptTitle"
:description="promptDescription"
:primary-label="primaryLabel"
:secondary-label="secondaryLabel"
:busy="busy"
:busy-text="$t('android_storage.working')"
:primary-disabled="setupMode && !selectedSetupMode"
@update:model-value="onVisibleUpdate"
@primary="onPrimary"
@secondary="onSecondary"
>
<div v-if="setupMode" class="space-y-2 text-left">
<label
class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-colors"
:class="
selectedSetupMode === 'external'
? 'border-blue-500 bg-blue-50/80 dark:bg-blue-950/30'
: 'border-gray-200 dark:border-zinc-800'
"
>
<input v-model="selectedSetupMode" type="radio" class="mt-1" value="external" />
<span>
<span class="font-medium text-gray-900 dark:text-zinc-100 block">
{{ $t("android_storage.setup_external_title") }}
</span>
<span class="text-xs text-gray-600 dark:text-zinc-400">
{{ $t("android_storage.setup_external_desc") }}
</span>
</span>
</label>
<label
class="flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-colors"
:class="
selectedSetupMode === 'internal'
? 'border-blue-500 bg-blue-50/80 dark:bg-blue-950/30'
: 'border-gray-200 dark:border-zinc-800'
"
>
<input v-model="selectedSetupMode" type="radio" class="mt-1" value="internal" />
<span>
<span class="font-medium text-gray-900 dark:text-zinc-100 block">
{{ $t("android_storage.setup_internal_title") }}
</span>
<span class="text-xs text-gray-600 dark:text-zinc-400">
{{ $t("android_storage.setup_internal_desc") }}
</span>
</span>
</label>
</div>
<p v-if="status?.active_path" class="text-[10px] font-mono text-gray-500 dark:text-zinc-500 break-all">
{{ status.active_path }}
</p>
</AppUpdatePrompt>
</template>
<script>
import AppUpdatePrompt from "./AppUpdatePrompt.vue";
import AndroidStorageBridge from "../js/AndroidStorageBridge.js";
import ToastUtils from "../js/ToastUtils.js";
export default {
name: "AndroidStorageChoicePrompt",
components: { AppUpdatePrompt },
props: {
variant: {
type: String,
default: "upgrade",
validator: (v) => v === "setup" || v === "upgrade",
},
},
emits: ["completed", "dismissed"],
data() {
return {
visible: false,
busy: false,
status: null,
selectedSetupMode: "external",
storageBridge: null,
};
},
computed: {
setupMode() {
return this.variant === "setup";
},
promptTitle() {
return this.setupMode ? this.$t("android_storage.setup_title") : this.$t("android_storage.upgrade_title");
},
promptDescription() {
return this.setupMode ? this.$t("android_storage.setup_desc") : this.$t("android_storage.upgrade_desc");
},
primaryLabel() {
return this.setupMode ? this.$t("android_storage.setup_continue") : this.$t("android_storage.upgrade_copy");
},
secondaryLabel() {
return this.setupMode ? "" : this.$t("android_storage.upgrade_stay_internal");
},
},
created() {
this.storageBridge = new AndroidStorageBridge();
},
methods: {
ensureStorageBridge() {
if (!this.storageBridge) {
this.storageBridge = new AndroidStorageBridge();
}
return this.storageBridge;
},
refreshStatus() {
this.status = this.ensureStorageBridge().getStatus();
return this.status;
},
shouldShowSetup() {
const s = this.refreshStatus();
return Boolean(s?.needs_setup_choice);
},
shouldShowUpgrade() {
const s = this.refreshStatus();
return Boolean(s?.needs_upgrade_prompt);
},
showSetup() {
if (!this.shouldShowSetup()) {
return false;
}
this.selectedSetupMode = "external";
this.visible = true;
return true;
},
showUpgrade() {
if (!this.shouldShowUpgrade()) {
return false;
}
this.visible = true;
return true;
},
hide() {
this.visible = false;
},
onVisibleUpdate(val) {
this.visible = val;
if (!val) {
this.$emit("dismissed");
}
},
async onPrimary() {
if (this.busy) {
return;
}
this.busy = true;
try {
if (this.setupMode) {
const mode = this.selectedSetupMode || "external";
const result = this.ensureStorageBridge().applySetupChoice(mode, this.status);
this.hide();
this.$emit("completed", { action: "setup", mode, restarted: result.restarted });
if (result.restarted) {
ToastUtils.success(this.$t("android_storage.restart_to_apply"));
}
return;
}
if (!this.ensureStorageBridge().scheduleCopyToExternalAndRestart()) {
ToastUtils.error(this.$t("android_storage.failed"));
return;
}
ToastUtils.success(this.$t("android_storage.copy_restart_hint"));
this.ensureStorageBridge().restartApp();
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("android_storage.failed"));
} finally {
this.busy = false;
}
},
async onSecondary() {
if (this.busy || this.setupMode) {
return;
}
this.busy = true;
try {
if (!this.ensureStorageBridge().keepInternalAndDismiss()) {
ToastUtils.error(this.$t("android_storage.failed"));
return;
}
this.hide();
this.$emit("completed", { action: "stay_internal" });
} catch (e) {
console.error(e);
ToastUtils.error(this.$t("android_storage.failed"));
} finally {
this.busy = false;
}
},
},
};
</script>

View file

@ -643,6 +643,11 @@
<IntegrityWarningModal />
<ChangelogModal ref="changelogModal" :app-version="appInfo?.version" />
<TutorialModal ref="tutorialModal" />
<AndroidStorageChoicePrompt
ref="androidStorageUpgradePrompt"
variant="upgrade"
@completed="onAndroidStorageUpgradeCompleted"
/>
<!-- LXMF QR modal -->
<div
@ -754,6 +759,7 @@ import CommandPalette from "./CommandPalette.vue";
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
import ChangelogModal from "./ChangelogModal.vue";
import TutorialModal from "./TutorialModal.vue";
import AndroidStorageChoicePrompt from "./AndroidStorageChoicePrompt.vue";
import AppShellBanners from "./layout/AppShellBanners.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
@ -776,6 +782,7 @@ export default {
IntegrityWarningModal,
ChangelogModal,
TutorialModal,
AndroidStorageChoicePrompt,
AppShellBanners,
},
setup() {
@ -1274,6 +1281,16 @@ export default {
onShowTutorialShell() {
this.$refs.tutorialModal?.show();
},
maybeShowAndroidStorageUpgrade() {
const prompt = this.$refs.androidStorageUpgradePrompt;
if (!prompt || typeof prompt.showUpgrade !== "function") {
return false;
}
return prompt.showUpgrade();
},
onAndroidStorageUpgradeCompleted() {
// prompt handles restart when user copies to external storage
},
updateUnreadConversationsCount() {
if (this._unreadCountTimeout) {
clearTimeout(this._unreadCountTimeout);
@ -1574,6 +1591,8 @@ export default {
this.hasCheckedForModals = true;
if (this.appInfo && !this.appInfo.tutorial_seen) {
this.$refs.tutorialModal.show();
} else if (this.maybeShowAndroidStorageUpgrade()) {
// upgrade prompt for existing internal-storage installs
} else if (
this.appInfo &&
this.appInfo.changelog_seen_version !== "999.999.999" &&
@ -1749,6 +1768,8 @@ export default {
// Guard to prevent overlapping poll calls
this._isPropagationSyncPolling = false;
const pollStartedAt = Date.now();
const propagationSyncPollTimeoutMs = 120000;
const poll = async () => {
if (this._isPropagationSyncPolling) return;
@ -1756,6 +1777,19 @@ export default {
try {
await this.updatePropagationNodeStatus();
if (this.isSyncingPropagationNode) {
if (Date.now() - pollStartedAt > propagationSyncPollTimeoutMs) {
if (this._propagationSyncPollTimer != null) {
clearInterval(this._propagationSyncPollTimer);
this._propagationSyncPollTimer = null;
}
await this.stopSyncingPropagationNode();
ToastUtils.error(
this.$t("app.sync_error", {
status: this.propagationSyncStatusLabel("path_timeout"),
})
);
return;
}
ToastUtils.loading(this.propagationSyncLiveToastMessage(), 0, propagationSyncToastKey);
return;
}

View file

@ -224,9 +224,7 @@
:title="cv.outboundSentStatusTitle(entry.items[0].lxmf_message)"
/>
<svg
v-else-if="
cv.showRichOutboundPendingUi(entry.items[0]) && cv.isOutboundPendingForUi(entry.items[0])
"
v-if="cv.showRichOutboundPendingUi(entry.items[0]) && cv.isOutboundPendingForUi(entry.items[0])"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@ -303,6 +301,24 @@
</button>
</div>
</div>
<div
v-if="
entry.items[0].is_outbound && cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) !== null
"
class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
>
<div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
<div
class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
:style="{
width: cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) + '%',
}"
></div>
</div>
<span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
{{ cv.outboundSendingProgressLabel(entry.items[0].lxmf_message) }}
</span>
</div>
<div
v-if="cv.expandedMessageInfo === entry.items[0].lxmf_message.hash"
class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5"
@ -1081,7 +1097,7 @@
:title="cv.outboundSentStatusTitle(chatItem.lxmf_message)"
/>
<svg
v-else-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
v-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@ -1163,6 +1179,21 @@
</div>
</div>
<div
v-if="chatItem.is_outbound && cv.outboundTransferProgressPercent(chatItem.lxmf_message) !== null"
class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
>
<div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
<div
class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
:style="{ width: cv.outboundTransferProgressPercent(chatItem.lxmf_message) + '%' }"
></div>
</div>
<span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
{{ cv.outboundSendingProgressLabel(chatItem.lxmf_message) }}
</span>
</div>
<div
v-if="chatItem.lxmf_message.reactions?.length || !chatItem.lxmf_message.is_reaction"
class="flex w-fit max-w-full flex-wrap items-center gap-0.5 px-0.5"

View file

@ -3519,12 +3519,16 @@ export default {
return;
}
const prev = this.chatItems[chatItemIndex].lxmf_message;
const chatItem = this.chatItems[chatItemIndex];
const prev = chatItem.lxmf_message;
const merged = { ...prev, ...lxmfMessage };
if (!Object.prototype.hasOwnProperty.call(lxmfMessage, "_pendingPathfinding")) {
delete merged._pendingPathfinding;
}
this.chatItems[chatItemIndex].lxmf_message = merged;
this.chatItems[chatItemIndex] = {
...chatItem,
lxmf_message: merged,
};
},
onLxmfMessageDeleted(hash) {
if (hash) {
@ -4300,6 +4304,24 @@ export default {
return true;
});
},
outboundTransferProgressPercent(lxmfMessage) {
if (!lxmfMessage || lxmfMessage._pendingPathfinding) {
return null;
}
const progress = Number(lxmfMessage.progress ?? 0);
const state = lxmfMessage.state;
if (state === "sending") {
return Math.min(100, Math.max(0, Math.round(progress)));
}
if (progress > 0 && ["outbound", "generating"].includes(state)) {
return Math.min(100, Math.max(0, Math.round(progress)));
}
return null;
},
outboundSendingProgressLabel(lxmfMessage) {
const pct = this.outboundTransferProgressPercent(lxmfMessage);
return pct === null ? null : `${pct}%`;
},
outboundSendingStatusTooltip(lxmfMessage) {
if (!lxmfMessage) {
return "";

View file

@ -703,146 +703,127 @@
</div>
<!-- host view -->
<div v-show="view === 'host'" class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div class="mx-auto w-full max-w-3xl space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">{{ $t("relay_chat.host_title") }}</h2>
<p class="text-sm text-sem-fg-muted">{{ $t("relay_chat.host_subtitle") }}</p>
</div>
<button type="button" :class="btnPrimary" @click="openCreateHub">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.create_hub") }}
</button>
</div>
<div
v-if="serverHubs.length === 0"
class="flex flex-col items-center gap-2 rounded-xl border border-sem-border bg-sem-canvas p-8 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="server-network-off" class="size-10 opacity-40" />
{{ $t("relay_chat.no_hosted_hubs") }}
</div>
<div
v-for="hub in serverHubs"
:key="hub.id"
class="rounded-xl border border-sem-border bg-sem-canvas p-4 space-y-3"
>
<div v-show="view === 'host'" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<RelayHostModerationPage
v-if="hostModeration.hub"
:hub="hostModeration.hub"
:initial-tab="hostModeration.tab"
:room-filter="hostModeration.room"
@back="closeHostModeration"
@refresh="fetchServers"
/>
<div v-else class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div class="mx-auto w-full max-w-3xl space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="size-2 shrink-0 rounded-full"
:class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
></span>
<span class="font-semibold truncate">{{ hub.name }}</span>
</div>
<button
type="button"
class="mt-1 flex items-center gap-1.5 text-xs font-mono text-sem-fg-muted hover:text-sem-accent"
:title="$t('relay_chat.copy_hash')"
@click="copyHash(hub.dest_hash)"
>
<MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
<span class="truncate">{{ formatHash(hub.dest_hash) }}</span>
</button>
<div>
<h2 class="text-lg font-semibold">{{ $t("relay_chat.host_title") }}</h2>
<p class="text-sm text-sem-fg-muted">{{ $t("relay_chat.host_subtitle") }}</p>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
v-if="!hub.running"
type="button"
:class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
@click="startServerHub(hub)"
>
<MaterialDesignIcon icon-name="play" class="size-4" />
{{ $t("relay_chat.host_start") }}
</button>
<button
v-else
type="button"
:class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
@click="stopServerHub(hub)"
>
<MaterialDesignIcon icon-name="stop" class="size-4" />
{{ $t("relay_chat.host_stop") }}
</button>
<button
type="button"
:class="btnIcon"
:title="$t('relay_chat.host_announce')"
:disabled="!hub.running"
@click="announceServerHub(hub)"
>
<MaterialDesignIcon icon-name="bullhorn-outline" class="size-4" />
</button>
<button
type="button"
:class="btnDanger"
:title="$t('relay_chat.host_delete')"
@click="deleteServerHub(hub)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-sem-surface/60 hover:text-sem-fg"
:title="$t('relay_chat.show_members')"
:disabled="!hub.running || hub.clients === 0"
@click="openHostMembers(hub)"
>
<MaterialDesignIcon icon-name="account-group" class="size-3.5" />
{{ hub.clients }} {{ $t("relay_chat.host_clients") }}
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-sem-surface/60 hover:text-sem-fg"
:title="$t('relay_chat.host_manage_rooms')"
@click="openHostRooms(hub)"
>
<MaterialDesignIcon icon-name="pound" class="size-3.5" />
{{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
<button type="button" :class="btnPrimary" @click="openCreateHub">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.create_hub") }}
</button>
</div>
<button
type="button"
:class="[btnSecondary, 'w-full !py-2 !text-xs']"
@click="openHostRooms(hub)"
<div
v-if="serverHubs.length === 0"
class="flex flex-col items-center gap-2 rounded-xl border border-sem-border bg-sem-canvas p-8 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="door-open" class="size-4" />
{{ $t("relay_chat.host_manage_rooms") }}
</button>
<MaterialDesignIcon icon-name="server-network-off" class="size-10 opacity-40" />
{{ $t("relay_chat.no_hosted_hubs") }}
</div>
<div
v-for="hub in serverHubs"
:key="hub.id"
class="rounded-xl border border-sem-border bg-sem-canvas p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="size-2 shrink-0 rounded-full"
:class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
></span>
<span class="font-semibold truncate">{{ hub.name }}</span>
</div>
<button
type="button"
class="mt-1 flex items-center gap-1.5 text-xs font-mono text-sem-fg-muted hover:text-sem-accent"
:title="$t('relay_chat.copy_hash')"
@click="copyHash(hub.dest_hash)"
>
<MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
<span class="truncate">{{ formatHash(hub.dest_hash) }}</span>
</button>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
v-if="!hub.running"
type="button"
:class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
@click="startServerHub(hub)"
>
<MaterialDesignIcon icon-name="play" class="size-4" />
{{ $t("relay_chat.host_start") }}
</button>
<button
v-else
type="button"
:class="[btnSecondary, '!px-2.5 !py-1.5 !text-xs']"
@click="stopServerHub(hub)"
>
<MaterialDesignIcon icon-name="stop" class="size-4" />
{{ $t("relay_chat.host_stop") }}
</button>
<button
type="button"
:class="btnIcon"
:title="$t('relay_chat.host_announce')"
:disabled="!hub.running"
@click="announceServerHub(hub)"
>
<MaterialDesignIcon icon-name="bullhorn-outline" class="size-4" />
</button>
<button
type="button"
:class="btnDanger"
:title="$t('relay_chat.host_delete')"
@click="deleteServerHub(hub)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="account-group" class="size-3.5" />
{{ hub.clients }} {{ $t("relay_chat.host_clients") }}
</span>
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="pound" class="size-3.5" />
{{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
</span>
</div>
<button
type="button"
:class="[btnSecondary, 'w-full !py-2 !text-xs']"
@click="openHostModeration(hub)"
>
<MaterialDesignIcon icon-name="shield-account" class="size-4" />
{{ $t("relay_chat.host_moderate") }}
</button>
</div>
</div>
</div>
</div>
<RelayHostMembersModal
:open="hostMembersModal.open"
:hub="hostMembersModal.hub"
:room="hostMembersModal.room"
@close="closeHostMembers"
@refresh="fetchServers"
/>
<RelayHostRoomsModal
:open="hostRoomsModal.open"
:hub="hostRoomsModal.hub"
@close="closeHostRooms"
@refresh="fetchServers"
/>
<!-- create hub dialog -->
<div
v-if="showCreateHub"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="showCreateHub = false"
>
<div class="w-full max-w-md rounded-2xl border border-sem-border-card bg-sem-surface p-5 shadow-xl">
<h2 class="mb-4 text-lg font-semibold">{{ $t("relay_chat.create_hub_title") }}</h2>
<div v-if="showCreateHub" :class="RELAY_HOST_MODAL_OVERLAY" @click.self="showCreateHub = false">
<div :class="RELAY_HOST_MODAL_PANEL_COMPACT" @click.stop>
<h2 class="mb-4 text-lg font-semibold text-sem-fg">{{ $t("relay_chat.create_hub_title") }}</h2>
<form class="space-y-4" @submit.prevent="createServerHub">
<div class="space-y-1.5">
<label class="block text-sm font-semibold text-sem-fg-secondary">{{
@ -1113,10 +1094,10 @@ import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
import RelayHostMembersModal from "./RelayHostMembersModal.vue";
import RelayHostRoomsModal from "./RelayHostRoomsModal.vue";
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
@ -1142,8 +1123,7 @@ export default {
components: {
MaterialDesignIcon,
MdiIconPickerModal,
RelayHostMembersModal,
RelayHostRoomsModal,
RelayHostModerationPage,
ContextMenuPanel,
ContextMenuItem,
ContextMenuDivider,
@ -1159,6 +1139,8 @@ export default {
},
data() {
return {
RELAY_HOST_MODAL_OVERLAY,
RELAY_HOST_MODAL_PANEL_COMPACT,
btnPrimary: BTN_PRIMARY,
btnSecondary: BTN_SECONDARY,
btnIcon: BTN_ICON,
@ -1234,15 +1216,11 @@ export default {
has_custom_name: false,
hub_icon: null,
},
hostMembersModal: {
open: false,
hostModeration: {
hub: null,
tab: "rooms",
room: null,
},
hostRoomsModal: {
open: false,
hub: null,
},
};
},
computed: {
@ -1349,6 +1327,9 @@ export default {
},
methods: {
selectView(view) {
if (view !== "host") {
this.closeHostModeration();
}
this.view = view;
this.persistRelayLayout();
if (view === "discovery") {
@ -2316,25 +2297,27 @@ export default {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
openHostMembers(hub, room = null) {
this.hostMembersModal = {
open: true,
openHostModeration(hub, { tab = "rooms", room = null } = {}) {
if (!hub) {
return;
}
if (tab === "members" && !hub.running) {
ToastUtils.warning(this.$t("relay_chat.host_hub_not_running"));
return;
}
this.hostModeration = {
hub,
tab: tab === "members" ? "members" : "rooms",
room: room || null,
};
},
closeHostMembers() {
this.hostMembersModal.open = false;
},
openHostRooms(hub) {
this.hostRoomsModal = {
open: true,
hub,
closeHostModeration() {
this.hostModeration = {
hub: null,
tab: "rooms",
room: null,
};
},
closeHostRooms() {
this.hostRoomsModal.open = false;
},
copyHash(hash) {
if (!hash) {
return;

View file

@ -1,410 +0,0 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="$emit('close')"
>
<div
class="flex h-[min(100dvh-2rem,900px)] w-full max-w-6xl flex-col rounded-2xl border border-sem-border-card bg-sem-surface shadow-xl"
role="dialog"
:aria-label="title"
>
<div class="flex shrink-0 items-center gap-2 border-b border-sem-border px-4 py-3 sm:px-5 sm:py-4">
<button
v-if="selectedMember && isNarrow"
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
@click="selectedMember = null"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-5" />
</button>
<h2 class="min-w-0 flex-1 text-lg font-semibold truncate">{{ title }}</h2>
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
:title="$t('common.close')"
@click="$emit('close')"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="shrink-0 border-b border-sem-border px-4 py-2.5 sm:px-5">
<div class="relative">
<input
v-model="search"
type="search"
:placeholder="$t('relay_chat.host_members_search')"
class="input-field !py-2.5 pr-10"
/>
<MaterialDesignIcon
icon-name="magnify"
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
/>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
<div
class="flex min-h-0 flex-col border-sem-border lg:w-80 lg:shrink-0 lg:border-r"
:class="isNarrow && selectedMember ? 'hidden' : 'flex-1 lg:flex-none'"
>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="loading" class="py-12 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<div
v-else-if="filteredMembers.length === 0"
class="py-12 text-center text-sm text-sem-fg-muted"
>
{{ $t("relay_chat.no_members") }}
</div>
<ul v-else class="space-y-1.5">
<li
v-for="member in filteredMembers"
:key="member.hash"
class="rounded-xl border px-3 py-2.5 transition-colors cursor-pointer"
:class="
selectedMember?.hash === member.hash
? 'border-sem-accent bg-sem-accent/10'
: 'border-sem-border hover:bg-sem-surface/50'
"
@click="selectMember(member)"
>
<div class="flex items-start gap-2">
<span class="mt-1.5 size-2 shrink-0 rounded-full bg-sem-success"></span>
<div class="min-w-0 flex-1">
<div class="truncate font-medium" :style="{ color: colorForHash(member.hash) }">
{{ member.name }}
</div>
<div class="truncate font-mono text-xs text-sem-fg-muted">
{{ formatHash(member.hash) }}
</div>
<div
v-if="!room && member.rooms?.length"
class="mt-1 text-xs text-sem-fg-muted truncate"
>
{{ member.rooms.map((r) => "#" + r).join(", ") }}
</div>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-warning/15 hover:text-sem-warning"
:title="$t('relay_chat.ctx_kick_user')"
@click.stop="moderate(member, 'kick')"
>
<MaterialDesignIcon icon-name="account-remove" class="size-4" />
</button>
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-danger/15 hover:text-sem-danger"
:title="$t('relay_chat.host_ban_hub')"
@click.stop="moderate(member, room ? 'room_ban' : 'ban')"
>
<MaterialDesignIcon icon-name="block-helper" class="size-4" />
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<div
class="flex min-h-0 min-w-0 flex-1 flex-col"
:class="isNarrow && !selectedMember ? 'hidden' : 'flex'"
>
<div
v-if="!selectedMember"
class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="account-search" class="size-10 opacity-40" />
{{ $t("relay_chat.host_members_select") }}
</div>
<template v-else>
<div class="shrink-0 border-b border-sem-border px-4 py-3 sm:px-5">
<div class="font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
{{ selectedMember.name }}
</div>
<button
type="button"
class="mt-0.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
@click="copyHash(selectedMember.hash)"
>
{{ formatHash(selectedMember.hash) }}
</button>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="messagesLoading" class="py-8 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<div
v-else-if="memberMessages.length === 0"
class="py-8 text-center text-sm text-sem-fg-muted"
>
{{ $t("relay_chat.host_no_messages") }}
</div>
<ul v-else class="space-y-2">
<li
v-for="(msg, idx) in memberMessages"
:key="idx"
class="rounded-lg border border-sem-border bg-sem-canvas px-3 py-2 text-sm"
>
<div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
<span>#{{ msg.room }}</span>
<span>{{ formatTime(msg.ts) }}</span>
<span v-if="msg.kind === 'action'" class="italic">action</span>
</div>
<div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
export default {
name: "RelayHostMembersModal",
components: { MaterialDesignIcon },
props: {
open: { type: Boolean, default: false },
hub: { type: Object, default: null },
room: { type: String, default: null },
},
emits: ["close", "refresh"],
data() {
return {
loading: false,
members: [],
search: "",
selectedMember: null,
memberMessages: [],
messagesLoading: false,
isNarrow: false,
mq: null,
localIdentityHash: "",
};
},
computed: {
title() {
const hubName = this.hub?.name || "";
if (this.room) {
return this.$t("relay_chat.host_members_room_title", { hub: hubName, room: this.room });
}
return this.$t("relay_chat.host_members_all_title", { hub: hubName });
},
filteredMembers() {
const q = this.search.trim().toLowerCase();
if (!q) {
return this.members;
}
return this.members.filter((m) => {
const name = (m.name || "").toLowerCase();
const hash = (m.hash || "").toLowerCase();
const rooms = (m.rooms || []).join(" ").toLowerCase();
return name.includes(q) || hash.includes(q) || rooms.includes(q);
});
},
},
watch: {
open(val) {
if (val) {
this.search = "";
this.selectedMember = null;
this.memberMessages = [];
this.ensureLocalIdentity();
this.fetchMembers();
}
},
},
mounted() {
this.mq = window.matchMedia("(max-width: 1023px)");
this.isNarrow = this.mq.matches;
this.mq.addEventListener("change", this.onMq);
},
beforeUnmount() {
if (this.mq) {
this.mq.removeEventListener("change", this.onMq);
}
},
methods: {
onMq() {
this.isNarrow = this.mq.matches;
},
async fetchMembers() {
if (!this.hub?.id) {
return;
}
this.loading = true;
try {
const params = this.room ? { params: { room: this.room } } : {};
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/members`, params);
this.members = response.data?.members || [];
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
this.$emit("close");
} finally {
this.loading = false;
}
},
selectMember(member) {
this.selectedMember = member;
this.loadMemberMessages();
},
async loadMemberMessages() {
if (!this.selectedMember || !this.hub?.id) {
return;
}
this.messagesLoading = true;
try {
const params = { peer: this.selectedMember.hash, limit: 200 };
if (this.room) {
params.room = this.room;
}
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/messages`, { params });
this.memberMessages = response.data?.messages || [];
} catch {
this.memberMessages = [];
} finally {
this.messagesLoading = false;
}
},
async ensureLocalIdentity() {
if (this.localIdentityHash) {
return;
}
try {
const response = await window.api.get("/api/v1/config");
const hash = response.data?.identity_hash;
if (typeof hash === "string" && hash.trim()) {
this.localIdentityHash = hash.trim().toLowerCase();
}
} catch {
// config may be unavailable in tests
}
},
async resolveModerationRoom(member, action) {
if (this.room) {
return this.room;
}
const needsRoom = action === "kick" || action === "room_ban";
if (!needsRoom) {
return null;
}
const rooms = (member.rooms || []).filter((r) => typeof r === "string" && r.trim());
if (rooms.length === 0) {
ToastUtils.warning(this.$t("relay_chat.host_kick_no_room"));
return null;
}
if (rooms.length === 1) {
return rooms[0];
}
const entered = await DialogUtils.prompt(
this.$t("relay_chat.host_kick_pick_room", {
name: member.name,
rooms: rooms.map((r) => "#" + r).join(", "),
})
);
if (!entered) {
return null;
}
const norm = entered.trim().replace(/^#/, "");
const match = rooms.find((r) => r.toLowerCase() === norm.toLowerCase());
if (!match) {
ToastUtils.warning(this.$t("relay_chat.host_kick_room_invalid"));
return null;
}
return match;
},
async moderate(member, action) {
if (!this.hub?.id || !member?.hash) {
return;
}
await this.ensureLocalIdentity();
if (this.localIdentityHash && member.hash.toLowerCase() === this.localIdentityHash) {
ToastUtils.warning(this.$t("relay_chat.host_cannot_moderate_self"));
return;
}
const room = await this.resolveModerationRoom(member, action);
if ((action === "kick" || action === "room_ban") && !room) {
return;
}
const labels = {
kick: this.$t("relay_chat.host_kick_confirm", { name: member.name, room }),
ban: this.$t("relay_chat.host_ban_confirm", { name: member.name }),
room_ban: this.$t("relay_chat.host_room_ban_confirm", {
name: member.name,
room,
}),
};
const confirmed = await DialogUtils.confirm(labels[action] || "");
if (!confirmed) {
return;
}
try {
await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/moderate`, {
action,
peer: member.hash,
room: room || undefined,
});
ToastUtils.success(this.$t("common.success"));
this.$emit("refresh");
await this.fetchMembers();
if (this.selectedMember?.hash === member.hash) {
this.selectedMember = null;
this.memberMessages = [];
}
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
colorForHash(hash) {
if (!hash) {
return undefined;
}
let n = 0;
for (let i = 0; i < hash.length; i++) {
n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
}
return NAME_COLORS[n];
},
formatHash(hash) {
if (!hash || hash.length < 16) {
return hash || "";
}
return hash.slice(0, 8) + "..." + hash.slice(-8);
},
formatTime(ts) {
if (!ts) {
return "";
}
const d = new Date(ts);
return d.toLocaleString();
},
copyHash(hash) {
if (!hash) {
return;
}
try {
navigator.clipboard.writeText(hash);
ToastUtils.success(this.$t("relay_chat.hash_copied"));
} catch {
ToastUtils.error(this.$t("common.failed_to_copy"));
}
},
},
};
</script>

View file

@ -0,0 +1,825 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden bg-sem-canvas text-sem-fg">
<div :class="RELAY_HOST_PAGE_HEADER">
<button type="button" :class="RELAY_HOST_ICON_BTN" :title="$t('relay_chat.back')" @click="$emit('back')">
<MaterialDesignIcon icon-name="arrow-left" class="size-5" />
</button>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold text-sem-fg">{{ pageTitle }}</h2>
<p v-if="hub" class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-sem-fg-muted">
<span class="inline-flex items-center gap-1.5">
<span
class="inline-block size-2 shrink-0 rounded-full"
:class="hub.running ? 'bg-sem-success' : 'bg-sem-fg-muted'"
></span>
{{ hub.running ? $t("relay_chat.host_status_running") : $t("relay_chat.host_status_stopped") }}
</span>
<template v-if="hub.running && liveUptimeSeconds > 0">
<span class="text-sem-border-strong" aria-hidden="true">·</span>
<span>{{
$t("relay_chat.host_moderation_uptime", { time: formatUptime(liveUptimeSeconds) })
}}</span>
</template>
<template v-if="navbarMemberCount != null">
<span class="text-sem-border-strong" aria-hidden="true">·</span>
<span>{{ $t("relay_chat.host_moderation_members", { count: navbarMemberCount }) }}</span>
</template>
</p>
</div>
</div>
<div :class="RELAY_HOST_PAGE_TABS" role="tablist">
<button
type="button"
role="tab"
:aria-selected="tab === 'rooms'"
:class="[RELAY_HOST_PAGE_TAB, tab === 'rooms' ? RELAY_HOST_PAGE_TAB_ACTIVE : RELAY_HOST_PAGE_TAB_IDLE]"
@click="setTab('rooms')"
>
<MaterialDesignIcon icon-name="pound" class="size-4" />
{{ $t("relay_chat.host_moderation_tab_rooms") }}
</button>
<button
type="button"
role="tab"
:aria-selected="tab === 'members'"
:class="[
RELAY_HOST_PAGE_TAB,
tab === 'members' ? RELAY_HOST_PAGE_TAB_ACTIVE : RELAY_HOST_PAGE_TAB_IDLE,
]"
@click="setTab('members')"
>
<MaterialDesignIcon icon-name="account-group" class="size-4" />
{{ $t("relay_chat.host_moderation_tab_members") }}
</button>
</div>
<div v-if="!hub" class="flex flex-1 items-center justify-center p-6 text-sm text-sem-fg-muted">
{{ $t("relay_chat.host_moderation_hub_missing") }}
</div>
<div v-else-if="tab === 'rooms'" :class="RELAY_HOST_PAGE_BODY">
<div :class="[RELAY_HOST_PAGE_LIST, isNarrow && selectedRoom ? 'hidden' : 'flex flex-1 lg:flex-none']">
<div class="shrink-0 space-y-3 border-b border-sem-border p-3 sm:p-4">
<div class="relative">
<input
v-model="roomsSearch"
type="search"
:placeholder="$t('relay_chat.host_rooms_search')"
class="input-field !py-2.5 pl-10 pr-3"
/>
<MaterialDesignIcon
icon-name="magnify"
class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
/>
</div>
<button
v-if="!showAddRoomForm"
type="button"
class="flex w-full items-center gap-2 rounded-xl border-2 border-dashed border-sem-border px-3 py-3 text-left text-sm text-sem-fg-muted transition-colors hover:border-sem-accent hover:bg-sem-surface/40 hover:text-sem-accent"
@click="showAddRoomForm = true"
>
<MaterialDesignIcon icon-name="plus-circle-outline" class="size-5 shrink-0" />
<span class="font-medium">{{ $t("relay_chat.host_add_room") }}</span>
</button>
<form
v-else
class="space-y-2.5 rounded-xl border border-sem-border bg-sem-surface-raised/40 p-3"
@submit.prevent="createRoom"
>
<input
v-model="newRoom.name"
type="text"
:placeholder="$t('relay_chat.host_room_name')"
class="input-field w-full !py-2.5 !text-sm"
autofocus
/>
<input
v-model="newRoom.topic"
type="text"
:placeholder="$t('relay_chat.host_room_topic')"
class="input-field w-full !py-2.5 !text-sm"
/>
<div class="flex gap-2 pt-0.5">
<button
type="submit"
:class="[btnPrimary, 'flex-1 !py-2.5 !text-sm']"
:disabled="creatingRoom"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.host_add_room") }}
</button>
<button
type="button"
:class="[btnSecondary, '!py-2.5 !text-sm']"
:disabled="creatingRoom"
@click="cancelAddRoom"
>
{{ $t("common.cancel") }}
</button>
</div>
</form>
</div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="roomsLoading" class="py-12 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<div v-else-if="filteredRooms.length === 0" class="py-12 text-center text-sm text-sem-fg-muted">
{{
roomsSearch.trim()
? $t("relay_chat.host_rooms_search_empty")
: $t("relay_chat.host_no_rooms")
}}
</div>
<ul v-else class="space-y-1.5">
<li
v-for="room in filteredRooms"
:key="room.name"
class="cursor-pointer px-3 py-2.5"
:class="[
RELAY_HOST_LIST_ITEM,
selectedRoom === room.name ? RELAY_HOST_LIST_ITEM_SELECTED : RELAY_HOST_LIST_ITEM_IDLE,
]"
@click="selectedRoom = room.name"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium text-sem-fg">#{{ room.name }}</div>
<div v-if="room.topic" class="truncate text-xs text-sem-fg-muted">
{{ room.topic }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 text-xs text-sem-fg-muted">
<span>{{ room.members }} {{ $t("relay_chat.host_clients") }}</span>
<span>{{ room.message_count || 0 }} msgs</span>
</div>
</div>
<button
v-if="room.registered"
type="button"
class="shrink-0 rounded-lg p-1.5 text-sem-fg-muted hover:text-sem-danger"
:title="$t('relay_chat.host_delete_room')"
@click.stop="deleteRoom(room.name)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
</button>
</div>
</li>
</ul>
</div>
</div>
<div :class="[RELAY_HOST_PAGE_DETAIL, isNarrow && !selectedRoom ? 'hidden' : 'flex']">
<div
v-if="isNarrow && selectedRoom"
class="flex shrink-0 items-center gap-2 border-b border-sem-border px-3 py-2 lg:hidden"
>
<button type="button" :class="RELAY_HOST_ICON_BTN" @click="selectedRoom = null">
<MaterialDesignIcon icon-name="arrow-left" class="size-5" />
</button>
<span class="font-semibold text-sem-fg">#{{ selectedRoom }}</span>
</div>
<div
v-if="!selectedRoom"
class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="pound" class="size-10 opacity-40" />
{{ $t("relay_chat.host_rooms_select") }}
</div>
<template v-else>
<div :class="[RELAY_HOST_DETAIL_HEADER, 'hidden lg:block']">
<div class="font-semibold text-sem-fg">#{{ selectedRoom }}</div>
<div class="text-xs text-sem-fg-muted">{{ $t("relay_chat.host_room_activity") }}</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<ul v-if="roomMessages.length > 0" class="space-y-2">
<li v-for="(msg, idx) in roomMessages" :key="idx" :class="RELAY_HOST_MESSAGE">
<div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
<span :style="{ color: colorForHash(msg.peer) }">{{
msg.nick || formatHash(msg.peer)
}}</span>
<span>{{ formatTime(msg.ts) }}</span>
</div>
<div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
</li>
</ul>
<div v-else class="py-8 text-center text-sm text-sem-fg-muted">
{{ $t("relay_chat.host_no_activity") }}
</div>
</div>
</template>
</div>
</div>
<div v-else :class="RELAY_HOST_PAGE_BODY">
<div :class="[RELAY_HOST_PAGE_LIST, isNarrow && selectedMember ? 'hidden' : 'flex flex-1 lg:flex-none']">
<div class="shrink-0 border-b border-sem-border p-3 sm:p-4">
<div class="relative">
<input
v-model="membersSearch"
type="search"
:placeholder="$t('relay_chat.host_members_search')"
class="input-field !py-2.5 pl-10 pr-3"
/>
<MaterialDesignIcon
icon-name="magnify"
class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-sem-fg-muted"
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="membersLoading" class="py-12 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<div v-else-if="filteredMembers.length === 0" class="py-12 text-center text-sm text-sem-fg-muted">
{{ $t("relay_chat.no_members") }}
</div>
<ul v-else class="space-y-1.5">
<li
v-for="member in filteredMembers"
:key="member.hash"
class="cursor-pointer px-3 py-2.5"
:class="[
RELAY_HOST_LIST_ITEM,
selectedMember?.hash === member.hash
? RELAY_HOST_LIST_ITEM_SELECTED
: RELAY_HOST_LIST_ITEM_IDLE,
]"
@click="selectMember(member)"
>
<div class="flex items-start gap-2">
<span class="mt-1.5 size-2 shrink-0 rounded-full bg-sem-success"></span>
<div class="min-w-0 flex-1">
<div class="truncate font-medium" :style="{ color: colorForHash(member.hash) }">
{{ member.name }}
</div>
<div class="truncate font-mono text-xs text-sem-fg-muted">
{{ formatHash(member.hash) }}
</div>
<div
v-if="!roomFilter && member.rooms?.length"
class="mt-1 truncate text-xs text-sem-fg-muted"
>
{{ member.rooms.map((r) => "#" + r).join(", ") }}
</div>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-warning/15 hover:text-sem-warning"
:title="$t('relay_chat.ctx_kick_user')"
@click.stop="moderate(member, 'kick')"
>
<MaterialDesignIcon icon-name="account-remove" class="size-4" />
</button>
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-danger/15 hover:text-sem-danger"
:title="$t('relay_chat.host_ban_hub')"
@click.stop="moderate(member, roomFilter ? 'room_ban' : 'ban')"
>
<MaterialDesignIcon icon-name="block-helper" class="size-4" />
</button>
</div>
</div>
</li>
</ul>
</div>
</div>
<div :class="[RELAY_HOST_PAGE_DETAIL, isNarrow && !selectedMember ? 'hidden' : 'flex']">
<div
v-if="isNarrow && selectedMember"
class="flex shrink-0 items-center gap-2 border-b border-sem-border px-3 py-2 lg:hidden"
>
<button type="button" :class="RELAY_HOST_ICON_BTN" @click="selectedMember = null">
<MaterialDesignIcon icon-name="arrow-left" class="size-5" />
</button>
<span class="truncate font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
{{ selectedMember.name }}
</span>
</div>
<div
v-if="!selectedMember"
class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="account-search" class="size-10 opacity-40" />
{{ $t("relay_chat.host_members_select") }}
</div>
<template v-else>
<div :class="[RELAY_HOST_DETAIL_HEADER, 'hidden lg:block']">
<div class="font-semibold" :style="{ color: colorForHash(selectedMember.hash) }">
{{ selectedMember.name }}
</div>
<button
type="button"
class="mt-0.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
@click="copyHash(selectedMember.hash)"
>
{{ formatHash(selectedMember.hash) }}
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="messagesLoading" class="py-8 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<div v-else-if="memberMessages.length === 0" class="py-8 text-center text-sm text-sem-fg-muted">
{{ $t("relay_chat.host_no_messages") }}
</div>
<ul v-else class="space-y-2">
<li v-for="(msg, idx) in memberMessages" :key="idx" :class="RELAY_HOST_MESSAGE">
<div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
<span>#{{ msg.room }}</span>
<span>{{ formatTime(msg.ts) }}</span>
<span v-if="msg.kind === 'action'" class="italic">action</span>
</div>
<div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import {
RELAY_HOST_DETAIL_HEADER,
RELAY_HOST_ICON_BTN,
RELAY_HOST_LIST_ITEM,
RELAY_HOST_LIST_ITEM_IDLE,
RELAY_HOST_LIST_ITEM_SELECTED,
RELAY_HOST_MESSAGE,
RELAY_HOST_PAGE_BODY,
RELAY_HOST_PAGE_DETAIL,
RELAY_HOST_PAGE_HEADER,
RELAY_HOST_PAGE_LIST,
RELAY_HOST_PAGE_TAB,
RELAY_HOST_PAGE_TAB_ACTIVE,
RELAY_HOST_PAGE_TAB_IDLE,
RELAY_HOST_PAGE_TABS,
} from "../../js/relayHostModerationClasses.js";
const BTN_PRIMARY =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-sem-action-primary px-3 py-2 text-sm font-semibold text-white transition hover:bg-sem-action-primary-hover disabled:opacity-50";
const BTN_SECONDARY =
"inline-flex items-center justify-center gap-1.5 rounded-lg border border-sem-border bg-sem-surface-raised px-3 py-2 text-sm font-medium text-sem-fg transition hover:bg-sem-surface-muted disabled:opacity-50";
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
export default {
name: "RelayHostModerationPage",
components: { MaterialDesignIcon },
props: {
hub: { type: Object, default: null },
initialTab: { type: String, default: "rooms" },
roomFilter: { type: String, default: null },
},
emits: ["back", "refresh"],
data() {
return {
RELAY_HOST_PAGE_HEADER,
RELAY_HOST_PAGE_TABS,
RELAY_HOST_PAGE_TAB,
RELAY_HOST_PAGE_TAB_ACTIVE,
RELAY_HOST_PAGE_TAB_IDLE,
RELAY_HOST_PAGE_BODY,
RELAY_HOST_PAGE_LIST,
RELAY_HOST_PAGE_DETAIL,
RELAY_HOST_ICON_BTN,
RELAY_HOST_LIST_ITEM,
RELAY_HOST_LIST_ITEM_SELECTED,
RELAY_HOST_LIST_ITEM_IDLE,
RELAY_HOST_DETAIL_HEADER,
RELAY_HOST_MESSAGE,
btnPrimary: BTN_PRIMARY,
btnSecondary: BTN_SECONDARY,
tab: "rooms",
isNarrow: false,
mq: null,
roomsLoading: false,
membersLoading: false,
creatingRoom: false,
messagesLoading: false,
showAddRoomForm: false,
rooms: [],
recent: [],
members: [],
roomsSearch: "",
membersSearch: "",
selectedRoom: null,
selectedMember: null,
memberMessages: [],
newRoom: { name: "", topic: "" },
localIdentityHash: "",
uptimeTick: 0,
uptimeAnchorMs: 0,
uptimeTimer: null,
};
},
computed: {
liveUptimeSeconds() {
void this.uptimeTick;
if (!this.hub?.running) {
return 0;
}
const base = Number(this.hub.uptime_seconds) || 0;
if (!this.uptimeAnchorMs) {
return base;
}
return base + Math.floor((Date.now() - this.uptimeAnchorMs) / 1000);
},
navbarMemberCount() {
if (this.roomFilter) {
if (this.membersLoading) {
return null;
}
return this.members.length;
}
if (this.hub?.clients != null) {
return this.hub.clients;
}
if (!this.membersLoading) {
return this.members.length;
}
return null;
},
filteredRooms() {
const q = this.roomsSearch.trim().toLowerCase();
if (!q) {
return this.rooms;
}
return this.rooms.filter((room) => {
const name = (room.name || "").toLowerCase();
const topic = (room.topic || "").toLowerCase();
return name.includes(q) || topic.includes(q);
});
},
pageTitle() {
if (!this.hub?.name) {
return this.$t("relay_chat.host_moderation_title");
}
if (this.roomFilter) {
return this.$t("relay_chat.host_moderation_title_room", {
hub: this.hub.name,
room: this.roomFilter,
});
}
return this.$t("relay_chat.host_moderation_title_hub", { hub: this.hub.name });
},
roomMessages() {
if (!this.selectedRoom) {
return [];
}
return this.recent.filter((m) => m.room === this.selectedRoom);
},
filteredMembers() {
const q = this.membersSearch.trim().toLowerCase();
if (!q) {
return this.members;
}
return this.members.filter((m) => {
const name = (m.name || "").toLowerCase();
const hash = (m.hash || "").toLowerCase();
const rooms = (m.rooms || []).join(" ").toLowerCase();
return name.includes(q) || hash.includes(q) || rooms.includes(q);
});
},
},
watch: {
hub: {
immediate: true,
handler() {
this.uptimeAnchorMs = Date.now();
this.reload();
},
},
initialTab: {
immediate: true,
handler(val) {
this.tab = val === "members" ? "members" : "rooms";
},
},
tab(val) {
if (val === "rooms") {
this.fetchActivity();
} else {
this.fetchMembers();
}
},
},
mounted() {
this.mq = window.matchMedia("(max-width: 1023px)");
this.isNarrow = this.mq.matches;
this.mq.addEventListener("change", this.onMq);
this.tab = this.initialTab === "members" ? "members" : "rooms";
this.ensureLocalIdentity();
this.uptimeAnchorMs = Date.now();
this.uptimeTimer = window.setInterval(() => {
if (this.hub?.running) {
this.uptimeTick += 1;
}
}, 1000);
if (this.hub?.id) {
this.reload();
}
},
beforeUnmount() {
if (this.mq) {
this.mq.removeEventListener("change", this.onMq);
}
if (this.uptimeTimer) {
clearInterval(this.uptimeTimer);
}
},
methods: {
onMq() {
this.isNarrow = this.mq.matches;
},
setTab(next) {
this.tab = next;
if (next === "rooms") {
this.selectedMember = null;
} else {
this.selectedRoom = null;
}
},
reload() {
this.selectedRoom = null;
this.selectedMember = null;
this.memberMessages = [];
this.newRoom = { name: "", topic: "" };
this.showAddRoomForm = false;
this.roomsSearch = "";
if (this.hub?.running) {
this.fetchMembers();
} else {
this.members = [];
}
if (this.tab === "rooms") {
this.fetchActivity();
}
},
cancelAddRoom() {
this.showAddRoomForm = false;
this.newRoom = { name: "", topic: "" };
},
async fetchActivity() {
if (!this.hub?.id) {
return;
}
this.roomsLoading = true;
try {
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/activity`);
this.rooms = response.data?.rooms || [];
this.recent = response.data?.recent || [];
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
} finally {
this.roomsLoading = false;
}
},
async fetchMembers() {
if (!this.hub?.id) {
return;
}
this.membersLoading = true;
try {
const params = this.roomFilter ? { params: { room: this.roomFilter } } : {};
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/members`, params);
this.members = response.data?.members || [];
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
} finally {
this.membersLoading = false;
}
},
async createRoom() {
const name = (this.newRoom.name || "").trim();
if (!name || !this.hub?.id) {
ToastUtils.warning(this.$t("relay_chat.room_required"));
return;
}
this.creatingRoom = true;
try {
await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/rooms`, {
name,
topic: (this.newRoom.topic || "").trim() || undefined,
});
this.newRoom = { name: "", topic: "" };
this.showAddRoomForm = false;
ToastUtils.success(this.$t("relay_chat.host_room_created"));
this.$emit("refresh");
await this.fetchActivity();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
} finally {
this.creatingRoom = false;
}
},
async deleteRoom(room) {
const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_delete_room_confirm"));
if (!confirmed || !this.hub?.id) {
return;
}
try {
await window.api.delete(`/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(room)}`);
ToastUtils.success(this.$t("relay_chat.host_room_deleted"));
if (this.selectedRoom === room) {
this.selectedRoom = null;
}
this.$emit("refresh");
await this.fetchActivity();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
selectMember(member) {
this.selectedMember = member;
this.loadMemberMessages();
},
async loadMemberMessages() {
if (!this.selectedMember || !this.hub?.id) {
return;
}
this.messagesLoading = true;
try {
const params = { peer: this.selectedMember.hash, limit: 200 };
if (this.roomFilter) {
params.room = this.roomFilter;
}
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/messages`, {
params,
});
this.memberMessages = response.data?.messages || [];
} catch {
this.memberMessages = [];
} finally {
this.messagesLoading = false;
}
},
async ensureLocalIdentity() {
if (this.localIdentityHash) {
return;
}
try {
const response = await window.api.get("/api/v1/config");
const hash = response.data?.identity_hash;
if (typeof hash === "string" && hash.trim()) {
this.localIdentityHash = hash.trim().toLowerCase();
}
} catch {
// config may be unavailable in tests
}
},
async resolveModerationRoom(member, action) {
if (this.roomFilter) {
return this.roomFilter;
}
const needsRoom = action === "kick" || action === "room_ban";
if (!needsRoom) {
return null;
}
const rooms = (member.rooms || []).filter((r) => typeof r === "string" && r.trim());
if (rooms.length === 0) {
ToastUtils.warning(this.$t("relay_chat.host_kick_no_room"));
return null;
}
if (rooms.length === 1) {
return rooms[0];
}
const entered = await DialogUtils.prompt(
this.$t("relay_chat.host_kick_pick_room", {
name: member.name,
rooms: rooms.map((r) => "#" + r).join(", "),
})
);
if (!entered) {
return null;
}
const norm = entered.trim().replace(/^#/, "");
const match = rooms.find((r) => r.toLowerCase() === norm.toLowerCase());
if (!match) {
ToastUtils.warning(this.$t("relay_chat.host_kick_room_invalid"));
return null;
}
return match;
},
async moderate(member, action) {
if (!this.hub?.id || !member?.hash) {
return;
}
await this.ensureLocalIdentity();
if (this.localIdentityHash && member.hash.toLowerCase() === this.localIdentityHash) {
ToastUtils.warning(this.$t("relay_chat.host_cannot_moderate_self"));
return;
}
const room = await this.resolveModerationRoom(member, action);
if ((action === "kick" || action === "room_ban") && !room) {
return;
}
const labels = {
kick: this.$t("relay_chat.host_kick_confirm", { name: member.name, room }),
ban: this.$t("relay_chat.host_ban_confirm", { name: member.name }),
room_ban: this.$t("relay_chat.host_room_ban_confirm", {
name: member.name,
room,
}),
};
const confirmed = await DialogUtils.confirm(labels[action] || "");
if (!confirmed) {
return;
}
try {
await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/moderate`, {
action,
peer: member.hash,
room: room || undefined,
});
ToastUtils.success(this.$t("common.success"));
this.$emit("refresh");
await this.fetchMembers();
if (this.selectedMember?.hash === member.hash) {
this.selectedMember = null;
this.memberMessages = [];
}
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
colorForHash(hash) {
if (!hash) {
return undefined;
}
let n = 0;
for (let i = 0; i < hash.length; i++) {
n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
}
return NAME_COLORS[n];
},
formatHash(hash) {
if (!hash || hash.length < 16) {
return hash || "";
}
return hash.slice(0, 8) + "..." + hash.slice(-8);
},
formatTime(ts) {
if (!ts) {
return "";
}
return new Date(ts).toLocaleString();
},
formatUptime(seconds) {
if (seconds == null || seconds < 0) {
return "—";
}
let s = Math.floor(seconds);
if (s < 60) {
return `${s}s`;
}
if (s < 3600) {
return `${Math.floor(s / 60)}m`;
}
if (s < 86400) {
return `${Math.floor(s / 3600)}h`;
}
if (s < 30 * 86400) {
return `${Math.floor(s / 86400)}d`;
}
const yearSec = 365 * 86400;
const monthSec = 30 * 86400;
const years = Math.floor(s / yearSec);
s -= years * yearSec;
const months = Math.floor(s / monthSec);
s -= months * monthSec;
const days = Math.floor(s / 86400);
const parts = [];
if (years) {
parts.push(`${years}y`);
}
if (months) {
parts.push(`${months}mo`);
}
if (days) {
parts.push(`${days}d`);
}
return parts.length ? parts.join(" ") : "0d";
},
copyHash(hash) {
if (!hash) {
return;
}
try {
navigator.clipboard.writeText(hash);
ToastUtils.success(this.$t("relay_chat.hash_copied"));
} catch {
ToastUtils.error(this.$t("common.failed_to_copy"));
}
},
},
};
</script>

View file

@ -1,293 +0,0 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="$emit('close')"
>
<div
class="flex h-[min(100dvh-2rem,900px)] w-full max-w-6xl flex-col rounded-2xl border border-sem-border-card bg-sem-surface shadow-xl"
role="dialog"
:aria-label="title"
>
<div class="flex shrink-0 items-center gap-2 border-b border-sem-border px-4 py-3 sm:px-5 sm:py-4">
<button
v-if="selectedRoom && isNarrow"
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
@click="selectedRoom = null"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-5" />
</button>
<h2 class="min-w-0 flex-1 text-lg font-semibold truncate">{{ title }}</h2>
<button
type="button"
class="rounded-lg p-1.5 text-sem-fg-muted hover:bg-sem-surface/60"
:title="$t('common.close')"
@click="$emit('close')"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
<div
class="flex min-h-0 flex-col border-sem-border lg:w-96 lg:shrink-0 lg:border-r"
:class="isNarrow && selectedRoom ? 'hidden' : 'flex-1 lg:flex-none'"
>
<div class="shrink-0 border-b border-sem-border p-3 sm:p-4 space-y-2">
<form class="flex flex-wrap gap-2" @submit.prevent="createRoom">
<input
v-model="newRoom.name"
type="text"
:placeholder="$t('relay_chat.host_room_name')"
class="input-field !py-2 !text-xs flex-1 min-w-[8rem]"
/>
<input
v-model="newRoom.topic"
type="text"
:placeholder="$t('relay_chat.host_room_topic')"
class="input-field !py-2 !text-xs flex-1 min-w-[8rem]"
/>
<button type="submit" :class="[btnPrimary, '!px-3 !py-2 !text-xs']" :disabled="creating">
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("relay_chat.host_add_room") }}
</button>
</form>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<div v-if="loading" class="py-12 text-center text-sm text-sem-fg-muted">
{{ $t("common.loading") }}
</div>
<ul v-else class="space-y-1.5">
<li
v-for="room in rooms"
:key="room.name"
class="rounded-xl border px-3 py-2.5 cursor-pointer transition-colors"
:class="
selectedRoom === room.name
? 'border-sem-accent bg-sem-accent/10'
: 'border-sem-border hover:bg-sem-surface/50'
"
@click="selectRoom(room.name)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium">#{{ room.name }}</div>
<div v-if="room.topic" class="truncate text-xs text-sem-fg-muted">
{{ room.topic }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 text-xs text-sem-fg-muted">
<span>{{ room.members }} {{ $t("relay_chat.host_clients") }}</span>
<span>{{ room.message_count || 0 }} msgs</span>
</div>
<div v-if="room.last_activity_ts" class="text-xs text-sem-fg-muted">
{{ formatTime(room.last_activity_ts) }}
</div>
</div>
<button
v-if="room.registered"
type="button"
class="shrink-0 rounded-lg p-1.5 text-sem-fg-muted hover:text-sem-danger"
:title="$t('relay_chat.host_delete_room')"
@click.stop="deleteRoom(room.name)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
</button>
</div>
</li>
</ul>
</div>
</div>
<div
class="flex min-h-0 min-w-0 flex-1 flex-col"
:class="isNarrow && !selectedRoom ? 'hidden' : 'flex'"
>
<div
v-if="!selectedRoom"
class="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-sem-fg-muted"
>
<MaterialDesignIcon icon-name="pound" class="size-10 opacity-40" />
{{ $t("relay_chat.host_rooms_select") }}
</div>
<template v-else>
<div class="shrink-0 border-b border-sem-border px-4 py-3 sm:px-5">
<div class="font-semibold">#{{ selectedRoom }}</div>
<div class="text-xs text-sem-fg-muted">{{ $t("relay_chat.host_room_activity") }}</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
<ul v-if="roomMessages.length > 0" class="space-y-2">
<li
v-for="(msg, idx) in roomMessages"
:key="idx"
class="rounded-lg border border-sem-border bg-sem-canvas px-3 py-2 text-sm"
>
<div class="flex flex-wrap items-center gap-x-2 text-xs text-sem-fg-muted">
<span :style="{ color: colorForHash(msg.peer) }">{{
msg.nick || formatHash(msg.peer)
}}</span>
<span>{{ formatTime(msg.ts) }}</span>
</div>
<div class="mt-1 whitespace-pre-wrap break-words">{{ msg.text }}</div>
</li>
</ul>
<div v-else class="py-8 text-center text-sm text-sem-fg-muted">
{{ $t("relay_chat.host_no_activity") }}
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
const BTN_PRIMARY =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-sem-action-primary px-3 py-2 text-sm font-semibold text-white transition hover:bg-sem-action-primary-hover disabled:opacity-50";
const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899"];
export default {
name: "RelayHostRoomsModal",
components: { MaterialDesignIcon },
props: {
open: { type: Boolean, default: false },
hub: { type: Object, default: null },
},
emits: ["close", "refresh"],
data() {
return {
loading: false,
creating: false,
rooms: [],
recent: [],
selectedRoom: null,
newRoom: { name: "", topic: "" },
isNarrow: false,
mq: null,
btnPrimary: BTN_PRIMARY,
};
},
computed: {
title() {
return this.$t("relay_chat.host_rooms_modal_title", { hub: this.hub?.name || "" });
},
roomMessages() {
if (!this.selectedRoom) {
return [];
}
return this.recent.filter((m) => m.room === this.selectedRoom);
},
},
watch: {
open(val) {
if (val) {
this.selectedRoom = null;
this.newRoom = { name: "", topic: "" };
this.fetchActivity();
}
},
},
mounted() {
this.mq = window.matchMedia("(max-width: 1023px)");
this.isNarrow = this.mq.matches;
this.mq.addEventListener("change", this.onMq);
},
beforeUnmount() {
if (this.mq) {
this.mq.removeEventListener("change", this.onMq);
}
},
methods: {
onMq() {
this.isNarrow = this.mq.matches;
},
async fetchActivity() {
if (!this.hub?.id) {
return;
}
this.loading = true;
try {
const response = await window.api.get(`/api/v1/rrc/servers/${this.hub.id}/activity`);
this.rooms = response.data?.rooms || [];
this.recent = response.data?.recent || [];
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
this.$emit("close");
} finally {
this.loading = false;
}
},
selectRoom(name) {
this.selectedRoom = name;
},
async createRoom() {
const name = (this.newRoom.name || "").trim();
if (!name || !this.hub?.id) {
ToastUtils.warning(this.$t("relay_chat.room_required"));
return;
}
this.creating = true;
try {
await window.api.post(`/api/v1/rrc/servers/${this.hub.id}/rooms`, {
name,
topic: (this.newRoom.topic || "").trim() || undefined,
});
this.newRoom = { name: "", topic: "" };
ToastUtils.success(this.$t("relay_chat.host_room_created"));
this.$emit("refresh");
await this.fetchActivity();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
} finally {
this.creating = false;
}
},
async deleteRoom(room) {
const confirmed = await DialogUtils.confirm(this.$t("relay_chat.host_delete_room_confirm"));
if (!confirmed || !this.hub?.id) {
return;
}
try {
await window.api.delete(`/api/v1/rrc/servers/${this.hub.id}/rooms/${encodeURIComponent(room)}`);
ToastUtils.success(this.$t("relay_chat.host_room_deleted"));
if (this.selectedRoom === room) {
this.selectedRoom = null;
}
this.$emit("refresh");
await this.fetchActivity();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
}
},
colorForHash(hash) {
if (!hash) {
return undefined;
}
let n = 0;
for (let i = 0; i < hash.length; i++) {
n = (n + hash.charCodeAt(i)) % NAME_COLORS.length;
}
return NAME_COLORS[n];
},
formatHash(hash) {
if (!hash || hash.length < 16) {
return hash || "";
}
return hash.slice(0, 8) + "..." + hash.slice(-8);
},
formatTime(ts) {
if (!ts) {
return "";
}
return new Date(ts).toLocaleString();
},
},
};
</script>

View file

@ -3,13 +3,15 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<ToolsPageHeader
v-show="!sessionFullscreen"
icon="console-network-outline"
:title="$t('rnsh.title')"
:description="$t('rnsh.description')"
:description="headerDescription"
:eyebrow="$t('rnsh.remote_shell')"
accent="indigo"
/>
<div
v-show="!sessionFullscreen"
class="flex items-stretch h-9 shrink-0 border-b border-gray-200 dark:border-zinc-800 bg-gray-50 dark:bg-zinc-900 overflow-x-auto"
role="tablist"
>
@ -19,7 +21,7 @@
type="button"
role="tab"
:aria-selected="activeTab === tab.id"
class="inline-flex items-center gap-1.5 px-3 sm:px-4 border-r border-gray-200 dark:border-zinc-800 text-sm transition-colors shrink-0"
class="inline-flex items-center gap-1 px-2.5 sm:px-4 border-r border-gray-200 dark:border-zinc-800 text-xs sm:text-sm transition-colors shrink-0"
:class="
activeTab === tab.id
? 'bg-white dark:bg-zinc-950 text-gray-900 dark:text-gray-100 font-medium'
@ -28,31 +30,33 @@
@click="activeTab = tab.id"
>
<MaterialDesignIcon :icon-name="tab.icon" class="size-4 shrink-0 opacity-70" />
<span>{{ $t(tab.label) }}</span>
<span class="lg:hidden">{{ $t(tab.shortLabel || tab.label) }}</span>
<span class="hidden lg:inline">{{ $t(tab.label) }}</span>
</button>
</div>
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div v-show="!sessionFullscreen" class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div v-show="activeTab === 'sessions'" class="flex-1 flex flex-col lg:flex-row min-h-0 overflow-hidden">
<aside
class="flex flex-col min-h-0 max-h-[40vh] lg:max-h-none lg:w-80 xl:w-96 shrink-0 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-zinc-800 px-3 md:px-4 py-3 gap-3"
class="flex flex-col min-h-0 shrink-0 border-gray-200 dark:border-zinc-800 px-2 sm:px-3 md:px-4 py-2 sm:py-3 gap-2 sm:gap-3"
:class="sessionsAsideClass"
>
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
<div class="text-xs sm:text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("rnsh.sessions") }}
</div>
<button type="button" class="secondary-chip text-xs px-2 py-1.5" @click="loadSessions">
<MaterialDesignIcon icon-name="refresh" class="size-4" />
{{ $t("rnsh.refresh") }}
<span class="hidden sm:inline">{{ $t("rnsh.refresh") }}</span>
</button>
</div>
<div class="flex-1 min-h-0 space-y-1.5 overflow-y-auto custom-scrollbar pr-1">
<div class="flex-1 min-h-0 space-y-1 overflow-y-auto custom-scrollbar pr-0.5">
<button
v-for="session in sessions"
:key="session.id"
type="button"
class="w-full text-left rounded-lg px-3 py-2 transition-colors"
class="w-full text-left rounded-lg px-2.5 sm:px-3 py-1.5 sm:py-2 transition-colors"
:class="
session.id === selectedSessionId
? 'bg-indigo-100 dark:bg-indigo-900/35 text-indigo-950 dark:text-indigo-100'
@ -61,116 +65,57 @@
@click="selectSession(session.id)"
>
<div class="flex items-center justify-between gap-2">
<div class="font-medium text-sm text-gray-900 dark:text-zinc-100 truncate">
<div class="font-medium text-xs sm:text-sm text-gray-900 dark:text-zinc-100 truncate">
{{ session.name || $t("rnsh.unnamed_session") }}
</div>
<span
class="text-[11px] font-semibold uppercase tracking-wide shrink-0"
class="text-[10px] sm:text-[11px] font-semibold uppercase tracking-wide shrink-0"
:class="statusClass(session)"
>
{{ statusLabel(session) }}
</span>
</div>
<div class="font-mono text-xs text-gray-500 dark:text-zinc-400 truncate mt-1">
<div
class="font-mono text-[10px] sm:text-xs text-gray-500 dark:text-zinc-400 truncate mt-0.5"
>
{{ session.mode === "listen" ? $t("rnsh.listen_mode") : session.destination || "-" }}
</div>
</button>
<div v-if="sessions.length === 0" class="text-xs text-gray-500 dark:text-zinc-400">
<div v-if="sessions.length === 0" class="text-xs text-gray-500 dark:text-zinc-400 px-1">
{{ $t("rnsh.no_sessions") }}
</div>
</div>
</aside>
<section class="flex-1 min-w-0 min-h-0 flex flex-col">
<div
class="shrink-0 flex flex-wrap items-center justify-between gap-2 px-3 md:px-4 py-2.5 border-b border-gray-200 dark:border-zinc-800"
>
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ selectedSession?.name || $t("rnsh.session_output") }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 font-mono truncate">
{{ selectedSession?.last_command || $t("rnsh.no_command_yet") }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="secondary-chip text-xs px-2 py-1.5"
:disabled="!selectedSession"
@click="startSelected"
>
<MaterialDesignIcon icon-name="play" class="size-4" />
{{ $t("rnsh.start") }}
</button>
<button
type="button"
class="secondary-chip text-xs px-2 py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
:disabled="!selectedSession"
@click="stopSelected"
>
<MaterialDesignIcon icon-name="stop" class="size-4" />
{{ $t("rnsh.stop") }}
</button>
<button
type="button"
class="secondary-chip text-xs px-2 py-1.5"
:disabled="!selectedSession"
@click="clearSelectedOutput"
>
<MaterialDesignIcon icon-name="broom" class="size-4" />
{{ $t("rnsh.clear") }}
</button>
<button
type="button"
class="secondary-chip text-xs px-2 py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
:disabled="!selectedSession"
@click="removeSelected"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
{{ $t("rnsh.remove") }}
</button>
</div>
</div>
<div
ref="outputBox"
class="flex-1 min-h-0 bg-zinc-950 dark:bg-black text-zinc-100 font-mono text-xs px-3 md:px-4 py-3 whitespace-pre-wrap break-words overflow-auto custom-scrollbar"
>
{{ selectedOutput }}
</div>
<form
class="shrink-0 flex flex-wrap gap-2 px-3 md:px-4 py-2.5 border-t border-gray-200 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-950"
@submit.prevent="sendCommand"
>
<input
v-model="commandInput"
type="text"
class="input-field flex-1 min-w-52 font-mono text-xs"
:placeholder="$t('rnsh.command_input_placeholder')"
:disabled="!selectedSession"
/>
<button
type="submit"
class="primary-chip px-3 py-2 text-xs"
:disabled="!selectedSession || !commandInput.trim()"
>
<MaterialDesignIcon icon-name="send" class="size-4" />
{{ $t("rnsh.send_line") }}
</button>
</form>
<section class="flex-1 min-w-0 min-h-0 flex flex-col" :class="terminalSectionClass">
<RNSHSessionTerminal
ref="sessionTerminal"
:session="selectedSession"
:output="selectedOutput"
:command-input="commandInput"
:show-sessions-toggle="isNarrowScreen"
:sessions-open="mobileSessionsOpen"
:compact-header="isNarrowScreen"
@update:command-input="commandInput = $event"
@send="sendCommand"
@start="startSelected"
@stop="stopSelected"
@clear="clearSelectedOutput"
@remove="removeSelected"
@toggle-fullscreen="toggleSessionFullscreen"
@toggle-sessions="toggleMobileSessions"
/>
</section>
</div>
<div
v-show="activeTab === 'connect'"
class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-4 md:px-5 lg:px-8 py-4 space-y-4"
class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-4 space-y-3 sm:space-y-4"
>
<p class="text-xs text-gray-500 dark:text-zinc-500 leading-relaxed">
{{ $t("rnsh.usage_hint") }}
</p>
<div class="grid lg:grid-cols-2 gap-4">
<div class="grid gap-3 sm:gap-4 lg:grid-cols-2">
<div>
<label class="glass-label">{{ $t("rnsh.name") }}</label>
<input
@ -185,7 +130,7 @@
<input
v-model="connectForm.destination"
type="text"
class="input-field font-mono"
class="input-field font-mono text-xs"
:placeholder="$t('rnsh.destination_placeholder')"
/>
</div>
@ -199,17 +144,21 @@
:placeholder="$t('rnsh.command_placeholder')"
/>
</div>
<div class="flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<div class="flex flex-wrap items-center gap-3 sm:gap-4">
<label class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300">
<input v-model="connectForm.mirror" type="checkbox" class="rounded-sm" />
{{ $t("rnsh.mirror_exit_code") }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<label class="flex items-center gap-2 text-xs sm:text-sm text-gray-700 dark:text-gray-300">
<input v-model="connectForm.no_id" type="checkbox" class="rounded-sm" />
{{ $t("rnsh.no_id") }}
</label>
</div>
<button type="button" class="primary-chip px-4 py-2 text-sm" @click="createConnectSession">
<button
type="button"
class="primary-chip px-4 py-2 text-sm w-full sm:w-auto"
@click="createConnectSession"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("rnsh.create_and_start") }}
</button>
@ -217,7 +166,7 @@
<div
v-show="activeTab === 'listen'"
class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-4 md:px-5 lg:px-8 py-4 space-y-4"
class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-4 space-y-3 sm:space-y-4"
>
<p class="text-xs text-gray-500 dark:text-zinc-500 leading-relaxed">
{{ $t("rnsh.usage_hint") }}
@ -249,18 +198,52 @@
:placeholder="$t('rnsh.command_placeholder')"
/>
</div>
<button type="button" class="primary-chip px-4 py-2 text-sm" @click="createListenSession">
<button
type="button"
class="primary-chip px-4 py-2 text-sm w-full sm:w-auto"
@click="createListenSession"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
{{ $t("rnsh.create_and_start") }}
</button>
</div>
</div>
<Teleport to="body">
<div
v-if="sessionFullscreen"
class="fixed inset-0 z-[220] flex flex-col bg-zinc-950"
role="dialog"
aria-modal="true"
:aria-label="$t('rnsh.session_output')"
>
<RNSHSessionTerminal
ref="fullscreenTerminal"
:session="selectedSession"
:output="selectedOutput"
:command-input="commandInput"
fullscreen
:show-sessions-toggle="isNarrowScreen"
:sessions-open="mobileSessionsOpen"
compact-header
@update:command-input="commandInput = $event"
@send="sendCommand"
@start="startSelected"
@stop="stopSelected"
@clear="clearSelectedOutput"
@remove="removeSelected"
@toggle-fullscreen="toggleSessionFullscreen"
@toggle-sessions="toggleMobileSessions"
/>
</div>
</Teleport>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToolsPageHeader from "./ToolsPageHeader.vue";
import RNSHSessionTerminal from "./RNSHSessionTerminal.vue";
import ToastUtils from "../../js/ToastUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import { loadRnshLayout, saveRnshLayout } from "../../js/browserLayoutStore";
@ -269,18 +252,31 @@ const EMPTY_LAYOUT = {
selectedSessionId: null,
};
const NARROW_BREAKPOINT_PX = 1024;
export default {
name: "RNSHManagerPage",
components: {
MaterialDesignIcon,
ToolsPageHeader,
RNSHSessionTerminal,
},
data() {
return {
viewTabs: [
{ id: "sessions", label: "rnsh.tab_sessions", icon: "console-line" },
{ id: "connect", label: "rnsh.tab_connect", icon: "lan-connect" },
{ id: "listen", label: "rnsh.tab_listen", icon: "access-point-network" },
{
id: "sessions",
label: "rnsh.tab_sessions",
shortLabel: "rnsh.tab_sessions_short",
icon: "console-line",
},
{ id: "connect", label: "rnsh.tab_connect", shortLabel: "rnsh.tab_connect_short", icon: "lan-connect" },
{
id: "listen",
label: "rnsh.tab_listen",
shortLabel: "rnsh.tab_listen_short",
icon: "access-point-network",
},
],
activeTab: "sessions",
sessions: [],
@ -299,6 +295,11 @@ export default {
allowed_hashes_text: "",
command: "",
},
isNarrowScreen: false,
mobileSessionsOpen: false,
sessionFullscreen: false,
onWindowResize: null,
onFullscreenKeydown: null,
};
},
computed: {
@ -315,16 +316,77 @@ export default {
}
return this.$t("rnsh.no_output_yet");
},
headerDescription() {
return this.isNarrowScreen ? "" : this.$t("rnsh.description");
},
sessionsAsideClass() {
if (!this.isNarrowScreen) {
return "lg:w-80 xl:w-96 border-b lg:border-b-0 lg:border-r max-h-[36vh] lg:max-h-none";
}
if (this.mobileSessionsOpen) {
return "flex-1 min-h-0 border-b";
}
return "hidden";
},
terminalSectionClass() {
if (this.isNarrowScreen && this.mobileSessionsOpen) {
return "hidden";
}
return "";
},
},
watch: {
sessionFullscreen(active) {
if (typeof document === "undefined") {
return;
}
document.body.style.overflow = active ? "hidden" : "";
if (active) {
this.$nextTick(() => this.scrollOutputToBottom());
}
},
},
async mounted() {
this.updateViewport();
this.onWindowResize = () => this.updateViewport();
window.addEventListener("resize", this.onWindowResize, { passive: true });
this.onFullscreenKeydown = (event) => {
if (event.key === "Escape" && this.sessionFullscreen) {
this.sessionFullscreen = false;
}
};
window.addEventListener("keydown", this.onFullscreenKeydown);
this.restoreLayout();
await this.loadSessions();
WebSocketConnection.on("message", this.onWebsocketMessage);
},
beforeUnmount() {
if (this.onWindowResize) {
window.removeEventListener("resize", this.onWindowResize);
}
if (this.onFullscreenKeydown) {
window.removeEventListener("keydown", this.onFullscreenKeydown);
}
document.body.style.overflow = "";
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
updateViewport() {
const narrow = typeof window !== "undefined" && window.innerWidth < NARROW_BREAKPOINT_PX;
this.isNarrowScreen = narrow;
if (!narrow) {
this.mobileSessionsOpen = false;
}
},
toggleMobileSessions() {
this.mobileSessionsOpen = !this.mobileSessionsOpen;
},
toggleSessionFullscreen() {
this.sessionFullscreen = !this.sessionFullscreen;
if (this.sessionFullscreen && this.isNarrowScreen) {
this.mobileSessionsOpen = false;
}
},
statusClass(session) {
if (!session) return "text-gray-500";
if (session.status === "running") return "text-emerald-600 dark:text-emerald-400";
@ -348,6 +410,9 @@ export default {
selectSession(sessionId) {
this.selectedSessionId = sessionId;
this.persistLayout();
if (this.isNarrowScreen) {
this.mobileSessionsOpen = false;
}
this.$nextTick(() => {
this.scrollOutputToBottom();
});
@ -526,11 +591,13 @@ export default {
}
},
scrollOutputToBottom() {
const target = this.$refs.outputBox;
if (!target) {
return;
const inline = this.$refs.sessionTerminal;
const full = this.$refs.fullscreenTerminal;
if (this.sessionFullscreen && full?.scrollToBottom) {
full.scrollToBottom();
} else if (inline?.scrollToBottom) {
inline.scrollToBottom();
}
target.scrollTop = target.scrollHeight;
},
},
};

View file

@ -0,0 +1,166 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col min-h-0 flex-1 min-w-0" :class="fullscreen ? 'h-dvh max-h-dvh' : ''">
<div
class="shrink-0 flex flex-wrap items-center justify-between gap-1.5 sm:gap-2 border-b border-gray-200 dark:border-zinc-800"
:class="fullscreen ? 'px-2 py-2 bg-zinc-900 safe-top' : 'px-2 sm:px-3 md:px-4 py-2 sm:py-2.5'"
>
<div class="min-w-0 flex-1 flex items-center gap-1.5">
<button
v-if="showSessionsToggle"
type="button"
class="secondary-chip text-xs px-2 py-1.5 shrink-0 lg:hidden"
:aria-label="sessionsOpen ? $t('rnsh.hide_sessions') : $t('rnsh.show_sessions')"
@click="$emit('toggle-sessions')"
>
<MaterialDesignIcon icon-name="format-list-bulleted" class="size-4" />
<span class="hidden sm:inline">{{
sessionsOpen ? $t("rnsh.hide_sessions") : $t("rnsh.show_sessions")
}}</span>
</button>
<div class="min-w-0">
<div class="text-xs sm:text-sm font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ session?.name || $t("rnsh.session_output") }}
</div>
<div
v-if="!compactHeader"
class="text-[10px] sm:text-xs text-gray-500 dark:text-zinc-400 font-mono truncate"
>
{{ session?.last_command || $t("rnsh.no_command_yet") }}
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-1 sm:gap-2 shrink-0">
<button
type="button"
class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
:disabled="!session"
:title="$t('rnsh.start')"
:aria-label="$t('rnsh.start')"
@click="$emit('start')"
>
<MaterialDesignIcon icon-name="play" class="size-4" />
<span class="hidden sm:inline ml-1">{{ $t("rnsh.start") }}</span>
</button>
<button
type="button"
class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
:disabled="!session"
:title="$t('rnsh.stop')"
:aria-label="$t('rnsh.stop')"
@click="$emit('stop')"
>
<MaterialDesignIcon icon-name="stop" class="size-4" />
<span class="hidden sm:inline ml-1">{{ $t("rnsh.stop") }}</span>
</button>
<button
type="button"
class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
:disabled="!session"
:title="$t('rnsh.clear')"
:aria-label="$t('rnsh.clear')"
@click="$emit('clear')"
>
<MaterialDesignIcon icon-name="broom" class="size-4" />
<span class="hidden sm:inline ml-1">{{ $t("rnsh.clear") }}</span>
</button>
<button
type="button"
class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5 text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/40"
:disabled="!session"
:title="$t('rnsh.remove')"
:aria-label="$t('rnsh.remove')"
@click="$emit('remove')"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
<span class="hidden sm:inline ml-1">{{ $t("rnsh.remove") }}</span>
</button>
<button
type="button"
class="secondary-chip text-xs p-1.5 sm:px-2 sm:py-1.5"
:title="fullscreen ? $t('rnsh.exit_fullscreen') : $t('rnsh.fullscreen')"
:aria-label="fullscreen ? $t('rnsh.exit_fullscreen') : $t('rnsh.fullscreen')"
@click="$emit('toggle-fullscreen')"
>
<MaterialDesignIcon :icon-name="fullscreen ? 'fullscreen-exit' : 'fullscreen'" class="size-4" />
</button>
</div>
</div>
<div
ref="outputBox"
class="flex-1 min-h-0 bg-zinc-950 dark:bg-black text-zinc-100 font-mono whitespace-pre-wrap break-words overflow-auto custom-scrollbar"
:class="fullscreen ? 'text-[11px] leading-relaxed px-2 py-2' : 'text-xs px-2 sm:px-3 md:px-4 py-2 sm:py-3'"
>
{{ output }}
</div>
<form
class="shrink-0 flex gap-1.5 sm:gap-2 border-t border-gray-200 dark:border-zinc-800 bg-slate-50 dark:bg-zinc-950"
:class="
fullscreen
? 'px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] safe-bottom'
: 'px-2 sm:px-3 md:px-4 py-2 sm:py-2.5'
"
@submit.prevent="$emit('send')"
>
<input
:value="commandInput"
type="text"
class="input-field flex-1 min-w-0 font-mono text-xs"
:placeholder="$t('rnsh.command_input_placeholder')"
:disabled="!session"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@input="$emit('update:commandInput', $event.target.value)"
/>
<button
type="submit"
class="primary-chip px-2.5 sm:px-3 py-2 text-xs shrink-0"
:disabled="!session || !commandInput.trim()"
:aria-label="$t('rnsh.send_line')"
>
<MaterialDesignIcon icon-name="send" class="size-4" />
<span class="hidden sm:inline ml-1">{{ $t("rnsh.send_line") }}</span>
</button>
</form>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "RNSHSessionTerminal",
components: { MaterialDesignIcon },
props: {
session: { type: Object, default: null },
output: { type: String, required: true },
commandInput: { type: String, default: "" },
fullscreen: { type: Boolean, default: false },
showSessionsToggle: { type: Boolean, default: false },
sessionsOpen: { type: Boolean, default: false },
compactHeader: { type: Boolean, default: false },
},
emits: ["update:commandInput", "send", "start", "stop", "clear", "remove", "toggle-fullscreen", "toggle-sessions"],
methods: {
scrollToBottom() {
const target = this.$refs.outputBox;
if (target) {
target.scrollTop = target.scrollHeight;
}
},
},
};
</script>
<style scoped>
.safe-top {
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.safe-bottom {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
}
</style>

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: 0BSD
/** Create-hub dialog overlay and panel (compact modal). */
export const RELAY_HOST_MODAL_OVERLAY =
"fixed inset-0 z-[60] flex flex-col overflow-hidden bg-black/50 backdrop-blur-[2px] dark:bg-black/70 p-3 sm:items-center sm:justify-center sm:p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))]";
export const RELAY_HOST_MODAL_PANEL_COMPACT =
"mx-auto flex w-full max-w-md shrink-0 flex-col overflow-hidden rounded-2xl border border-sem-border-card bg-sem-canvas p-5 text-sem-fg shadow-2xl ring-1 ring-inset ring-sem-border/25";

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: 0BSD
/** Relay Chat host moderation page layout (full-height, no modal shell). */
export const RELAY_HOST_PAGE_HEADER =
"flex shrink-0 flex-wrap items-center gap-2 border-b border-sem-border bg-sem-canvas px-3 py-2.5 sm:px-4";
export const RELAY_HOST_PAGE_TABS = "flex shrink-0 gap-1.5 border-b border-sem-border bg-sem-canvas px-3 py-2 sm:px-4";
export const RELAY_HOST_PAGE_TAB =
"inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors";
export const RELAY_HOST_PAGE_TAB_ACTIVE = "bg-sem-surface-raised text-sem-fg shadow-sm ring-1 ring-sem-border";
export const RELAY_HOST_PAGE_TAB_IDLE = "text-sem-fg-muted hover:bg-sem-surface-raised/60 hover:text-sem-fg";
export const RELAY_HOST_PAGE_BODY = "flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row";
export const RELAY_HOST_PAGE_LIST =
"flex min-h-0 flex-col border-sem-border lg:w-80 lg:shrink-0 lg:border-r lg:bg-sem-surface-muted/20";
export const RELAY_HOST_PAGE_DETAIL = "flex min-h-0 min-w-0 flex-1 flex-col bg-sem-canvas";
export const RELAY_HOST_LIST_ITEM = "rounded-xl border border-sem-border bg-sem-surface-raised/50 transition-colors";
export const RELAY_HOST_LIST_ITEM_SELECTED = "border-sem-accent bg-sem-accent/15";
export const RELAY_HOST_LIST_ITEM_IDLE = "hover:bg-sem-surface-raised";
export const RELAY_HOST_DETAIL_HEADER =
"shrink-0 border-b border-sem-border bg-sem-surface-muted/30 px-3 py-2.5 sm:px-4";
export const RELAY_HOST_MESSAGE =
"rounded-lg border border-sem-border bg-sem-surface-raised px-3 py-2 text-sm text-sem-fg";
export const RELAY_HOST_ICON_BTN =
"rounded-lg p-1.5 text-sem-fg-muted transition-colors hover:bg-sem-surface-raised hover:text-sem-fg";

View file

@ -2162,7 +2162,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Netzwerkdiagnose",
@ -2832,6 +2839,9 @@
"copy_hash": "Ziel-Hash kopieren",
"hash_copied": "Ziel-Hash kopiert",
"host_start": "Starten",
"host_status_running": "Läuft",
"host_status_stopped": "Gestoppt",
"host_hub_not_running": "Starten Sie den Hub, bevor Sie Räume oder Mitglieder verwalten.",
"host_stop": "Stoppen",
"host_announce": "Jetzt ankündigen",
"host_announced": "Angekündigt",
@ -2853,8 +2863,8 @@
"host_room_created": "Raum erstellt",
"host_delete_room_confirm": "Diesen Raum löschen?",
"host_room_deleted": "Raum gelöscht",
"host_members_all_title": "Verbundene Clients {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Verbundene Clients: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Räume",
"hub_icon": "Hub-Symbol",
"hub_icon_choose": "Symbol wählen",
@ -2883,7 +2893,12 @@
"show_members": "Mitglieder anzeigen",
"hide_members": "Mitglieder ausblenden",
"discovery_refreshed": "Entdeckung aktualisiert",
"host_members_search": "Mitglieder nach Name, Hash oder Raum suchen...",
"host_members_search": "Mitglieder suchen...",
"host_rooms_search": "Räume suchen...",
"host_rooms_search_empty": "Keine Räume passen zur Suche.",
"host_no_rooms": "Noch keine Räume.",
"host_moderation_uptime": "{time} Laufzeit",
"host_moderation_members": "{count} Mitglieder",
"host_members_select": "Mitglied auswählen, um Nachrichten anzuzeigen und zu moderieren",
"host_no_messages": "Noch keine Nachrichten von diesem Benutzer im Protokoll",
"host_ban_hub": "Vom Hub sperren",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "{name} von diesem Hub sperren?",
"host_room_ban_confirm": "{name} aus #{room} sperren?",
"host_manage_rooms": "Räume verwalten",
"host_rooms_modal_title": "Räume — {hub}",
"host_moderate": "Hub moderieren",
"host_moderation_title": "Moderation",
"host_moderation_title_hub": "Moderation: {hub}",
"host_moderation_title_room": "Moderation: {hub} (#{room})",
"host_moderation_tab_rooms": "Räume",
"host_moderation_tab_members": "Mitglieder",
"host_moderation_hub_missing": "Hub nicht gefunden.",
"host_rooms_modal_title": "Räume: {hub}",
"host_rooms_select": "Raum auswählen, um Aktivität anzuzeigen",
"host_room_activity": "Letzte Aktivität",
"host_no_activity": "Keine kürzliche Aktivität in diesem Raum",
@ -2926,5 +2948,22 @@
"search_no_results": "Keine Nachrichten entsprechen der Suche",
"popout_channel": "In neuem Fenster öffnen",
"new_message_toast": "Neue Nachricht in #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2210,6 +2210,9 @@
"copy_hash": "Copy destination hash",
"hash_copied": "Destination hash copied",
"host_start": "Start",
"host_status_running": "Running",
"host_status_stopped": "Stopped",
"host_hub_not_running": "Start the hub before managing rooms or members.",
"host_stop": "Stop",
"host_announce": "Announce now",
"host_announced": "Announced",
@ -2231,10 +2234,15 @@
"host_room_created": "Room created",
"host_delete_room_confirm": "Delete this room?",
"host_room_deleted": "Room deleted",
"host_members_all_title": "Connected clients {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Connected clients: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Rooms",
"host_members_search": "Search members by name, hash, or room...",
"host_members_search": "Search members...",
"host_rooms_search": "Search rooms...",
"host_rooms_search_empty": "No rooms match your search.",
"host_no_rooms": "No rooms yet.",
"host_moderation_uptime": "{time} uptime",
"host_moderation_members": "{count} members",
"host_members_select": "Select a member to view messages and moderate",
"host_no_messages": "No messages from this user in the log yet",
"host_ban_hub": "Ban from hub",
@ -2246,7 +2254,14 @@
"host_ban_confirm": "Ban {name} from this hub?",
"host_room_ban_confirm": "Ban {name} from #{room}?",
"host_manage_rooms": "Manage rooms",
"host_rooms_modal_title": "Rooms — {hub}",
"host_moderate": "Moderate hub",
"host_moderation_title": "Moderation",
"host_moderation_title_hub": "Moderation: {hub}",
"host_moderation_title_room": "Moderation: {hub} (#{room})",
"host_moderation_tab_rooms": "Rooms",
"host_moderation_tab_members": "Members",
"host_moderation_hub_missing": "Hub not found.",
"host_rooms_modal_title": "Rooms: {hub}",
"host_rooms_select": "Select a room to view activity",
"host_room_activity": "Recent activity",
"host_no_activity": "No recent activity in this room",
@ -2361,8 +2376,15 @@
"usage_title": "Usage",
"usage_hint": "Create connect or listen sessions, then manage them on the Sessions tab. Output streams live while MeshChatX is running.",
"tab_sessions": "Sessions",
"tab_sessions_short": "Sessions",
"tab_connect": "Connect",
"tab_connect_short": "Connect",
"tab_listen": "Listen",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal",
"sessions": "Sessions",
"refresh": "Refresh",
"name": "Session name",
@ -2721,6 +2743,23 @@
"contact_updated": "Contact updated",
"failed_update_contact": "Failed to update contact"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
},
"tutorial": {
"title": "Getting Started",
"welcome": "Welcome to",

View file

@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnósticos de red",
@ -2832,6 +2839,9 @@
"copy_hash": "Copiar hash de destino",
"hash_copied": "Hash de destino copiado",
"host_start": "Iniciar",
"host_status_running": "En ejecución",
"host_status_stopped": "Detenido",
"host_hub_not_running": "Inicie el hub antes de administrar salas o miembros.",
"host_stop": "Detener",
"host_announce": "Anunciar ahora",
"host_announced": "Anunciado",
@ -2853,8 +2863,8 @@
"host_room_created": "Sala creada",
"host_delete_room_confirm": "¿Eliminar esta sala?",
"host_room_deleted": "Sala eliminada",
"host_members_all_title": "Clientes conectados {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Clientes conectados: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Salas",
"hub_icon": "Icono del hub",
"hub_icon_choose": "Elegir icono",
@ -2883,7 +2893,12 @@
"show_members": "Mostrar miembros",
"hide_members": "Ocultar miembros",
"discovery_refreshed": "Descubrimiento actualizado",
"host_members_search": "Buscar miembros por nombre, hash o sala...",
"host_members_search": "Buscar miembros...",
"host_rooms_search": "Buscar salas...",
"host_rooms_search_empty": "Ninguna sala coincide con la búsqueda.",
"host_no_rooms": "Aún no hay salas.",
"host_moderation_uptime": "{time} en ejecución",
"host_moderation_members": "{count} miembros",
"host_members_select": "Selecciona un miembro para ver mensajes y moderar",
"host_no_messages": "Aún no hay mensajes de este usuario en el registro",
"host_ban_hub": "Prohibir en el hub",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "¿Prohibir a {name} en este hub?",
"host_room_ban_confirm": "¿Prohibir a {name} en #{room}?",
"host_manage_rooms": "Gestionar salas",
"host_rooms_modal_title": "Salas — {hub}",
"host_moderate": "Moderar hub",
"host_moderation_title": "Moderación",
"host_moderation_title_hub": "Moderación: {hub}",
"host_moderation_title_room": "Moderación: {hub} (#{room})",
"host_moderation_tab_rooms": "Salas",
"host_moderation_tab_members": "Miembros",
"host_moderation_hub_missing": "Hub no encontrado.",
"host_rooms_modal_title": "Salas: {hub}",
"host_rooms_select": "Selecciona una sala para ver la actividad",
"host_room_activity": "Actividad reciente",
"host_no_activity": "No hay actividad reciente en esta sala",
@ -2926,5 +2948,22 @@
"search_no_results": "Ningún mensaje coincide con la búsqueda",
"popout_channel": "Abrir en una ventana nueva",
"new_message_toast": "Nuevo mensaje en #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnostics réseau",
@ -2832,6 +2839,9 @@
"copy_hash": "Copier le hash de destination",
"hash_copied": "Hash de destination copié",
"host_start": "Démarrer",
"host_status_running": "En cours",
"host_status_stopped": "Arrêté",
"host_hub_not_running": "Démarrez le hub avant de gérer les salons ou les membres.",
"host_stop": "Arrêter",
"host_announce": "Annoncer maintenant",
"host_announced": "Annoncé",
@ -2853,8 +2863,8 @@
"host_room_created": "Salon créé",
"host_delete_room_confirm": "Supprimer ce salon ?",
"host_room_deleted": "Salon supprimé",
"host_members_all_title": "Clients connectés {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Clients connectés : {hub}",
"host_members_room_title": "#{room} : {hub}",
"host_member_rooms": "Salons",
"hub_icon": "Icône du hub",
"hub_icon_choose": "Choisir une icône",
@ -2883,7 +2893,12 @@
"show_members": "Afficher les membres",
"hide_members": "Masquer les membres",
"discovery_refreshed": "Découverte actualisée",
"host_members_search": "Rechercher des membres par nom, hash ou salon...",
"host_members_search": "Rechercher des membres...",
"host_rooms_search": "Rechercher des salons...",
"host_rooms_search_empty": "Aucun salon ne correspond à la recherche.",
"host_no_rooms": "Aucun salon pour l'instant.",
"host_moderation_uptime": "{time} de disponibilité",
"host_moderation_members": "{count} membres",
"host_members_select": "Sélectionnez un membre pour voir les messages et modérer",
"host_no_messages": "Aucun message de cet utilisateur dans le journal pour l'instant",
"host_ban_hub": "Bannir du hub",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "Bannir {name} de ce hub ?",
"host_room_ban_confirm": "Bannir {name} de #{room} ?",
"host_manage_rooms": "Gérer les salons",
"host_rooms_modal_title": "Salons — {hub}",
"host_moderate": "Modérer le hub",
"host_moderation_title": "Modération",
"host_moderation_title_hub": "Modération : {hub}",
"host_moderation_title_room": "Modération : {hub} (#{room})",
"host_moderation_tab_rooms": "Salons",
"host_moderation_tab_members": "Membres",
"host_moderation_hub_missing": "Hub introuvable.",
"host_rooms_modal_title": "Salons : {hub}",
"host_rooms_select": "Sélectionnez un salon pour voir l'activité",
"host_room_activity": "Activité récente",
"host_no_activity": "Aucune activité récente dans ce salon",
@ -2926,5 +2948,22 @@
"search_no_results": "Aucun message ne correspond à la recherche",
"popout_channel": "Ouvrir dans une nouvelle fenêtre",
"new_message_toast": "Nouveau message dans #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2303,7 +2303,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Diagnostica di Rete",
@ -2832,6 +2839,9 @@
"copy_hash": "Copia hash di destinazione",
"hash_copied": "Hash di destinazione copiato",
"host_start": "Avvia",
"host_status_running": "In esecuzione",
"host_status_stopped": "Arrestato",
"host_hub_not_running": "Avvia l'hub prima di gestire stanze o membri.",
"host_stop": "Ferma",
"host_announce": "Annuncia ora",
"host_announced": "Annunciato",
@ -2853,8 +2863,8 @@
"host_room_created": "Stanza creata",
"host_delete_room_confirm": "Eliminare questa stanza?",
"host_room_deleted": "Stanza eliminata",
"host_members_all_title": "Client connessi {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Client connessi: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Stanze",
"hub_icon": "Icona hub",
"hub_icon_choose": "Scegli icona",
@ -2883,7 +2893,12 @@
"show_members": "Mostra membri",
"hide_members": "Nascondi membri",
"discovery_refreshed": "Scoperta aggiornata",
"host_members_search": "Cerca membri per nome, hash o stanza...",
"host_members_search": "Cerca membri...",
"host_rooms_search": "Cerca stanze...",
"host_rooms_search_empty": "Nessuna stanza corrisponde alla ricerca.",
"host_no_rooms": "Nessuna stanza ancora.",
"host_moderation_uptime": "{time} di attività",
"host_moderation_members": "{count} membri",
"host_members_select": "Seleziona un membro per vedere i messaggi e moderare",
"host_no_messages": "Nessun messaggio da questo utente nel registro",
"host_ban_hub": "Banna dall'hub",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "Bannare {name} da questo hub?",
"host_room_ban_confirm": "Bannare {name} da #{room}?",
"host_manage_rooms": "Gestisci stanze",
"host_rooms_modal_title": "Stanze — {hub}",
"host_moderate": "Modera hub",
"host_moderation_title": "Moderazione",
"host_moderation_title_hub": "Moderazione: {hub}",
"host_moderation_title_room": "Moderazione: {hub} (#{room})",
"host_moderation_tab_rooms": "Stanze",
"host_moderation_tab_members": "Membri",
"host_moderation_hub_missing": "Hub non trovato.",
"host_rooms_modal_title": "Stanze: {hub}",
"host_rooms_select": "Seleziona una stanza per vedere l'attività",
"host_room_activity": "Attività recente",
"host_no_activity": "Nessuna attività recente in questa stanza",
@ -2926,5 +2948,22 @@
"search_no_results": "Nessun messaggio corrisponde alla ricerca",
"popout_channel": "Apri in una nuova finestra",
"new_message_toast": "Nuovo messaggio in #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Netwerkdiagnostiek",
@ -2832,6 +2839,9 @@
"copy_hash": "Bestemmingshash kopiëren",
"hash_copied": "Bestemmingshash gekopieerd",
"host_start": "Starten",
"host_status_running": "Actief",
"host_status_stopped": "Gestopt",
"host_hub_not_running": "Start de hub voordat je kamers of leden beheert.",
"host_stop": "Stoppen",
"host_announce": "Nu aankondigen",
"host_announced": "Aangekondigd",
@ -2853,8 +2863,8 @@
"host_room_created": "Kamer gemaakt",
"host_delete_room_confirm": "Deze kamer verwijderen?",
"host_room_deleted": "Kamer verwijderd",
"host_members_all_title": "Verbonden clients {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Verbonden clients: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Kamers",
"hub_icon": "Hub-pictogram",
"hub_icon_choose": "Pictogram kiezen",
@ -2883,7 +2893,12 @@
"show_members": "Leden tonen",
"hide_members": "Leden verbergen",
"discovery_refreshed": "Ontdekking vernieuwd",
"host_members_search": "Zoek leden op naam, hash of kamer...",
"host_members_search": "Leden zoeken...",
"host_rooms_search": "Kamers zoeken...",
"host_rooms_search_empty": "Geen kamers komen overeen met je zoekopdracht.",
"host_no_rooms": "Nog geen kamers.",
"host_moderation_uptime": "{time} uptime",
"host_moderation_members": "{count} leden",
"host_members_select": "Selecteer een lid om berichten te bekijken en te modereren",
"host_no_messages": "Nog geen berichten van deze gebruiker in het logboek",
"host_ban_hub": "Verbannen van hub",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "{name} van deze hub verbannen?",
"host_room_ban_confirm": "{name} uit #{room} verbannen?",
"host_manage_rooms": "Kamers beheren",
"host_rooms_modal_title": "Kamers — {hub}",
"host_moderate": "Hub modereren",
"host_moderation_title": "Moderatie",
"host_moderation_title_hub": "Moderatie: {hub}",
"host_moderation_title_room": "Moderatie: {hub} (#{room})",
"host_moderation_tab_rooms": "Kamers",
"host_moderation_tab_members": "Leden",
"host_moderation_hub_missing": "Hub niet gevonden.",
"host_rooms_modal_title": "Kamers: {hub}",
"host_rooms_select": "Selecteer een kamer om activiteit te bekijken",
"host_room_activity": "Recente activiteit",
"host_no_activity": "Geen recente activiteit in deze kamer",
@ -2926,5 +2948,22 @@
"search_no_results": "Geen berichten komen overeen met je zoekopdracht",
"popout_channel": "Openen in nieuw venster",
"new_message_toast": "Nieuw bericht in #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2162,7 +2162,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "Диагностика сети",
@ -2832,6 +2839,9 @@
"copy_hash": "Копировать хеш назначения",
"hash_copied": "Хеш назначения скопирован",
"host_start": "Запустить",
"host_status_running": "Работает",
"host_status_stopped": "Остановлен",
"host_hub_not_running": "Запустите хаб перед управлением комнатами или участниками.",
"host_stop": "Остановить",
"host_announce": "Объявить сейчас",
"host_announced": "Объявлено",
@ -2853,8 +2863,8 @@
"host_room_created": "Комната создана",
"host_delete_room_confirm": "Удалить эту комнату?",
"host_room_deleted": "Комната удалена",
"host_members_all_title": "Подключённые клиенты {hub}",
"host_members_room_title": "#{room} {hub}",
"host_members_all_title": "Подключённые клиенты: {hub}",
"host_members_room_title": "#{room}: {hub}",
"host_member_rooms": "Комнаты",
"hub_icon": "Иконка хаба",
"hub_icon_choose": "Выбрать иконку",
@ -2883,7 +2893,12 @@
"show_members": "Показать участников",
"hide_members": "Скрыть участников",
"discovery_refreshed": "Обзор обновлён",
"host_members_search": "Поиск участников по имени, хешу или комнате...",
"host_members_search": "Поиск участников...",
"host_rooms_search": "Поиск комнат...",
"host_rooms_search_empty": "Нет комнат по вашему запросу.",
"host_no_rooms": "Пока нет комнат.",
"host_moderation_uptime": "Аптайм {time}",
"host_moderation_members": "{count} участников",
"host_members_select": "Выберите участника, чтобы просмотреть сообщения и модерировать",
"host_no_messages": "В журнале пока нет сообщений от этого пользователя",
"host_ban_hub": "Забанить на хабе",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "Забанить {name} на этом хабе?",
"host_room_ban_confirm": "Забанить {name} в #{room}?",
"host_manage_rooms": "Управление комнатами",
"host_rooms_modal_title": "Комнаты — {hub}",
"host_moderate": "Модерация хаба",
"host_moderation_title": "Модерация",
"host_moderation_title_hub": "Модерация: {hub}",
"host_moderation_title_room": "Модерация: {hub} (#{room})",
"host_moderation_tab_rooms": "Комнаты",
"host_moderation_tab_members": "Участники",
"host_moderation_hub_missing": "Хаб не найден.",
"host_rooms_modal_title": "Комнаты: {hub}",
"host_rooms_select": "Выберите комнату для просмотра активности",
"host_room_activity": "Недавняя активность",
"host_no_activity": "Нет недавней активности в этой комнате",
@ -2926,5 +2948,22 @@
"search_no_results": "Сообщения по запросу не найдены",
"popout_channel": "Открыть в новом окне",
"new_message_toast": "Новое сообщение в #{room}"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -2251,7 +2251,14 @@
"failed_to_stop_session": "Failed to stop RNSH session",
"failed_to_remove_session": "Failed to remove RNSH session",
"failed_to_clear_output": "Failed to clear RNSH output",
"failed_to_send_input": "Failed to send RNSH input"
"failed_to_send_input": "Failed to send RNSH input",
"tab_sessions_short": "Sessions",
"tab_connect_short": "Connect",
"tab_listen_short": "Listen",
"fullscreen": "Full screen",
"exit_fullscreen": "Exit full screen",
"show_sessions": "Sessions",
"hide_sessions": "Back to terminal"
},
"rnprobe": {
"network_diagnostics": "网络诊断",
@ -2832,6 +2839,9 @@
"copy_hash": "复制目标哈希",
"hash_copied": "已复制目标哈希",
"host_start": "启动",
"host_status_running": "运行中",
"host_status_stopped": "已停止",
"host_hub_not_running": "请先启动中心,再管理房间或成员。",
"host_stop": "停止",
"host_announce": "立即通告",
"host_announced": "已通告",
@ -2853,8 +2863,8 @@
"host_room_created": "房间已创建",
"host_delete_room_confirm": "删除此房间?",
"host_room_deleted": "房间已删除",
"host_members_all_title": "已连接客户端{hub}",
"host_members_room_title": "#{room}{hub}",
"host_members_all_title": "已连接客户端{hub}",
"host_members_room_title": "#{room}{hub}",
"host_member_rooms": "房间",
"hub_icon": "中心图标",
"hub_icon_choose": "选择图标",
@ -2883,7 +2893,12 @@
"show_members": "显示成员",
"hide_members": "隐藏成员",
"discovery_refreshed": "已刷新发现",
"host_members_search": "按名称、哈希或房间搜索成员...",
"host_members_search": "搜索成员...",
"host_rooms_search": "搜索房间...",
"host_rooms_search_empty": "没有匹配的房间。",
"host_no_rooms": "暂无房间。",
"host_moderation_uptime": "运行 {time}",
"host_moderation_members": "{count} 名成员",
"host_members_select": "选择成员以查看消息并进行管理",
"host_no_messages": "日志中尚无此用户的消息",
"host_ban_hub": "从中心封禁",
@ -2895,7 +2910,14 @@
"host_ban_confirm": "在中心封禁 {name}",
"host_room_ban_confirm": "在 #{room} 封禁 {name}",
"host_manage_rooms": "管理房间",
"host_rooms_modal_title": "房间 — {hub}",
"host_moderate": "管理 Hub",
"host_moderation_title": "管理",
"host_moderation_title_hub": "管理:{hub}",
"host_moderation_title_room": "管理:{hub}#{room}",
"host_moderation_tab_rooms": "房间",
"host_moderation_tab_members": "成员",
"host_moderation_hub_missing": "未找到 Hub。",
"host_rooms_modal_title": "房间:{hub}",
"host_rooms_select": "选择房间以查看活动",
"host_room_activity": "最近活动",
"host_no_activity": "此房间暂无最近活动",
@ -2926,5 +2948,22 @@
"search_no_results": "没有匹配搜索的消息",
"popout_channel": "在新窗口中打开",
"new_message_toast": "#{room} 中有新消息"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
"setup_desc": "File-manager storage is recommended. You can browse and back up your identity and messages under Android/data/com.meshchatx/files/. The app restarts when you change this during setup.",
"setup_external_title": "File-manager storage (recommended)",
"setup_external_desc": "Android/data/com.meshchatx/files/meshchatx — visible in many file managers.",
"setup_internal_title": "Private app storage",
"setup_internal_desc": "Hidden from file managers; only this app can access it.",
"setup_continue": "Continue",
"upgrade_title": "Move data to file-manager storage?",
"upgrade_desc": "Your MeshChatX data is in private app storage. Copy it to the file-manager folder so you can browse and back up files, then restart the app.",
"upgrade_copy": "Copy and restart",
"upgrade_stay_internal": "Stay on private storage",
"working": "Preparing…",
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
}
}

View file

@ -7,6 +7,7 @@ from meshchatx.meshchat import ReticulumMeshChat
def test_disable_rnode_interfaces_on_android(tmp_path):
"""Unconditional disable helper still clears RNode interfaces on Android."""
config_path = tmp_path / "config"
config_path.write_text(
"""[reticulum]

View file

@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import json
import shutil
import tempfile
@ -121,6 +122,7 @@ async def test_lxmf_sync_flow(mock_app):
await route.handler(None)
break
await asyncio.sleep(0.05)
mock_router.request_messages_from_propagation_node.assert_called_once()
# Check status (Receiving)
@ -152,6 +154,7 @@ async def test_lxmf_sync_requests_path_before_sync(mock_app):
with patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=False):
with patch("meshchatx.meshchat.RNS.Transport.request_path") as mock_request:
await sync_handler(None)
await asyncio.sleep(0.05)
mock_request.assert_called_with(outbound)
mock_router.request_messages_from_propagation_node.assert_called_with(
mock_app.current_context.identity
@ -327,6 +330,7 @@ async def test_user_provided_node_hash(mock_app):
if r.path == "/api/v1/lxmf/propagation-node/sync"
)
await sync_handler(None)
await asyncio.sleep(0.05)
# Verify the router was told to sync for our identity
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_with(

View file

@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import json
import shutil
import tempfile
@ -145,6 +146,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(
):
first_sync = await sync_handler(None)
assert first_sync.status == 200
await asyncio.sleep(0.05)
first_status = json.loads((await status_handler(None)).body)[
"propagation_node_status"
@ -153,6 +155,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(
second_sync = await sync_handler(None)
assert second_sync.status == 200
await asyncio.sleep(0.05)
second_status = json.loads((await status_handler(None)).body)[
"propagation_node_status"

View file

@ -1,5 +1,6 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import json
import shutil
import tempfile
@ -104,6 +105,7 @@ async def test_lxmf_sync_endpoints(mock_app):
response = await sync_handler(None)
assert response.status == 200
await asyncio.sleep(0.05)
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_once()
# 3. Test status change to complete
@ -143,6 +145,7 @@ async def test_specific_node_hash_validation(mock_app):
mock_app.current_context.message_router.get_outbound_propagation_node.return_value = expected_bytes
await sync_handler(None)
await asyncio.sleep(0.05)
mock_app.current_context.message_router.request_messages_from_propagation_node.assert_called_once()
@ -175,6 +178,7 @@ async def test_status_includes_sync_storage_and_confirmation_metrics(mock_app):
),
):
await sync_handler(None)
await asyncio.sleep(0.05)
response = await status_handler(None)
data = json.loads(response.body)["propagation_node_status"]
@ -229,6 +233,7 @@ async def test_status_hidden_metric_is_clamped_to_zero(mock_app):
),
):
await sync_handler(None)
await asyncio.sleep(0.05)
response = await status_handler(None)
data = json.loads(response.body)["propagation_node_status"]

View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: 0BSD
import asyncio
import threading
from unittest.mock import MagicMock, patch
import LXMF
import pytest
from meshchatx.meshchat import ReticulumMeshChat
PR_IDLE = LXMF.LXMRouter.PR_IDLE
PR_LINK_ESTABLISHING = LXMF.LXMRouter.PR_LINK_ESTABLISHING
PR_PATH_REQUESTED = LXMF.LXMRouter.PR_PATH_REQUESTED
@pytest.mark.asyncio
async def test_request_propagation_node_messages_handles_eof_error():
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
router = MagicMock()
router.PR_IDLE = PR_IDLE
router.propagation_transfer_state = PR_LINK_ESTABLISHING
router.request_messages_from_propagation_node.side_effect = EOFError()
ctx.message_router = router
ctx.identity = MagicMock()
with patch.object(app, "send_config_to_websocket_clients", return_value=None):
await app._request_propagation_node_messages(context=ctx)
assert router.propagation_transfer_state == PR_IDLE
assert router.propagation_transfer_progress == 0.0
def test_stop_propagation_node_sync_forces_idle_when_cancel_leaves_active_state():
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
router = MagicMock()
router.PR_IDLE = PR_IDLE
router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
router.PR_LINK_ESTABLISHING = PR_LINK_ESTABLISHING
router.PR_LINK_ESTABLISHED = LXMF.LXMRouter.PR_LINK_ESTABLISHED
router.PR_REQUEST_SENT = LXMF.LXMRouter.PR_REQUEST_SENT
router.PR_RECEIVING = LXMF.LXMRouter.PR_RECEIVING
router.PR_RESPONSE_RECEIVED = LXMF.LXMRouter.PR_RESPONSE_RECEIVED
router.propagation_transfer_state = PR_PATH_REQUESTED
ctx.message_router = router
app.stop_propagation_node_sync(context=ctx)
router.cancel_propagation_node_requests.assert_called_once()
assert router.propagation_transfer_state == PR_IDLE
assert router.propagation_transfer_progress == 0.0
@pytest.mark.asyncio
async def test_sync_propagation_nodes_returns_before_request_thread_finishes():
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
router = MagicMock()
router.PR_IDLE = PR_IDLE
router.PR_COMPLETE = LXMF.LXMRouter.PR_COMPLETE
router.propagation_transfer_state = PR_IDLE
router.get_outbound_propagation_node.return_value = b"\x22" * 16
router.propagation_destination = MagicMock(hash=b"\x11" * 16)
ctx.message_router = router
ctx.identity = MagicMock()
ctx.config = MagicMock()
ctx.database = MagicMock()
ctx.database.messages.count_lxmf_messages.return_value = 0
ctx.database.messages.count_lxmf_messages_by_state.return_value = 0
request_started = threading.Event()
request_finished = threading.Event()
def slow_request(_identity):
request_started.set()
request_finished.wait(timeout=5.0)
router.request_messages_from_propagation_node.side_effect = slow_request
with (
patch.object(app, "_begin_propagation_sync_metrics"),
patch.object(app, "send_config_to_websocket_clients", return_value=None),
):
await app.sync_propagation_nodes(context=ctx, force=False)
await asyncio.sleep(0.05)
assert request_started.is_set()
assert not request_finished.is_set()
request_finished.set()
await asyncio.sleep(0.05)

View file

@ -0,0 +1,44 @@
# SPDX-License-Identifier: 0BSD
from meshchatx.src.backend import rnode_support
def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch):
config_path = tmp_path / "config"
config_path.write_text(
"""[interfaces]
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
port = ble://aa:bb:cc:dd:ee:ff
""",
encoding="utf-8",
)
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False)
assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True
assert "interface_enabled = false" in config_path.read_text(encoding="utf-8")
def test_guard_keeps_rnode_when_usbserial4a_available(tmp_path, monkeypatch):
config_path = tmp_path / "config"
config_path.write_text(
"""[interfaces]
[[RNode Serial]]
type = RNodeInterface
interface_enabled = True
""",
encoding="utf-8",
)
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True)
monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True)
assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False
assert "interface_enabled = True" in config_path.read_text(encoding="utf-8")
def test_rnode_serial_supported_on_desktop(monkeypatch):
monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False)
assert rnode_support.rnode_serial_supported() is True

View file

@ -2,6 +2,8 @@
"""Unit tests for the RRC hub server (hosting) layer."""
import time
from meshchatx.src.backend.rrc import protocol as proto
from meshchatx.src.backend.rrc.manager import RRCManager, RRCHub
from meshchatx.src.backend.rrc.server import (
@ -277,6 +279,13 @@ def test_ping_returns_pong():
assert out[0][1][proto.K_BODY] == 123
def test_uptime_seconds_in_summary():
server = make_running_server()
assert server.to_dict()["uptime_seconds"] == 0
server._started_at = time.time() - 90
assert server.to_dict()["uptime_seconds"] >= 90
def test_register_and_unregister_room():
server = make_server()
server.register_room("Lobby", topic="hi")

View file

@ -77,6 +77,17 @@ describe("ConversationViewer outbound propagation status", () => {
expect(wrapper.vm.outboundSentStatusTitle(null)).toBe("");
});
it("outboundTransferProgressPercent and label track resource transfer", () => {
const wrapper = mountViewer();
expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 42.5 })).toBe(43);
expect(wrapper.vm.outboundSendingProgressLabel({ state: "sending", progress: 42.5 })).toBe("43%");
expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 0 })).toBe(0);
expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 50 })).toBe(50);
expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 0 })).toBeNull();
expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", _pendingPathfinding: true })).toBeNull();
expect(wrapper.vm.outboundSendingProgressLabel(null)).toBeNull();
});
it("outboundSendingStatusTooltip uses propagation pending strings for propagated method", () => {
const wrapper = mountViewer();
const withProgress = wrapper.vm.outboundSendingStatusTooltip({

View file

@ -108,4 +108,28 @@ describe("RNSHManagerPage.vue", () => {
expect(wrapper.vm.outputsBySession[SESSION_ID]).toContain("line2");
});
it("toggles session fullscreen and closes mobile sessions drawer on narrow screens", async () => {
const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
wrapper.vm.isNarrowScreen = true;
wrapper.vm.mobileSessionsOpen = true;
wrapper.vm.toggleSessionFullscreen();
expect(wrapper.vm.sessionFullscreen).toBe(true);
expect(wrapper.vm.mobileSessionsOpen).toBe(false);
wrapper.vm.toggleSessionFullscreen();
expect(wrapper.vm.sessionFullscreen).toBe(false);
});
it("selectSession closes mobile sessions list on narrow screens", async () => {
const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
wrapper.vm.isNarrowScreen = true;
wrapper.vm.mobileSessionsOpen = true;
wrapper.vm.selectSession(SESSION_ID);
expect(wrapper.vm.mobileSessionsOpen).toBe(false);
});
});

View file

@ -368,23 +368,23 @@ describe("RelayChatPage.vue", () => {
);
});
it("loads hosted hub members when opening the members dialog", async () => {
it("loads hosted hub members when opening the moderation page", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
const hub = wrapper.vm.serverHubs[0];
wrapper.vm.openHostMembers(hub, "lobby");
wrapper.vm.view = "host";
wrapper.vm.openHostModeration(hub, { tab: "members", room: "lobby" });
await wrapper.vm.$nextTick();
const modal = wrapper.findComponent({ name: "RelayHostMembersModal" });
await vi.waitFor(() => expect(modal.vm.members).toHaveLength(1));
const page = wrapper.findComponent({ name: "RelayHostModerationPage" });
await vi.waitFor(() => expect(page.vm.members).toHaveLength(1));
expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rrc/servers/${HOSTED_HUB_ID}/members`, {
params: { room: "lobby" },
});
expect(wrapper.vm.hostMembersModal.open).toBe(true);
expect(wrapper.vm.hostMembersModal.hub.id).toBe(HOSTED_HUB_ID);
expect(modal.vm.members[0].name).toBe("alice");
expect(wrapper.vm.hostModeration.hub.id).toBe(HOSTED_HUB_ID);
expect(page.vm.members[0].name).toBe("alice");
});
it("refreshes hosted hubs on a server change websocket event", async () => {

View file

@ -1,7 +1,7 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import RelayHostMembersModal from "@/components/relay/RelayHostMembersModal.vue";
import RelayHostModerationPage from "@/components/relay/RelayHostModerationPage.vue";
import DialogUtils from "@/js/DialogUtils";
import ToastUtils from "@/js/ToastUtils";
import { mountToolsPageGlobals } from "./testI18n.js";
@ -26,7 +26,7 @@ const PEER_HASH = "00112233445566778899aabbccddeeff";
const LOCAL_HASH = "ffeeddccbbaa99887766554433221100";
function makeHub() {
return { id: HUB_ID, name: "Hosted" };
return { id: HUB_ID, name: "Hosted", running: true };
}
function makeMember(overrides = {}) {
@ -38,7 +38,7 @@ function makeMember(overrides = {}) {
};
}
describe("RelayHostMembersModal.vue", () => {
describe("RelayHostModerationPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
DialogUtils.confirm.mockResolvedValue(true);
@ -50,25 +50,28 @@ describe("RelayHostMembersModal.vue", () => {
if (url.includes("/members")) {
return { data: { members: [makeMember()] } };
}
if (url.includes("/activity")) {
return { data: { rooms: [], recent: [] } };
}
return { data: {} };
}),
post: vi.fn(async () => ({ data: { message: "ok" } })),
};
});
const mountModal = (props = {}) =>
mount(RelayHostMembersModal, {
const mountPage = (props = {}) =>
mount(RelayHostModerationPage, {
props: {
open: true,
hub: makeHub(),
room: null,
initialTab: "members",
roomFilter: null,
...props,
},
global: mountToolsPageGlobals(),
});
it("kicks using the member room when the modal has no room filter", async () => {
const wrapper = mountModal();
it("kicks using the member room when there is no room filter", async () => {
const wrapper = mountPage();
await wrapper.vm.fetchMembers();
await wrapper.vm.moderate(makeMember(), "kick");
@ -80,8 +83,8 @@ describe("RelayHostMembersModal.vue", () => {
});
});
it("uses the modal room filter when set", async () => {
const wrapper = mountModal({ room: "ops" });
it("uses the room filter when set", async () => {
const wrapper = mountPage({ roomFilter: "ops" });
await wrapper.vm.fetchMembers();
await wrapper.vm.moderate(makeMember({ rooms: ["lobby", "ops"] }), "kick");
@ -94,7 +97,7 @@ describe("RelayHostMembersModal.vue", () => {
});
it("blocks moderating the local identity", async () => {
const wrapper = mountModal();
const wrapper = mountPage();
await wrapper.vm.ensureLocalIdentity();
await wrapper.vm.moderate(makeMember({ hash: LOCAL_HASH }), "kick");