fix terminal mesh chat and guarded IndeeHub playback

This commit is contained in:
archipelago
2026-10-10 07:52:47 -04:00
parent bbf8dc76c4
commit 71de1a3c27
15 changed files with 363 additions and 43 deletions
+5 -2
View File
@@ -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
View File
@@ -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)
}
+14 -10
View File
@@ -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')