frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
+2
View File
@@ -39,6 +39,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const props = withDefaults(defineProps<{
show: boolean
@@ -65,6 +66,7 @@ function close() {
}
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
</script>
<style scoped>
@@ -11,10 +11,18 @@
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
@click.stop
>
<!-- Icon -->
<div class="flex justify-center mb-3">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button
type="button"
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Close companion modal"
@click="dismiss"
>
&times;
</button>
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
@@ -22,38 +30,44 @@
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
</div>
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
Control your node from another device. Install the
<span class="text-orange-400 font-medium">Archipelago</span>
companion app on your phone, connect to the same network, and use it as a
gamepad or keyboard.
</p>
<div class="flex flex-col gap-2 text-xs text-white/40">
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
<span>Install the APK on your phone</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
<span>Enter your node address and password</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
<span>Use D-pad &amp; buttons or keyboard to control apps</span>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
<p class="text-sm text-white/60 leading-relaxed">
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
</p>
</div>
</div>
<button
class="mt-4 w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30
text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors"
@click="dismiss"
<div class="mb-4">
<a
:href="companionDownloadUrl"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
<div class="hidden md:flex justify-center">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="Companion app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
</div>
<a
:href="companionDownloadUrl"
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
>
Got it
</button>
Download companion app
</a>
</div>
</div>
</Transition>
@@ -61,11 +75,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import * as QRCode from 'qrcode'
const STORAGE_KEY = 'neode_companion_intro_seen'
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
const visible = ref(false)
const qrDataUrl = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
onMounted(() => {
try {
@@ -78,6 +96,19 @@ onMounted(() => {
}
})
watch(visible, async (isVisible) => {
if (!isVisible) return
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 112,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}, { immediate: true, flush: 'post' })
function dismiss() {
visible.value = false
try {
+5 -2
View File
@@ -20,6 +20,7 @@ const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
let geoWatchId: number | null = null
function toggleLocationSharing() {
@@ -337,7 +338,7 @@ onUnmounted(() => {
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>Enable location sharing to see nodes on the map</span>
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
</div>
<!-- Location sharing overlay -->
@@ -373,7 +374,9 @@ onUnmounted(() => {
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
<div v-if="locationError" class="mesh-map-location-error">
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
</div>
</div>
</div>
</template>
@@ -39,6 +39,7 @@
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</div>
@@ -67,6 +68,7 @@ import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
@@ -124,6 +126,9 @@ async function receive() {
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
if (!res.address) {
throw new Error('LND did not return a Bitcoin address')
}
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else {
@@ -136,7 +141,9 @@ async function receive() {
emit('received')
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed'
error.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}
@@ -93,14 +93,6 @@
</button>
</div>
</template>
<!-- AI Assistant placeholder -->
<div class="p-2 border-t border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
Coming soon ask questions about your node, apps, and Bitcoin.
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('locks page scroll while open and restores it when closed', async () => {
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
attachTo: document.body,
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('')
wrapper.unmount()
})
})
@@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const meshState = vi.hoisted(() => ({
nodePositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}))
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => meshState,
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => ({
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
remove: vi.fn(),
})),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
},
}))
describe('MeshMap', () => {
beforeEach(() => {
meshState.nodePositions.clear()
meshState.peers = []
meshState.status = null
meshState.deadmanStatus = null
meshState.updateSelfPosition.mockClear()
})
it('treats denied browser location as optional for peer positions', async () => {
let errorHandler!: (error: { code: number; message: string }) => void
const watchPosition = vi.fn((_success, error) => {
errorHandler = error
return 7
})
const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
}))
vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch },
})
vi.stubGlobal('ResizeObserver', resizeObserver)
const wrapper = mount(MeshMap)
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
await wrapper.get('[role="switch"]').trigger('click')
errorHandler({ code: 1, message: 'denied' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
expect(wrapper.text()).not.toContain('location sharing is required')
vi.unstubAllGlobals()
})
})