Merge remote-tracking branch 'gitea-ai/fix/reticulum-daemon-pdeathsig'

This commit is contained in:
archipelago 2026-07-01 18:09:31 -04:00
commit 0da73a8ce1
19 changed files with 267 additions and 169 deletions

View File

@ -35,7 +35,7 @@
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
class="flex-1 rounded-lg bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
:disabled="loading || !selected"
@click="confirm"
>

View File

@ -236,8 +236,17 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (e.key === 'Enter' && target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit') {
// Enter in input: click next button (submit pattern)
if (
e.key === 'Enter' &&
target.tagName === 'INPUT' &&
(target as HTMLInputElement).type !== 'submit' &&
!target.hasAttribute('data-controller-no-submit')
) {
// Enter in input: click next button (submit pattern). Live-filter
// search boxes opt out via data-controller-no-submit — the "next
// focusable element" after a search field is often an unrelated
// button (a clear icon, a sideload/upload action) that just happens
// to sit next to it in the DOM, not a submit action for the query.
e.preventDefault()
const all = getFocusableElements(containerRef?.value ?? document)
const idx = all.indexOf(target)

View File

@ -441,13 +441,11 @@
"yourTorAddress": "Your Tor address",
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
"noMessages": "No messages yet. Messages from peers will appear here.",
"noRequests": "No pending connection requests.",
"accept": "Accept",
"reject": "Reject",
"discovering": "Discovering...",
"discoverNodes": "Discover Nodes on Nostr",
"refreshMessages": "Refresh Messages",
"refreshRequests": "Refresh Requests",
"torServices": "Tor Services",
"torServicesDesc": "Hidden services exposing your apps over Tor",
@ -530,7 +528,6 @@
"observer": "Observer",
"observers": "Observers",
"noObservers": "No observers yet.",
"messages": "Messages",
"requests": "Requests",
"myContent": "My Content",
"browsePeers": "Browse Peers",

View File

@ -439,13 +439,11 @@
"yourTorAddress": "Su direcci\u00f3n Tor",
"discoverableWarning": "Hacer su nodo descubrible permite que otros usuarios de Archipelago le encuentren y se conecten con usted.",
"noPeers": "A\u00fan no hay pares. Agregue un par manualmente o use Descubrir para encontrar nodos en Nostr.",
"noMessages": "A\u00fan no hay mensajes. Los mensajes de pares aparecer\u00e1n aqu\u00ed.",
"noRequests": "No hay solicitudes de conexi\u00f3n pendientes.",
"accept": "Aceptar",
"reject": "Rechazar",
"discovering": "Descubriendo...",
"discoverNodes": "Descubrir nodos en Nostr",
"refreshMessages": "Actualizar mensajes",
"refreshRequests": "Actualizar solicitudes",
"torServices": "Servicios Tor",
"torServicesDesc": "Servicios ocultos que exponen sus aplicaciones a trav\u00e9s de Tor",
@ -528,7 +526,6 @@
"observer": "Observador",
"observers": "Observadores",
"noObservers": "A\u00fan no hay observadores.",
"messages": "Mensajes",
"requests": "Solicitudes",
"myContent": "Mi contenido",
"browsePeers": "Explorar pares",

View File

@ -420,6 +420,46 @@ input[type="radio"]:active + * {
max-width: none;
}
/* Give the search bar the same pill-container look as .mode-switcher (the
My Apps/App Store/Services and category tabs it sits next to), instead
of its own brighter ad-hoc background/border. */
.app-header-search {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
outline: none;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.app-header-search:focus {
border-color: rgba(251, 146, 60, 0.35);
background-color: rgba(251, 146, 60, 0.08);
}
/* Match the search bar to the tabs it sits beside not just the inner
button, but the full .mode-switcher pill container (button + its 3px
padding + border): 40px on desktop. On mobile, the visible tab row is
the floating top tab bar (DashboardMobileNav), measured at 52px. */
@media (min-width: 921px) {
.app-header-search {
height: 40px;
padding-top: 0;
padding-bottom: 0;
font-size: 0.8rem;
box-sizing: border-box;
}
}
@media (max-width: 920px) {
.app-header-search {
height: 52px;
padding-top: 0;
padding-bottom: 0;
box-sizing: border-box;
}
}
.segmented-select .mode-switcher-btn {
flex: 0 0 auto;
}

View File

@ -61,7 +61,8 @@
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button
type="button"
@ -110,7 +111,8 @@
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button
type="button"
@ -260,6 +262,7 @@
@confirm="onConfirmUninstall"
/>
<Teleport to="body">
<Transition name="fade">
<div
v-if="credentialModal.show"
@ -272,7 +275,11 @@
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">&times;</button>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="credential-modal-body space-y-3">
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
@ -290,7 +297,9 @@
</div>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<Transition name="fade">
<div
v-if="showSideload"
@ -303,7 +312,11 @@
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">&times;</button>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
@ -347,6 +360,7 @@
</form>
</div>
</Transition>
</Teleport>
<!-- Action error toast -->
<Transition name="fade">
@ -772,11 +786,11 @@ async function submitSideload() {
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
width: 40px;
height: 40px;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.78);
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
@ -787,8 +801,8 @@ async function submitSideload() {
color: white;
}
.sideload-icon-btn-mobile {
width: 48px;
height: 48px;
width: 52px;
height: 52px;
}
.sideload-modal {
width: 100%;
@ -796,18 +810,37 @@ async function submitSideload() {
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
/* Bottom sheet on mobile (flush with the screen edge, so only the top
corners round); the md: breakpoint below switches to a centered
floating card, matching the wrapper's own items-end -> md:items-center
layout switch, so it gets fully rounded corners like every other modal. */
border-radius: 1.5rem 1.5rem 0 0;
background: rgba(8, 10, 18, 0.94);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
}
@media (min-width: 768px) {
.sideload-modal {
border-radius: 1.5rem;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
}
.sideload-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
background: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sideload-close-btn:hover,
.sideload-close-btn:focus-visible {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.sideload-label {
display: block;

View File

@ -54,7 +54,8 @@
type="text"
placeholder="Search apps..."
aria-label="Search apps"
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
data-controller-no-submit
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
@ -84,7 +85,8 @@
type="text"
placeholder="Search apps..."
aria-label="Search apps"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
data-controller-no-submit
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div>
</div>

View File

@ -48,6 +48,7 @@ const connectingDevice = ref<string | null>(null)
const showOnboardingModal = ref(false)
const onboardingDismissed = ref<Set<string>>(new Set())
const chatScrollEl = ref<HTMLElement | null>(null)
const messageInputRef = ref<HTMLInputElement | null>(null)
const mobileShowChat = ref(false)
// Device status panel starts collapsed on mobile (expandable via its header).
const deviceExpanded = ref(false)
@ -366,13 +367,21 @@ onMounted(async () => {
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
refreshOutboxCount()
// Deep-link from a message toast: open the sender's conversation if we can
// match it; otherwise just land on the mesh page (graceful fallback).
// match a LoRa mesh peer; otherwise open the Archipelago channel, since
// getReceivedMessages() (the toast's only message source) is exclusively
// Archipelago (Tor federation) traffic a federation pubkey will never
// match an entry in mesh.peers, so without this fallback the deep-link
// silently failed and just landed on the bare mesh page every time.
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) {
const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
)
if (match) openChat(match)
if (match) {
openChat(match)
} else {
openArchChannel()
}
}
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
@ -913,7 +922,7 @@ async function handleSendMessage() {
if (mesh.sending || sendingArch.value) return
if (archChannelActive.value) {
await sendArchMessage()
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
return
}
// Pending reply: Send flushes as mesh.send-reply targeting the stashed
@ -931,7 +940,7 @@ async function handleSendMessage() {
)
messageText.value = ''
pendingEdit.value = null
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Edit failed'
}
@ -949,7 +958,7 @@ async function handleSendMessage() {
)
messageText.value = ''
pendingReply.value = null
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Reply failed'
}
@ -966,7 +975,7 @@ async function handleSendMessage() {
await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption)
messageText.value = ''
pendingAttachment.value = null
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Share failed'
}
@ -978,11 +987,11 @@ async function handleSendMessage() {
if (activeChatChannel.value) {
await mesh.sendChannelMessage(activeChatChannel.value.index, messageText.value)
messageText.value = ''
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
} else if (activeChatPeer.value) {
await mesh.sendMessage(activeChatPeer.value.contact_id, messageText.value)
messageText.value = ''
nextTick(() => scrollChatToBottom())
nextTick(() => { scrollChatToBottom(); refocusMessageInput() })
}
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Send failed'
@ -995,6 +1004,13 @@ function scrollChatToBottom() {
}
}
// Keep the compose field focused after a send so the user can keep typing
// without re-clicking it (Enter re-focuses natively, but the Send button
// click otherwise leaves focus on the button).
function refocusMessageInput() {
messageInputRef.value?.focus()
}
// Wheel over the chat must scroll ONLY the chat never leak to the contacts
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
// from reaching the global controller-nav wheel handler (which would otherwise
@ -1869,6 +1885,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
class="mesh-peer-search"
placeholder="Search contacts…"
aria-label="Search contacts"
data-controller-no-submit
/>
<button
v-if="peerSearch"
@ -2301,6 +2318,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</button>
</div>
<input
ref="messageInputRef"
v-model="messageText"
class="mesh-chat-input"
:placeholder="activeChatPeer ? 'Type a message or pick a file…' : 'Type a message...'"

View File

@ -91,6 +91,7 @@
></button>
</div>
<Teleport to="body">
<Transition name="fade">
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/80 backdrop-blur-md p-4" @click.self="closeCredentialModal">
<div class="credential-modal-panel">
@ -99,7 +100,11 @@
<h2 class="text-lg font-semibold text-white">{{ credentialModal.title }}</h2>
<p class="text-sm text-white/55 mt-1">{{ credentialModal.description }}</p>
</div>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">&times;</button>
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeCredentialModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="credential-modal-body space-y-3">
<div v-for="cred in credentialModal.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
@ -117,6 +122,7 @@
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@ -344,27 +350,22 @@ function scrollToPage(index: number) {
transform: scale(0.88);
}
.sideload-modal {
display: flex;
flex-direction: column;
width: 100%;
max-width: 34rem;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.5rem 1.5rem 0 0;
background: rgba(8, 10, 18, 0.94);
padding: 1.25rem;
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
}
.sideload-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
border-radius: 0.75rem;
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
background: transparent;
transition: background-color 0.15s ease, color 0.15s ease;
}
.sideload-close-btn:hover,
.sideload-close-btn:focus-visible {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.credential-modal-body {
flex: 1 1 auto;

View File

@ -129,6 +129,7 @@
</Transition>
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@ -166,15 +167,17 @@
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
@ -194,6 +197,7 @@
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@ -78,6 +78,10 @@ describe('AppIconGrid', () => {
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
global: {
plugins: [pinia],
// The credential modal is <Teleport to="body">'d (so its full-screen
// backdrop isn't clipped by the dashboard's transformed layout) —
// stub it to render inline so wrapper.text() still sees it.
stubs: { teleport: true },
},
})

View File

@ -1,4 +1,5 @@
<template>
<Teleport to="body">
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
@ -125,6 +126,7 @@
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">

View File

@ -4,8 +4,6 @@
.mesh-view {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
@ -57,14 +55,16 @@
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
.mesh-status-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.mesh-status-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; cursor: pointer; }
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
.mesh-status-indicator { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
/* Collapse chevron only used on mobile (hidden on desktop, where the Device
panel is always expanded). Kept small and pushed to the far right. */
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
/* Collapse chevron — the Device panel collapses/expands on every breakpoint. */
.mesh-status-chevron { display: block; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
@ -91,10 +91,10 @@
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 36px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; }
.mesh-peer-search-clear { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; touch-action: manipulation; }
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
.mesh-peer-reach.is-reachable { background: #34d399; }
@ -271,14 +271,6 @@
/* Floating mesh tab strip — same placement logic as the mobile back button. */
.mesh-mobile-tabbar { display: flex; }
.mobile-hidden { display: none !important; }
/* Device panel is a collapsible/expandable accordion on mobile (starts
collapsed). Show the chevron, make the header tappable, and hide the body
when collapsed. */
.mesh-status-chevron { display: block; }
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
:deep(.mesh-bitcoin-panel),
:deep(.mesh-assistant-panel),
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
@ -297,6 +289,29 @@
.mesh-chat-mobile-back.mobile-back-btn {
left: 268px;
}
/* The 1279px bottom offsets above reserve `--mobile-tab-bar-height` for the
OS-style bottom nav bar but that bar only ever renders below 768px
(DashboardMobileNav's `md:hidden`); here the desktop sidebar is the primary
nav instead, so the var is always unset and its 72px fallback becomes a
phantom reservation with nothing under it, leaving a huge dead gap below
the panel. Drop that term so bottom clearance matches the top/left/right
margins, keeping only the space actually needed for the in-page floating
tab bar / back button. */
.mesh-left,
.mesh-mobile-tools,
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(var(--audio-player-height, 0px) + 72px);
}
.mesh-chat-card.mesh-chat-card-active {
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
}
.mesh-mobile-tabbar {
bottom: calc(var(--audio-player-height, 0px) + 12px);
}
.mesh-chat-mobile-back.mobile-back-btn {
bottom: calc(max(var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
}
}
@media (max-width: 920px) {

View File

@ -90,8 +90,7 @@ let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from './utils'
@ -110,7 +109,6 @@ import Web5Monitoring from './Web5Monitoring.vue'
import Web5Federation from './Web5Federation.vue'
// import Web5SendReceiveModals from './Web5SendReceiveModals.vue' // wallet hidden
const route = useRoute()
const { t } = useI18n()
const showStagger = !web5AnimationDone
@ -409,11 +407,6 @@ onMounted(() => {
loadTransactions()
loadEcashBalance()
}, 30000)
// Open Messages tab when navigated via toast
if (route.query.tab === 'messages') {
connectedNodesRef.value?.scrollToMessages()
}
})
onUnmounted(() => {
@ -422,10 +415,4 @@ onUnmounted(() => {
walletRefreshInterval = null
}
})
watch(() => route.query.tab, (tab) => {
if (tab === 'messages') {
connectedNodesRef.value?.scrollToMessages()
}
})
</script>

View File

@ -57,15 +57,6 @@
{{ t('web5.observers') }}
<span v-if="observers.length > 0" class="ml-1.5 text-xs text-white/50">({{ observers.length }})</span>
</button>
<button
@click="switchToMessagesTab"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
:class="nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.messages') }}
<span v-if="receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'">({{ receivedMessages.length }})</span>
<span v-if="unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
</button>
<button
@click="switchToRequestsTab"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
@ -140,30 +131,6 @@
</div>
</div>
<!-- Messages tab -->
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingMessages && receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noMessages') }}
</div>
<div v-else-if="loadingMessages" class="p-2 text-center text-white/45 text-xs">
{{ t('common.loading') }}
</div>
<div
v-for="(m, idx) in receivedMessages"
:key="idx"
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
>
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ peerNameFromPubkey(m.from_pubkey) }}</p>
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
</div>
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
</div>
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
@ -238,14 +205,6 @@
>
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
</button>
<button
v-else-if="nodesContainerTab === 'messages'"
@click="loadReceivedMessages"
:disabled="loadingMessages"
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
>
{{ loadingMessages ? t('common.loading') : t('web5.refreshMessages') }}
</button>
<button
v-else
@click="loadConnectionRequests"
@ -309,7 +268,7 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
@ -361,8 +320,8 @@ function writeConnectedNodesCache(state: ConnectedNodesCache) {
}
const nodesContainerRef = ref<HTMLElement | null>(null)
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
const nodesContainerTab = ref<'trusted' | 'observers' | 'requests'>('trusted')
const { loadReceivedMessages } = messageToast
const cached = readConnectedNodesCache()
const peers = ref<Peer[]>(cached.peers ?? [])
@ -413,11 +372,6 @@ function federationNodeToPeer(node: FederationNode): Peer {
}
}
function switchToMessagesTab() {
nodesContainerTab.value = 'messages'
markAsRead()
}
function switchToRequestsTab() {
nodesContainerTab.value = 'requests'
if (connectionRequests.value.length === 0 && !loadingRequests.value) {
@ -586,13 +540,5 @@ async function rejectRequest(requestId: string) {
}
}
function scrollToMessages() {
nodesContainerTab.value = 'messages'
markAsRead()
nextTick(() => {
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers, scrollToMessages })
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers })
</script>

View File

@ -1,6 +1,6 @@
<template>
<!-- Identity Management -->
<div class="glass-card p-6">
<div class="glass-card p-6 identities-card">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
@ -61,9 +61,10 @@
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
<img
@ -113,9 +114,10 @@
</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<div class="flex items-center justify-end gap-1 shrink-0">
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@ -168,7 +170,7 @@
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30">
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
{{ creatingIdentity ? t('web5.creatingDid') : t('web5.createIdentity') }}
</button>
</div>
@ -697,3 +699,26 @@ async function publishProfile() {
defineExpose({ loadIdentities, managedIdentities })
</script>
<style scoped>
/* This card sits in a 2-column page grid, so its own rendered width is
roughly half the viewport a viewport media query can't tell whether
the row actually has room for a horizontal layout. Use a container query
instead, keyed to the row's real width. */
.identities-card {
container-type: inline-size;
container-name: identities-card;
}
@container identities-card (min-width: 560px) {
.identity-row {
flex-direction: row;
align-items: center;
gap: 1rem;
}
.identity-row-main {
flex: 1 1 auto;
}
}
</style>

View File

@ -25,6 +25,20 @@
</button>
</div>
<!-- Mobile-only toggle: DID Status through Connected Nodes are often not
needed at a glance, so collapse them by default to save space. -->
<button
type="button"
class="md:hidden flex items-center justify-center gap-1.5 py-2 text-xs font-medium text-white/50 hover:text-white/80 transition-colors"
@click="mobileCollapsed = !mobileCollapsed"
>
{{ mobileCollapsed ? 'Show more' : 'Show less' }}
<svg class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': !mobileCollapsed }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div :class="mobileCollapsed ? 'hidden md:contents' : 'contents'">
<!-- DID Status -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div class="flex items-center gap-3 min-w-0">
@ -167,6 +181,7 @@
</button>
</div>
</div>
</div>
</div>
</div>
@ -187,6 +202,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
@ -194,6 +210,10 @@ import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
const router = useRouter()
const { t } = useI18n()
// DID Status through Connected Nodes are often not needed at a glance on
// mobile, so start collapsed there to save space. Desktop always shows them.
const mobileCollapsed = ref(true)
defineProps<{
showStagger: boolean
profitsBreakdown: ProfitsData | null

View File

@ -3,11 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
import { rpcClient } from '@/api/rpc-client'
const messageState = vi.hoisted(() => ({
receivedMessages: { __v_isRef: true, value: [] as Array<{ from_pubkey: string; timestamp: number; message: string }> },
loadingMessages: { __v_isRef: true, value: false },
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
@ -27,11 +22,7 @@ vi.mock('@/api/rpc-client', () => ({
vi.mock('@/composables/useMessageToast', () => ({
useMessageToast: () => ({
receivedMessages: messageState.receivedMessages,
loadingMessages: messageState.loadingMessages,
unreadCount: { value: 0 },
loadReceivedMessages: vi.fn(),
markAsRead: vi.fn(),
}),
}))
@ -50,8 +41,6 @@ vi.mock('@/composables/useModalKeyboard', () => ({
describe('Web5ConnectedNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
messageState.receivedMessages.value = []
messageState.loadingMessages.value = false
})
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
@ -64,22 +53,6 @@ describe('Web5ConnectedNodes', () => {
expect(wrapper.text()).not.toContain('web5.noPeers')
})
it('keeps received messages visible during refresh', async () => {
messageState.receivedMessages.value = [{
from_pubkey: 'peer-pubkey',
timestamp: 1760000000,
message: 'Existing message',
}]
messageState.loadingMessages.value = true
const wrapper = mount(Web5ConnectedNodes)
;(wrapper.vm as unknown as { nodesContainerTab: string }).nodesContainerTab = 'messages'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Existing message')
expect(wrapper.text()).toContain('common.loading')
})
it('keeps connection requests visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
requests: [{

View File

@ -502,7 +502,32 @@ def _parse_args(argv):
return p.parse_args(argv)
def _install_parent_death_signal() -> None:
"""Die when our parent process does.
The daemon ships as a PyInstaller one-file binary: our direct parent is the
bootloader, and the Rust supervisor (mesh/reticulum.rs) stops us by SIGKILL-
ing that bootloader. SIGKILL can't be forwarded, so without this the Python
child is orphaned and keeps holding the RNode serial port which piles up
stale daemons that jam the radio (observed: 9 instances on one node). Asking
the kernel to send us SIGTERM on parent death lets our existing SIGTERM
handler shut down cleanly and free the port. Linux-only; no-op elsewhere.
"""
if sys.platform != "linux":
return
try:
import ctypes
PR_SET_PDEATHSIG = 1
ctypes.CDLL("libc.so.6", use_errno=True).prctl(
PR_SET_PDEATHSIG, signal.SIGTERM
)
except Exception:
pass # best-effort; never block startup on this
def main(argv=None) -> int:
_install_parent_death_signal()
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.check: