fix(mesh): DM-via-channel tunnel + disable presence spam
Meshcore direct unicast silently drops between our two Archy nodes (firmware reports flood sends with resp_code=6 but nothing arrives). Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner] header; receivers filter by prefix and dispatch the inner payload through the existing typed/base64/chunk ladder. Shrink chunk body to 125B so the wrapper still fits the 160B LoRa budget. Auto-heal routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on refresh so floods take over. send_text now returns the firmware's flood/direct mode flag for diagnostics. Disable the 120s presence heartbeat broadcaster — its CBOR payload was being re-echoed as plaintext by the shared repeater, spamming every visible node with garbled "Archy-…: av�…fstatusfonline…" messages on channel 0. mesh.broadcast-presence RPC stays registered but no longer transmits. Re-enable only once presence moves off the shared broadcast path. Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't fail with "command channel already consumed"; MeshService.send_cmd helper; drop_message_by_id for control envelopes that shouldn't appear as Sent bubbles; self_advert_name reflected into MeshStatus after set; path_len/flags parsed out of RESP_CONTACT. Frontend: unified inbox merges mesh peers with federation nodes by DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/ contact_card from chat stream; publicChannel index → 1 to match the new DM-via-channel routing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,13 @@ export type MeshMessageTypeLabel =
|
||||
| 'content_ref'
|
||||
| 'reply'
|
||||
| 'reaction'
|
||||
| 'read_receipt'
|
||||
| 'forward'
|
||||
| 'edit'
|
||||
| 'delete'
|
||||
| 'presence'
|
||||
| 'channel_invite'
|
||||
| 'contact_card'
|
||||
|
||||
export interface MeshMessage {
|
||||
id: number
|
||||
|
||||
@@ -35,7 +35,13 @@ const mobileShowChat = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// The Public channel (always available on Meshcore)
|
||||
const publicChannel = { index: 0, name: 'Public' }
|
||||
// "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.
|
||||
const publicChannel = { index: 1, name: 'Public' }
|
||||
|
||||
// Channel contact_id convention: matches backend u32::MAX - channel_index
|
||||
function channelContactId(channelIndex: number): number {
|
||||
@@ -47,9 +53,78 @@ const archChannelActive = ref(false)
|
||||
const archMessages = ref<Array<{ from_pubkey: string; from_name?: string; message: string; timestamp: string; direction?: string }>>([])
|
||||
const archUnread = ref(0)
|
||||
let archPollInterval: ReturnType<typeof setInterval> | null = null
|
||||
// Federation node name cache: pubkey -> node name
|
||||
// Federation node name cache: pubkey -> node name (legacy, kept for archMessages display)
|
||||
const fedNodeNames = ref<Record<string, string>>({})
|
||||
|
||||
// Federation node enrichment cache, keyed by DID. Used by mergedPeers to
|
||||
// upgrade radio-discovered mesh peers with the canonical server name and
|
||||
// nostr identity (npub) reported by federation.list-nodes.
|
||||
interface FedNodeInfo {
|
||||
did: string
|
||||
name: string | null
|
||||
pubkey: string
|
||||
onion: string
|
||||
npub: string | null
|
||||
}
|
||||
const fedNodesByDid = ref<Map<string, FedNodeInfo>>(new Map())
|
||||
// Our own onion / DID / mesh advert name — used to filter "self" out of
|
||||
// the merged peer list. Without these filters every node sees itself as a
|
||||
// duplicate row (federation lists carry a self-entry, and the meshcore
|
||||
// radio occasionally surfaces its own outgoing advert as a peer).
|
||||
const selfTorOnion = ref<string | null>(null)
|
||||
const selfDid = ref<string | null>(null)
|
||||
|
||||
// User-set aliases for peers, keyed by whichever identifier is most stable
|
||||
// for that peer (DID first, then mesh pubkey_hex, then federation pubkey).
|
||||
// Loaded from `mesh.contacts-list` on mount and refreshed on every save so
|
||||
// the rename propagates everywhere display_name is computed.
|
||||
const contactAliases = ref<Map<string, string>>(new Map())
|
||||
async function refreshContacts() {
|
||||
try {
|
||||
const res = await rpcClient.meshContactsList()
|
||||
const next = new Map<string, string>()
|
||||
for (const c of res.contacts) {
|
||||
if (c.alias && c.alias.trim()) next.set(c.pubkey, c.alias.trim())
|
||||
}
|
||||
contactAliases.value = next
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
function aliasFor(mp: { did: string | null; primary_pubkey_hex: string | null }): string | null {
|
||||
if (mp.did && contactAliases.value.has(mp.did)) return contactAliases.value.get(mp.did) ?? null
|
||||
if (mp.primary_pubkey_hex && contactAliases.value.has(mp.primary_pubkey_hex)) {
|
||||
return contactAliases.value.get(mp.primary_pubkey_hex) ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
async function refreshFederationNodes() {
|
||||
try {
|
||||
const res = await rpcClient.federationListNodes()
|
||||
const next = new Map<string, FedNodeInfo>()
|
||||
for (const n of res.nodes) {
|
||||
next.set(n.did, {
|
||||
did: n.did,
|
||||
name: n.name ?? n.last_state?.node_name ?? null,
|
||||
pubkey: n.pubkey,
|
||||
onion: n.onion,
|
||||
npub: n.last_state?.nostr_npub ?? null,
|
||||
})
|
||||
}
|
||||
fedNodesByDid.value = next
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
async function refreshSelfOnion() {
|
||||
try {
|
||||
const res = await rpcClient.getTorAddress()
|
||||
selfTorOnion.value = (res.tor_address ?? '').replace(/\.onion\/?$/, '').replace(/^https?:\/\//, '')
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
async function refreshSelfDid() {
|
||||
try {
|
||||
const res = await rpcClient.getNodeDid()
|
||||
selfDid.value = res.did || null
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function openArchChannel() {
|
||||
activeChatPeer.value = null
|
||||
activeChatChannel.value = null
|
||||
@@ -193,12 +268,6 @@ watch(() => activeChatPeer.value, async (peer) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Fire a read receipt whenever a new received message for the active peer lands.
|
||||
watch(
|
||||
() => chatMessages.value.length,
|
||||
() => { scheduleReadReceipt() },
|
||||
)
|
||||
|
||||
async function handleToggleOffGrid() {
|
||||
togglingOffGrid.value = true
|
||||
try {
|
||||
@@ -211,7 +280,7 @@ onMounted(async () => {
|
||||
document.addEventListener('click', handleDocClickForMenu)
|
||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
loadPendingFromSession()
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
|
||||
refreshOutboxCount()
|
||||
// Start background polling for Archipelago (Tor) messages so unread count works
|
||||
loadArchMessages()
|
||||
@@ -239,6 +308,7 @@ onUnmounted(() => {
|
||||
const activeChatName = computed(() => {
|
||||
if (archChannelActive.value) return 'Archipelago'
|
||||
if (activeChatChannel.value) return activeChatChannel.value.name
|
||||
if (activeMergedPeer.value) return activeMergedPeer.value.display_name
|
||||
if (activeChatPeer.value) return activeChatPeer.value.advert_name
|
||||
return ''
|
||||
})
|
||||
@@ -246,6 +316,14 @@ const activeChatName = computed(() => {
|
||||
const activeChatSub = computed(() => {
|
||||
if (archChannelActive.value) return 'All nodes over Tor'
|
||||
if (activeChatChannel.value) return 'Mesh radio'
|
||||
const merged = activeMergedPeer.value
|
||||
if (merged) {
|
||||
const parts: string[] = []
|
||||
if (merged.short_did) parts.push(merged.short_did)
|
||||
else if (merged.primary_pubkey_hex) parts.push(truncatePubkey(merged.primary_pubkey_hex))
|
||||
if (merged.npub) parts.push(`${merged.npub.slice(0, 12)}…${merged.npub.slice(-6)}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (activeChatPeer.value) return truncatePubkey(activeChatPeer.value.pubkey_hex)
|
||||
return ''
|
||||
})
|
||||
@@ -284,20 +362,37 @@ const chatMessages = computed(() => {
|
||||
return mm
|
||||
})
|
||||
}
|
||||
// Reactions are auxiliary — they render as chips under their target
|
||||
// bubble, not as standalone chat stream entries.
|
||||
const hideReactions = (m: MeshMessage) => m.message_type !== 'reaction'
|
||||
// Hide control envelopes that aren't supposed to show as their own
|
||||
// bubbles: reactions (rendered as chips under the target), read receipts
|
||||
// (drive the ✓ ticks on outgoing bubbles), edits (mutate the target in
|
||||
// place), presence heartbeats, and any other auxiliary metadata. Without
|
||||
// this filter every receipt and edit appears as a stray bubble in the
|
||||
// chat history. Defense in depth — backend now also drops these on send.
|
||||
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))
|
||||
}
|
||||
if (activeChatPeer.value) {
|
||||
const cid = activeChatPeer.value.contact_id
|
||||
return mesh.messages.filter(m => m.peer_contact_id === cid && hideReactions(m))
|
||||
// Pull from every underlying contact_id in the merged group so radio
|
||||
// and federation-routed messages from the same node land in one thread.
|
||||
const merged = activeMergedPeer.value
|
||||
const cids = merged ? new Set(merged.contact_ids) : new Set([activeChatPeer.value.contact_id])
|
||||
return mesh.messages
|
||||
.filter(m => cids.has(m.peer_contact_id) && hideReactions(m))
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Fire a read receipt whenever a new received message for the active peer lands.
|
||||
// Declared after chatMessages so the watch getter doesn't hit a TDZ on registration.
|
||||
watch(
|
||||
() => chatMessages.value.length,
|
||||
() => { scheduleReadReceipt() },
|
||||
)
|
||||
|
||||
function isArchyNode(peer: MeshPeer): boolean {
|
||||
return peer.advert_name.startsWith('Archy-')
|
||||
}
|
||||
@@ -328,6 +423,261 @@ const sortedPeers = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Telegram-style unification: a single archipelago node may be discovered
|
||||
// twice — once as a LoRa contact (firmware-issued mesh pubkey, no DID) and
|
||||
// again as a synthetic federation peer (archipelago pubkey + DID) created
|
||||
// the first time it routes a typed message over Tor. Group them into one
|
||||
// row so the user sees their attachments and radio chat in the same thread.
|
||||
//
|
||||
// Merge key resolution, in priority order:
|
||||
// 1. peer.did (federation-injected peers always carry it)
|
||||
// 2. federation node whose pubkey matches peer.pubkey_hex
|
||||
// 3. federation node whose name (case-folded) matches peer.advert_name
|
||||
// 4. fall back to the peer's own pubkey_hex / advert_name (no merge)
|
||||
interface MergedPeer {
|
||||
key: string
|
||||
did: string | null
|
||||
display_name: string
|
||||
short_did: string | null
|
||||
npub: string | null
|
||||
contact_ids: number[]
|
||||
primary_contact_id: number
|
||||
primary_pubkey_hex: string | null
|
||||
primary_rssi: number | null
|
||||
is_archy: boolean
|
||||
// The original active-chat marker uses contact_id equality, so keep a
|
||||
// representative MeshPeer for the rest of the codepaths that still want
|
||||
// a single object (peer header rssi, prekey rotation, etc).
|
||||
primary: MeshPeer
|
||||
}
|
||||
|
||||
// Extract the did:key suffix prefix that meshcore embeds in archipelago
|
||||
// adverts. Meshcore firmware names archipelago nodes "Archy-{first 8 chars
|
||||
// of the node's did:key z-suffix}", e.g. "Archy-z6Mkn9RY". This lets us
|
||||
// link a LoRa-discovered radio peer (no DID, no archipelago pubkey) back
|
||||
// to its federation entry by matching that 8-char prefix against any
|
||||
// federation node whose DID starts the same way.
|
||||
function archyAdvertDidPrefix(advertName: string): string | null {
|
||||
if (!advertName.startsWith('Archy-')) return null
|
||||
const suffix = advertName.slice(6)
|
||||
if (suffix.length < 6) return null
|
||||
return suffix
|
||||
}
|
||||
|
||||
function fedDidKeySuffix(did: string): string | null {
|
||||
// did:key:z6Mk... → z6Mk...
|
||||
const idx = did.indexOf(':key:')
|
||||
return idx >= 0 ? did.slice(idx + 5) : null
|
||||
}
|
||||
|
||||
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
|
||||
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
|
||||
// pubkey cross-ref: a federation node may share the archipelago pubkey
|
||||
// with this radio peer if it's the same physical node (rare today, since
|
||||
// mesh and federation use different ed25519 keys, but kept for robustness)
|
||||
if (peer.pubkey_hex) {
|
||||
for (const fed of fedNodesByDid.value.values()) {
|
||||
if (fed.pubkey === peer.pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
|
||||
}
|
||||
}
|
||||
// did:key prefix cross-ref: meshcore "Archy-z6MkXXXX" → federation
|
||||
// node whose did:key suffix starts with the same chars. This is the
|
||||
// hot path for archipelago nodes — radio peers carry no DID/pubkey
|
||||
// we can use, but the firmware bakes the prefix into the advert.
|
||||
const advertPrefix = archyAdvertDidPrefix(peer.advert_name)
|
||||
if (advertPrefix) {
|
||||
for (const fed of fedNodesByDid.value.values()) {
|
||||
const fedSuffix = fedDidKeySuffix(fed.did)
|
||||
if (fedSuffix && fedSuffix.startsWith(advertPrefix)) {
|
||||
return { key: `did:${fed.did}`, matchedFed: fed }
|
||||
}
|
||||
}
|
||||
}
|
||||
// name cross-ref: a federation node whose name (case-folded) matches
|
||||
// the LoRa advert name. Last-resort match for non-archipelago meshcore
|
||||
// devices configured to advertise their server name verbatim.
|
||||
const norm = peer.advert_name.trim().toLowerCase()
|
||||
if (norm) {
|
||||
for (const fed of fedNodesByDid.value.values()) {
|
||||
const fedName = (fed.name ?? '').trim().toLowerCase()
|
||||
if (fedName && fedName === norm) return { key: `did:${fed.did}`, matchedFed: fed }
|
||||
}
|
||||
}
|
||||
return { key: `mesh:${peer.pubkey_hex || peer.advert_name || peer.contact_id}`, matchedFed: null }
|
||||
}
|
||||
|
||||
function shortDid(did: string): string {
|
||||
// did:archy:<hex64> → did:archy:abcd…wxyz. Other DID methods get truncated
|
||||
// generically.
|
||||
const idx = did.lastIndexOf(':')
|
||||
if (idx > 0 && did.length - idx > 14) {
|
||||
const prefix = did.slice(0, idx + 1)
|
||||
const id = did.slice(idx + 1)
|
||||
return `${prefix}${id.slice(0, 6)}…${id.slice(-4)}`
|
||||
}
|
||||
return did.length > 24 ? `${did.slice(0, 12)}…${did.slice(-6)}` : did
|
||||
}
|
||||
|
||||
function isSelfRadioPeer(peer: MeshPeer): boolean {
|
||||
const selfName = mesh.status?.self_advert_name
|
||||
if (selfName && peer.advert_name === selfName) return true
|
||||
// Primary fallback: derive the expected advert prefix from our own DID
|
||||
// and match it against the radio peer's "Archy-XXXXXXXX" prefix. This
|
||||
// works even before mesh.status.self_advert_name is populated and even
|
||||
// when there's no federation self-entry to cross-reference.
|
||||
const advertPrefix = archyAdvertDidPrefix(peer.advert_name)
|
||||
if (advertPrefix && selfDid.value) {
|
||||
const ourSuffix = fedDidKeySuffix(selfDid.value)
|
||||
if (ourSuffix?.startsWith(advertPrefix)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const mergedPeers = computed<MergedPeer[]>(() => {
|
||||
const groups = new Map<string, MergedPeer>()
|
||||
for (const peer of sortedPeers.value) {
|
||||
if (isSelfRadioPeer(peer)) continue
|
||||
const { key, matchedFed } = mergeKeyForPeer(peer)
|
||||
const existing = groups.get(key)
|
||||
if (existing) {
|
||||
existing.contact_ids.push(peer.contact_id)
|
||||
// Prefer a federation-enriched display name even if the second peer
|
||||
// is the radio one — keeps the row stable across discovery order.
|
||||
if (matchedFed?.name && existing.display_name === existing.primary.advert_name) {
|
||||
existing.display_name = matchedFed.name
|
||||
}
|
||||
if (matchedFed?.npub && !existing.npub) existing.npub = matchedFed.npub
|
||||
if (matchedFed?.did && !existing.did) {
|
||||
existing.did = matchedFed.did
|
||||
existing.short_did = shortDid(matchedFed.did)
|
||||
}
|
||||
} else {
|
||||
const did = peer.did ?? matchedFed?.did ?? null
|
||||
const stub = { did, primary_pubkey_hex: peer.pubkey_hex }
|
||||
const alias = aliasFor(stub)
|
||||
groups.set(key, {
|
||||
key,
|
||||
did,
|
||||
display_name: alias || matchedFed?.name || peer.advert_name || `Node #${peer.contact_id}`,
|
||||
short_did: did ? shortDid(did) : null,
|
||||
npub: matchedFed?.npub ?? null,
|
||||
contact_ids: [peer.contact_id],
|
||||
primary_contact_id: peer.contact_id,
|
||||
primary_pubkey_hex: peer.pubkey_hex,
|
||||
primary_rssi: peer.rssi,
|
||||
is_archy: isArchyNode(peer) || !!matchedFed,
|
||||
primary: peer,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Surface every federation node as its own row even if no radio peer has
|
||||
// been matched against it yet. The user always sees the canonical server
|
||||
// names ("Arch Dev", "ArchISO") in the list, and clicking one starts a
|
||||
// chat that routes over Tor until a radio link is also discovered, at
|
||||
// which point the rows transparently merge under the same DID key.
|
||||
for (const fed of fedNodesByDid.value.values()) {
|
||||
// Skip our own federation self-entry — every node accidentally has one
|
||||
// because the federation list isn't filtered server-side. Match by
|
||||
// onion instead of DID so it works even when names are missing.
|
||||
if (selfTorOnion.value) {
|
||||
const ours = selfTorOnion.value.replace(/\.onion$/, '')
|
||||
const theirs = fed.onion.replace(/\.onion$/, '')
|
||||
if (ours === theirs) continue
|
||||
}
|
||||
const key = `did:${fed.did}`
|
||||
if (groups.has(key)) continue
|
||||
// Synthesise a placeholder MeshPeer so openChat() and the existing
|
||||
// rssi/avatar template paths don't need a separate code path for
|
||||
// "federation-only" rows. The contact_id is a stable negative number
|
||||
// derived from the DID hash so it never collides with real mesh
|
||||
// contact_ids (which are u32 from the radio firmware).
|
||||
const synthCid = -100 - Math.abs(hashStringToInt(fed.did))
|
||||
const placeholder: MeshPeer = {
|
||||
contact_id: synthCid,
|
||||
advert_name: fed.name || fed.did,
|
||||
pubkey_hex: fed.pubkey,
|
||||
did: fed.did,
|
||||
rssi: null,
|
||||
hops: null,
|
||||
last_heard: null,
|
||||
} as unknown as MeshPeer
|
||||
const alias = aliasFor({ did: fed.did, primary_pubkey_hex: fed.pubkey })
|
||||
groups.set(key, {
|
||||
key,
|
||||
did: fed.did,
|
||||
display_name: alias || fed.name || shortDid(fed.did),
|
||||
short_did: shortDid(fed.did),
|
||||
npub: fed.npub,
|
||||
contact_ids: [synthCid],
|
||||
primary_contact_id: synthCid,
|
||||
primary_pubkey_hex: fed.pubkey,
|
||||
primary_rssi: null,
|
||||
is_archy: true,
|
||||
primary: placeholder,
|
||||
})
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
|
||||
function hashStringToInt(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
||||
return h
|
||||
}
|
||||
|
||||
// activeChatPeer is a single MeshPeer (the row the user clicked). To unify
|
||||
// the chat we need the merged group it belongs to so chatMessages can pull
|
||||
// from every underlying contact_id, not just the one that was clicked.
|
||||
const activeMergedPeer = computed<MergedPeer | null>(() => {
|
||||
const peer = activeChatPeer.value
|
||||
if (!peer) return null
|
||||
return mergedPeers.value.find(mp => mp.contact_ids.includes(peer.contact_id)) ?? null
|
||||
})
|
||||
|
||||
function mergedUnreadCount(mp: MergedPeer): number {
|
||||
let total = 0
|
||||
for (const cid of mp.contact_ids) total += mesh.unreadCounts[cid] || 0
|
||||
return total
|
||||
}
|
||||
|
||||
// Inline contact rename in the chat header. The pencil button toggles an
|
||||
// input bound to renameDraft; commit fires mesh.contacts-save keyed by
|
||||
// DID (or pubkey hex as fallback) so the alias propagates everywhere
|
||||
// `aliasFor` is consulted in the merged peer list.
|
||||
const renamingActive = ref(false)
|
||||
const renameDraft = ref('')
|
||||
const renameInputEl = ref<HTMLInputElement | null>(null)
|
||||
function startRename() {
|
||||
const mp = activeMergedPeer.value
|
||||
if (!mp) return
|
||||
renameDraft.value = mp.display_name
|
||||
renamingActive.value = true
|
||||
nextTick(() => renameInputEl.value?.focus())
|
||||
}
|
||||
function cancelRename() {
|
||||
renamingActive.value = false
|
||||
renameDraft.value = ''
|
||||
}
|
||||
async function commitRename() {
|
||||
if (!renamingActive.value) return
|
||||
const mp = activeMergedPeer.value
|
||||
if (!mp) { cancelRename(); return }
|
||||
const next = renameDraft.value.trim()
|
||||
renamingActive.value = false
|
||||
// Empty string → clear the alias and fall back to derived name.
|
||||
const key = mp.did || mp.primary_pubkey_hex
|
||||
if (!key) return
|
||||
// Optimistic local update so the header changes immediately.
|
||||
if (next) contactAliases.value.set(key, next)
|
||||
else contactAliases.value.delete(key)
|
||||
try {
|
||||
await rpcClient.meshContactsSave(key, next || null)
|
||||
await refreshContacts()
|
||||
} catch (e) {
|
||||
sendError.value = e instanceof Error ? e.message : 'Rename failed'
|
||||
}
|
||||
}
|
||||
|
||||
function openChat(peer: MeshPeer) {
|
||||
activeChatPeer.value = peer
|
||||
activeChatChannel.value = null
|
||||
@@ -594,6 +944,7 @@ function scheduleReadReceipt() {
|
||||
const received = chatMessages.value.filter((m) => m.direction === 'received' && m.sender_seq != null)
|
||||
if (received.length === 0) return
|
||||
const latest = received[received.length - 1]
|
||||
if (!latest) return
|
||||
const latestSeq = latest.sender_seq as number
|
||||
const already = lastReceiptSentForSeq.value.get(peer.contact_id) ?? -1
|
||||
if (latestSeq <= already) return
|
||||
@@ -978,31 +1329,35 @@ function isImageMime(mime?: string): boolean {
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
v-for="peer in sortedPeers" :key="peer.contact_id"
|
||||
v-for="mp in mergedPeers" :key="mp.key"
|
||||
class="mesh-peer-row"
|
||||
:class="{ active: activeChatPeer?.contact_id === peer.contact_id, 'is-archy': isArchyNode(peer) }"
|
||||
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="openChat(peer)"
|
||||
@keydown.enter="openChat(peer)"
|
||||
@click="openChat(mp.primary)"
|
||||
@keydown.enter="openChat(mp.primary)"
|
||||
>
|
||||
<div class="mesh-peer-avatar" :class="{ archy: isArchyNode(peer) }">
|
||||
<AnimatedLogo v-if="isArchyNode(peer)" size="sm" />
|
||||
<template v-else>{{ peer.advert_name.charAt(0).toUpperCase() }}</template>
|
||||
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
|
||||
<AnimatedLogo v-if="mp.is_archy" size="sm" />
|
||||
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
|
||||
</div>
|
||||
<div class="mesh-peer-info">
|
||||
<div class="mesh-peer-name">
|
||||
{{ peer.advert_name || `Node #${peer.contact_id}` }}
|
||||
<span v-if="isArchyNode(peer)" class="mesh-peer-archy-badge">Archy</span>
|
||||
{{ mp.display_name }}
|
||||
<span v-if="mp.is_archy" class="mesh-peer-archy-badge">Archy</span>
|
||||
</div>
|
||||
<div class="mesh-peer-sub">{{ truncatePubkey(peer.pubkey_hex) }}</div>
|
||||
<div class="mesh-peer-sub">
|
||||
<template v-if="mp.short_did">{{ mp.short_did }}</template>
|
||||
<template v-else>{{ truncatePubkey(mp.primary_pubkey_hex) }}</template>
|
||||
</div>
|
||||
<div v-if="mp.npub" class="mesh-peer-sub mesh-peer-npub">{{ mp.npub.slice(0, 12) }}…{{ mp.npub.slice(-6) }}</div>
|
||||
</div>
|
||||
<span v-if="mesh.unreadCounts[peer.contact_id]" class="mesh-unread-badge">
|
||||
{{ mesh.unreadCounts[peer.contact_id] }}
|
||||
<span v-if="mergedUnreadCount(mp)" class="mesh-unread-badge">
|
||||
{{ mergedUnreadCount(mp) }}
|
||||
</span>
|
||||
<div class="mesh-peer-signal">
|
||||
<div class="mesh-signal-bars">
|
||||
<div v-for="i in 4" :key="i" class="mesh-signal-bar" :class="{ active: i <= signalBars(peer.rssi) }" />
|
||||
<div v-for="i in 4" :key="i" class="mesh-signal-bar" :class="{ active: i <= signalBars(mp.primary_rssi) }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1041,7 +1396,26 @@ function isImageMime(mime?: string): boolean {
|
||||
<button class="mesh-chat-back" @click="closeChat">←</button>
|
||||
<div class="mesh-chat-header-info">
|
||||
<div class="mesh-chat-header-name">
|
||||
{{ activeChatName }}
|
||||
<template v-if="renamingActive">
|
||||
<input
|
||||
ref="renameInputEl"
|
||||
v-model="renameDraft"
|
||||
class="mesh-chat-header-rename-input"
|
||||
:placeholder="activeChatName"
|
||||
@keyup.enter="commitRename"
|
||||
@keyup.esc="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ activeChatName }}
|
||||
<button
|
||||
v-if="activeMergedPeer"
|
||||
class="mesh-chat-header-rename"
|
||||
title="Rename this contact"
|
||||
@click="startRename"
|
||||
>✎</button>
|
||||
</template>
|
||||
<span v-if="activeChatPeer && isArchyNode(activeChatPeer)" class="mesh-peer-archy-badge">Archy</span>
|
||||
<span v-if="activeChatChannel" class="mesh-peer-channel-badge">Channel</span>
|
||||
</div>
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; }
|
||||
.mesh-chat-header-info { flex: 1; min-width: 0; }
|
||||
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
|
||||
.mesh-chat-header-rename:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-chat-header-rename-input { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 6px; color: rgba(255, 255, 255, 0.95); font-size: 0.95rem; font-weight: 600; padding: 4px 8px; outline: none; min-width: 0; max-width: 220px; }
|
||||
.mesh-chat-header-rename-input:focus { border-color: rgba(255, 255, 255, 0.4); }
|
||||
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
|
||||
.mesh-chat-header-status { flex-shrink: 0; }
|
||||
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
|
||||
|
||||
Reference in New Issue
Block a user