Merge remote-tracking branch 'gitea-ai/fix/reticulum-daemon-pdeathsig'
This commit is contained in:
commit
0da73a8ce1
@ -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"
|
||||
>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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">×</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">×</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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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...'"
|
||||
|
||||
@ -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">×</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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [{
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user