mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(android): refactor RNode interface handling and add support for Android storage management
This commit is contained in:
parent
d3dfa648a3
commit
7eea421940
34 changed files with 2385 additions and 1074 deletions
|
|
@ -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,
|
||||
|
|
|
|||
100
meshchatx/src/backend/rnode_support.py
Normal file
100
meshchatx/src/backend/rnode_support.py
Normal 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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
199
meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue
Normal file
199
meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
166
meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue
Normal file
166
meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue
Normal 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>
|
||||
9
meshchatx/src/frontend/js/relayHostModalClasses.js
Normal file
9
meshchatx/src/frontend/js/relayHostModalClasses.js
Normal 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";
|
||||
37
meshchatx/src/frontend/js/relayHostModerationClasses.js
Normal file
37
meshchatx/src/frontend/js/relayHostModerationClasses.js
Normal 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";
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
92
tests/backend/test_propagation_sync_cancel.py
Normal file
92
tests/backend/test_propagation_sync_cancel.py
Normal 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)
|
||||
44
tests/backend/test_rnode_support.py
Normal file
44
tests/backend/test_rnode_support.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
Loading…
Add table
Add a link
Reference in a new issue