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
co-authored by Claude Sonnet 5
parent 469b0203b7
commit 8256fde1a6
18 changed files with 242 additions and 169 deletions
+1 -14
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>
+4 -58
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>
+29 -4
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>
@@ -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: [{