fix(ui): mesh/web5/apps layout, modal, and search UX fixes

- Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation
  leaking into the desktop-sidebar range), let the mesh view scale to
  full width on wide screens instead of capping at 1600px, and make the
  Device panel collapsible on desktop (previously mobile-only)
- Search/controller-nav: a global gamepad/keyboard-nav feature was
  auto-clicking "the next button in the DOM" on Enter in any text input,
  which cleared the mesh peer search and popped the sideload modal from
  the App Store/My Apps search boxes. Opt out via data-controller-no-submit
  on all filter inputs; bump the mesh clear button's touch target
- Modals: several (sideload, credential, Lightning channel open, identity
  create) used ad-hoc blue buttons and non-fullscreen backdrops that only
  covered the main content area, not the sidebar. Teleport them to body,
  unify backdrop/button theming to the dark+orange convention, fix the
  sideload modal's square bottom corners on desktop, and standardize
  close buttons to the ghost-icon style
- Web5: remove the redundant/dead "Messages" tab from Connected Nodes
  (its deep-link was unreachable dead code); fix the "view message" toast
  to actually open the Archipelago channel instead of silently failing to
  match a LoRa peer; make identity rows responsive via a container query
  (viewport-based breakpoints don't work in the page's 2-column grid) and
  right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected
  Nodes by default on mobile
- Apps/App Store: match the search bar and sideload button's height,
  padding, and background to the mode-switcher tabs beside them
- Mesh chat: keep the compose input focused after sending

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-01 18:04:31 -04:00
parent 469b0203b7
commit 8256fde1a6
18 changed files with 242 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: [{