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:
co-authored by
Claude Sonnet 5
parent
469b0203b7
commit
8256fde1a6
@@ -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: [{
|
||||
|
||||
Reference in New Issue
Block a user