fix terminal mesh chat and guarded IndeeHub playback
This commit is contained in:
@@ -88,7 +88,10 @@ async function createSession() {
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
|
||||
if (!response.ok) throw new Error('Could not create a developer session.')
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null
|
||||
throw new Error(payload?.error || `Could not create a developer session (${response.status}).`)
|
||||
}
|
||||
const session = await response.json()
|
||||
sessions.value = [session, ...sessions.value]
|
||||
selectedId.value = session.id
|
||||
@@ -215,6 +218,6 @@ onBeforeUnmount(() => {
|
||||
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
|
||||
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
|
||||
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
|
||||
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
|
||||
.terminal-empty { display: flex; flex: 1 1 100%; width: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
|
||||
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
|
||||
</style>
|
||||
|
||||
+20
-14
@@ -20,6 +20,7 @@ import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '@/views/mesh/channelMessages'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@@ -78,18 +79,18 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
// The Public channel (always available on Meshcore)
|
||||
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
|
||||
// in mesh-config.json. Slot 0 is the firmware default Public, which uses
|
||||
// the universal meshcore key and only works between devices sharing keys
|
||||
// + RF region. Slot 1 is set by archipelago first-boot to a hash derived
|
||||
// from the channel_name, so all archipelago nodes are guaranteed on the
|
||||
// same channel regardless of region.
|
||||
// "Public" is the stock MeshCore/Meshtastic public channel in slot 0.
|
||||
const publicChannel = { index: 0, name: 'Public' }
|
||||
|
||||
// Channel contact_id convention: matches backend u32::MAX - channel_index
|
||||
function channelContactId(channelIndex: number): number {
|
||||
return 4294967295 - channelIndex // u32::MAX - index
|
||||
}
|
||||
const publicChannelUnread = computed(() => {
|
||||
const contactIds = new Set([
|
||||
channelContactId(publicChannel.index),
|
||||
...mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id),
|
||||
])
|
||||
return Object.entries(mesh.unreadCounts)
|
||||
.filter(([contactId]) => contactIds.has(Number(contactId)))
|
||||
.reduce((total, [, count]) => total + count, 0)
|
||||
})
|
||||
|
||||
// Archipelago Channel — Tor-based messaging to all federated/peered nodes
|
||||
const archChannelActive = ref(false)
|
||||
@@ -714,8 +715,10 @@ const chatMessages = computed(() => {
|
||||
const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card'])
|
||||
const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type)
|
||||
if (activeChatChannel.value) {
|
||||
const chanId = channelContactId(activeChatChannel.value.index)
|
||||
return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m))
|
||||
return mesh.messages.filter(m => {
|
||||
const belongsToChannel = isChannelMessage(m, activeChatChannel.value!.index)
|
||||
return belongsToChannel && hideReactions(m)
|
||||
})
|
||||
}
|
||||
if (activeChatPeer.value) {
|
||||
// Pull from every underlying contact_id in the merged group so radio
|
||||
@@ -1134,7 +1137,10 @@ function openChannelChat(channel: { index: number; name: string }) {
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mobileShowChat.value = true
|
||||
mesh.markChatRead(channelContactId(channel.index))
|
||||
const contactIds = channel.index === publicChannel.index
|
||||
? mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id)
|
||||
: [channelContactId(channel.index)]
|
||||
mesh.markChatRead([...new Set([channelContactId(channel.index), ...contactIds])])
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
@@ -2275,7 +2281,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<div class="mesh-peer-name">Public</div>
|
||||
<div class="mesh-peer-sub">Mesh radio</div>
|
||||
</div>
|
||||
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
|
||||
<span v-if="publicChannelUnread" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ publicChannelUnread }}</span>
|
||||
</div>
|
||||
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
|
||||
No contacts match “{{ peerSearch.trim() }}”.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MeshMessage } from '@/stores/mesh'
|
||||
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '../channelMessages'
|
||||
|
||||
function message(peerContactId: number, transport = 'meshcore'): MeshMessage {
|
||||
return {
|
||||
id: 1,
|
||||
direction: 'received',
|
||||
peer_contact_id: peerContactId,
|
||||
peer_name: null,
|
||||
plaintext: 'hello',
|
||||
timestamp: '2026-10-10T00:00:00Z',
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
transport,
|
||||
message_type: 'text',
|
||||
typed_payload: null,
|
||||
sender_pubkey: null,
|
||||
sender_seq: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Mesh public-channel compatibility', () => {
|
||||
it('shows a correctly decoded slot-zero message in Public', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(0)))).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers persisted V3 messages whose SNR was mistaken for a channel index', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(47)))).toBe(true)
|
||||
expect(isPublicChannelMessage(message(channelContactId(208)))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not merge valid non-public slots or another transport into Public', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(1)))).toBe(false)
|
||||
expect(isPublicChannelMessage(message(channelContactId(47), 'meshtastic'))).toBe(false)
|
||||
expect(isChannelMessage(message(channelContactId(1)), 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MeshMessage } from '@/stores/mesh'
|
||||
|
||||
const MAX_U32 = 4294967295
|
||||
const MAX_MESHCORE_CHANNEL_INDEX = 7
|
||||
|
||||
export function channelContactId(channelIndex: number): number {
|
||||
return MAX_U32 - channelIndex
|
||||
}
|
||||
|
||||
export function isPublicChannelMessage(message: MeshMessage): boolean {
|
||||
if (message.peer_contact_id === channelContactId(0)) return true
|
||||
|
||||
// Compatibility for messages persisted by the old V3 decoder, which used
|
||||
// SNR as the channel index. MeshCore has only slots 0..7, making 8..255 an
|
||||
// unambiguous legacy decoder artifact.
|
||||
const derivedIndex = MAX_U32 - message.peer_contact_id
|
||||
return message.transport === 'meshcore'
|
||||
&& derivedIndex > MAX_MESHCORE_CHANNEL_INDEX
|
||||
&& derivedIndex <= 255
|
||||
}
|
||||
|
||||
export function isChannelMessage(message: MeshMessage, channelIndex: number): boolean {
|
||||
return channelIndex === 0
|
||||
? isPublicChannelMessage(message)
|
||||
: message.peer_contact_id === channelContactId(channelIndex)
|
||||
}
|
||||
|
||||
@@ -167,16 +167,19 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
|
||||
<nav aria-label="Node connections" class="grid grid-cols-[repeat(2,minmax(0,1fr))_2.75rem] sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] gap-2">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
My connections
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Find Nodes
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11 col-span-2 sm:col-span-1">
|
||||
Map
|
||||
</button>
|
||||
<nav aria-label="Node connections" class="space-y-2 sm:grid sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] sm:gap-2 sm:space-y-0">
|
||||
<div class="grid grid-cols-2 gap-2 sm:contents">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
My connections
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Find Nodes
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:contents">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
@@ -186,6 +189,7 @@
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('Web5ConnectedNodes', () => {
|
||||
expect(nav.text()).toContain('My connections')
|
||||
expect(nav.text()).toContain('Find Nodes')
|
||||
expect(nav.text()).toContain('Map')
|
||||
expect(nav.text()).not.toContain('Network Map')
|
||||
const mobilePrimaryRow = nav.get('div.grid.grid-cols-2')
|
||||
expect(mobilePrimaryRow.findAll('button')).toHaveLength(2)
|
||||
expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true)
|
||||
await nav.findAll('button')[0]!.trigger('click')
|
||||
await nav.findAll('button')[1]!.trigger('click')
|
||||
await nav.findAll('button')[2]!.trigger('click')
|
||||
|
||||
Reference in New Issue
Block a user