feat: share reviewed website archives and repair companion setup flows
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
|
||||
const selected = defineModel<string>({ required: true })
|
||||
const root = ref<HTMLElement>()
|
||||
const trigger = ref<HTMLButtonElement>()
|
||||
const search = ref<HTMLInputElement>()
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const active = ref(0)
|
||||
const uid = useId()
|
||||
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
|
||||
watch(query, () => { active.value = 0 })
|
||||
watch(() => props.disabled, value => { if (value) open.value = false })
|
||||
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
|
||||
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
|
||||
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
|
||||
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
|
||||
async function keyboard(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
|
||||
if (!open.value) {
|
||||
if (event.key === 'Enter') return // native button click opens it
|
||||
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
|
||||
}
|
||||
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
|
||||
if (event.key === 'Enter') return
|
||||
event.preventDefault()
|
||||
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
|
||||
search.value?.focus()
|
||||
await nextTick()
|
||||
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
|
||||
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="min-w-0" @keydown="keyboard">
|
||||
<span :id="`${uid}-label`" class="block mb-2">Application</span>
|
||||
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
|
||||
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
|
||||
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
|
||||
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
|
||||
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
|
||||
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
|
||||
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
|
||||
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { websitePreview } from '@/services/publishing'
|
||||
const props = defineProps<{
|
||||
publication: { html: string; public_archive?: string | null }
|
||||
archive?: { sha256: string; size: number } | null
|
||||
address?: string | null
|
||||
busy?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ change: [enabled: boolean] }>()
|
||||
const approved = ref(false)
|
||||
watch(() => [props.publication, props.archive], () => { approved.value = false })
|
||||
const fileAddress = computed(() => props.address && props.publication.public_archive
|
||||
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="rounded-xl border border-white/10 p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
|
||||
<div class="space-y-4 pt-4">
|
||||
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
|
||||
<template v-if="publication.public_archive">
|
||||
<p class="text-sm">This snapshot is shared on this connection.</p>
|
||||
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
|
||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
|
||||
</template>
|
||||
<template v-else-if="archive">
|
||||
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
|
||||
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
|
||||
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
|
||||
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
|
||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
|
||||
</template>
|
||||
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
|
||||
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SearchableAppSelect from '../SearchableAppSelect.vue'
|
||||
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
|
||||
describe('searchable app picker', () => {
|
||||
it('renders actual touchable options, filters them and selects only an offered app', async () => {
|
||||
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
|
||||
await wrapper.get('input').setValue('imm')
|
||||
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
|
||||
await wrapper.get('[role="option"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
|
||||
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
|
||||
await wrapper.get('input').setValue('home')
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.get('input').setValue('unknown')
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
|
||||
|
||||
describe('archived website sharing consent', () => {
|
||||
it('requires fresh approval after the reviewed snapshot changes', async () => {
|
||||
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('change')).toEqual([[true]])
|
||||
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('change')).toHaveLength(1)
|
||||
})
|
||||
it('keeps removal available without new approval and exposes only the selected hash', async () => {
|
||||
const hash = 'a'.repeat(64)
|
||||
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
|
||||
expect(wrapper.text()).toContain('https://example.com/'+hash)
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('change')).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user