feat(mesh-ui): Telegram-style action menu + Forward/Edit/Delete/ReadReceipt/rotate/outbox

* Replaces click-anywhere-on-bubble with a tiny ⋯ trigger in the meta row
  that fades in on hover (always visible on touch devices). Outside-click
  closes the menu, bubble gets a `menu-open` class so the trigger stays lit.
* Action menu gains Forward (any message) + Edit + Delete (own messages
  only, delete is red). Reaction spinner + reply preview upgraded to handle
  typed targets (attachment/invoice/location/alert) via summarizeForPreview.
* Pending-edit banner with ✎ icon mirrors the reply banner; Send flushes as
  mesh.edit-message when pendingEdit is set.
* Forwarded bubbles render "↪ Forwarded from {orig_name}" header; tombstone
  + (edited) markers; pending-reply close button upsized (28px, red hover).
* Scroll + message-arrival watcher fires a debounced 400ms read receipt
  with per-peer seq dedup so we never double-ack.
* Chat header: ⟲ rotate-prekeys button next to the shield badge; 📤 outbox
  count when mesh.outbox reports queued messages. Blob-store test widget
  removed and chat list now sorts by most-recent message timestamp.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-13 18:50:08 -04:00
parent 8ef7af985d
commit bdacc06a2b
3 changed files with 345 additions and 104 deletions

View File

@ -428,6 +428,69 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function getOutbox() {
try {
return await rpcClient.call<{ count: number; messages?: unknown[] }>({ method: 'mesh.outbox' })
} catch {
return { count: 0 }
}
}
async function sendReadReceipt(contactId: number, targetPubkey: string, targetSeq: number) {
try {
const res = await rpcClient.call<{ sent: boolean }>({
method: 'mesh.send-read-receipt',
params: { contact_id: contactId, target_pubkey: targetPubkey, target_seq: targetSeq },
})
return res
} catch {
// Read receipts are best-effort — never surface errors to the user.
return null
}
}
async function forwardMessage(contactId: number, sourceMessageId: number) {
sending.value = true
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.forward-message',
params: { contact_id: contactId, source_message_id: sourceMessageId },
})
if (res.sent) await fetchMessages()
return res
} finally {
sending.value = false
}
}
async function editMessage(contactId: number, targetSeq: number, newText: string) {
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.edit-message',
params: { contact_id: contactId, target_seq: targetSeq, new_text: newText },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to edit message'
throw err
}
}
async function deleteMessage(contactId: number, targetSeq: number) {
try {
const res = await rpcClient.call<{ sent: boolean; message_id: number }>({
method: 'mesh.delete-message',
params: { contact_id: contactId, target_seq: targetSeq },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to delete message'
throw err
}
}
async function fetchContent(params: {
cid: string
sender_onion: string
@ -566,6 +629,11 @@ export const useMeshStore = defineStore('mesh', () => {
fetchContent,
sendReply,
sendReaction,
getOutbox,
sendReadReceipt,
forwardMessage,
editMessage,
deleteMessage,
getSessionStatus,
rotatePrekeys,
getNodePositions,

View File

@ -129,6 +129,30 @@ async function sendArchMessage() {
const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
const rotatingPrekeys = ref(false)
const outboxCount = ref(0)
async function handleRotatePrekeys() {
if (rotatingPrekeys.value) return
rotatingPrekeys.value = true
try {
await mesh.rotatePrekeys()
if (activeChatPeer.value) {
peerSessionInfo.value = await mesh.getSessionStatus(activeChatPeer.value.contact_id)
}
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'rotate failed'
} finally {
rotatingPrekeys.value = false
}
}
async function refreshOutboxCount() {
try {
const resp = await mesh.getOutbox()
outboxCount.value = resp?.count ?? 0
} catch {
outboxCount.value = 0
}
}
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat')
@ -163,11 +187,18 @@ watch(() => activeChatPeer.value, async (peer) => {
} catch {
peerSessionInfo.value = null
}
scheduleReadReceipt()
} else {
peerSessionInfo.value = null
}
})
// 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 {
@ -177,9 +208,11 @@ async function handleToggleOffGrid() {
onMounted(async () => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleDocClickForMenu)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
refreshOutboxCount()
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
if (!archPollInterval) {
@ -196,6 +229,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleDocClickForMenu)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
@ -268,8 +302,25 @@ function isArchyNode(peer: MeshPeer): boolean {
return peer.advert_name.startsWith('Archy-')
}
/// Build a contact_id latest-message-timestamp map once per render so
/// we can sort the chat list by recency (freshest thread at the top,
/// Telegram-style). Empty threads keep their lexicographic fallback.
const lastMessageAt = computed<Map<number, number>>(() => {
const out = new Map<number, number>()
for (const m of mesh.messages) {
const ts = Date.parse(m.timestamp)
if (Number.isNaN(ts)) continue
const prev = out.get(m.peer_contact_id)
if (prev === undefined || ts > prev) out.set(m.peer_contact_id, ts)
}
return out
})
const sortedPeers = computed(() => {
return [...mesh.peers].sort((a, b) => {
const aTs = lastMessageAt.value.get(a.contact_id) ?? 0
const bTs = lastMessageAt.value.get(b.contact_id) ?? 0
if (aTs !== bTs) return bTs - aTs
const aArchy = isArchyNode(a) ? 0 : 1
const bArchy = isArchyNode(b) ? 0 : 1
if (aArchy !== bArchy) return aArchy - bArchy
@ -319,6 +370,24 @@ async function handleSendMessage() {
// Pending reply: Send flushes as mesh.send-reply targeting the stashed
// MessageKey. Takes precedence over a pending attachment we don't try
// to express "attach-as-reply" in one go.
// Pending edit: Send flushes as mesh.edit-message against the stashed seq.
if (pendingEdit.value && activeChatPeer.value) {
if (!messageText.value.trim()) return
sendError.value = ''
try {
await mesh.editMessage(
activeChatPeer.value.contact_id,
pendingEdit.value.target_seq,
messageText.value.trim(),
)
messageText.value = ''
pendingEdit.value = null
nextTick(() => scrollChatToBottom())
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Edit failed'
}
return
}
if (pendingReply.value && activeChatPeer.value) {
if (!messageText.value.trim()) return
sendError.value = ''
@ -422,55 +491,6 @@ function truncatePubkey(hex: string | null): string {
return hex.slice(0, 8) + '...' + hex.slice(-6)
}
// Blob store test (Phase 3a)
// Minimal widget to exercise POST /api/blob + GET /blob/<cid> with a
// self-signed capability. Validates the round-trip before we wire
// ContentRef typed-envelope sending.
interface BlobUploadResult {
cid: string
size: number
mime: string
filename: string | null
self_test_url: string
}
const blobUploading = ref(false)
const blobResult = ref<BlobUploadResult | null>(null)
const blobError = ref<string | null>(null)
const blobVerifyStatus = ref<string | null>(null)
async function handleBlobUpload(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
blobUploading.value = true
blobError.value = null
blobResult.value = null
blobVerifyStatus.value = null
try {
const buf = await file.arrayBuffer()
const resp = await fetch('/api/blob', {
method: 'POST',
headers: {
'X-Blob-Mime': file.type || 'application/octet-stream',
'X-Blob-Filename': file.name,
'Content-Type': 'application/octet-stream',
},
credentials: 'include',
body: buf,
})
if (!resp.ok) {
blobError.value = `upload failed: ${resp.status} ${await resp.text()}`
return
}
blobResult.value = await resp.json()
} catch (e) {
blobError.value = e instanceof Error ? e.message : 'upload failed'
} finally {
blobUploading.value = false
if (input) input.value = ''
}
}
// Reply + Reaction (Phase 2a)
// Pending reply state: when the user picks "Reply" on a bubble, we stash its
// MessageKey here; next Send uses mesh.send-reply instead of mesh.send.
@ -483,12 +503,21 @@ const pendingReply = ref<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(null)
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function openActionMenu(msgId: number) {
function openActionMenu(msgId: number, ev?: Event) {
ev?.stopPropagation()
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
}
function closeActionMenu() {
actionMenuForId.value = null
}
function handleDocClickForMenu(ev: MouseEvent) {
if (actionMenuForId.value === null) return
const target = ev.target as HTMLElement | null
if (!target) return
if (target.closest('.mesh-chat-action-menu')) return
if (target.closest('.mesh-chat-action-trigger')) return
actionMenuForId.value = null
}
function messageKeyFor(msg: { sender_pubkey?: string | null; sender_seq?: number | null }): { pubkey: string; seq: number } | null {
if (!msg.sender_pubkey || msg.sender_seq == null) return null
return { pubkey: msg.sender_pubkey, seq: msg.sender_seq }
@ -499,22 +528,95 @@ function startReplyTo(msg: MeshMessage) {
pendingReply.value = {
target_pubkey: key.pubkey,
target_seq: key.seq,
preview: msg.plaintext.slice(0, 80),
preview: summarizeForPreview(msg),
}
closeActionMenu()
}
function clearPendingReply() {
pendingReply.value = null
}
// Edit / Delete / Forward (Phase 2b)
// Pending edit: when the user picks "Edit" on an own message, we stash its
// sender_seq and prefill the composer. Next Send calls mesh.edit-message
// instead of mesh.send.
interface PendingEdit { target_seq: number; original_text: string }
const pendingEdit = ref<PendingEdit | null>(null)
function startEditOf(msg: MeshMessage) {
if (msg.direction !== 'sent' || msg.sender_seq == null) return
pendingEdit.value = { target_seq: msg.sender_seq, original_text: msg.plaintext }
messageText.value = msg.plaintext
closeActionMenu()
}
function clearPendingEdit() {
pendingEdit.value = null
messageText.value = ''
}
async function deleteOwnMessage(msg: MeshMessage) {
if (msg.direction !== 'sent' || msg.sender_seq == null || !activeChatPeer.value) return
if (!window.confirm('Delete this message? Peers already received it — this only marks it as deleted.')) return
try {
await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq)
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'delete failed'
} finally {
closeActionMenu()
}
}
async function forwardToCurrent(msg: MeshMessage) {
if (!activeChatPeer.value) return
try {
await mesh.forwardMessage(activeChatPeer.value.contact_id, msg.id)
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'forward failed'
} finally {
closeActionMenu()
}
}
function isEditedMessage(msg: MeshMessage): number | null {
const ts = msg.typed_payload?.edited_at
return typeof ts === 'number' ? ts : null
}
function isDeletedMessage(msg: MeshMessage): boolean {
return msg.message_type === 'delete' || msg.typed_payload?.deleted === true
}
// Read-receipt: after render, if the bottom message is from the peer (direction='received')
// and has a MessageKey, fire mesh.send-read-receipt up to that seq. Debounced so scroll
// doesn't spam the wire.
const lastReceiptSentForSeq = ref<Map<number, number>>(new Map()) // contactId last acked seq
let receiptDebounce: ReturnType<typeof setTimeout> | null = null
function scheduleReadReceipt() {
if (receiptDebounce) clearTimeout(receiptDebounce)
receiptDebounce = setTimeout(() => {
const peer = activeChatPeer.value
if (!peer) return
const received = chatMessages.value.filter((m) => m.direction === 'received' && m.sender_seq != null)
if (received.length === 0) return
const latest = received[received.length - 1]
const latestSeq = latest.sender_seq as number
const already = lastReceiptSentForSeq.value.get(peer.contact_id) ?? -1
if (latestSeq <= already) return
const pubkey = latest.sender_pubkey
if (!pubkey) return
lastReceiptSentForSeq.value.set(peer.contact_id, latestSeq)
void mesh.sendReadReceipt(peer.contact_id, pubkey, latestSeq)
}, 400)
}
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
async function reactTo(msg: MeshMessage, emoji: string) {
const key = messageKeyFor(msg)
if (!key || !activeChatPeer.value) return
const marker = `${msg.id}:${emoji}`
reactionInFlight.value = marker
try {
await mesh.sendReaction(activeChatPeer.value.contact_id, key.pubkey, key.seq, emoji)
closeActionMenu()
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'reaction failed'
} finally {
closeActionMenu()
if (reactionInFlight.value === marker) reactionInFlight.value = null
}
}
@ -565,7 +667,19 @@ function replyTargetPreview(msg: MeshMessage): string | null {
const match = mesh.messages.find(
(m) => m.sender_pubkey === target.sender_pubkey && m.sender_seq === target.sender_seq,
)
return match?.plaintext?.slice(0, 80) ?? `${String(target.sender_pubkey).slice(0, 8)}…#${target.sender_seq}`
if (!match) return `${String(target.sender_pubkey).slice(0, 8)}…#${target.sender_seq}`
return summarizeForPreview(match)
}
function summarizeForPreview(m: MeshMessage): string {
const text = m.plaintext?.trim()
if (text) return text.slice(0, 80)
switch (m.message_type) {
case 'content_ref': return `📎 ${m.typed_payload?.filename || m.typed_payload?.mime || 'attachment'}`
case 'invoice': return `${(m.typed_payload?.amount_sats || 0).toLocaleString()} sats`
case 'coordinate': return '📍 Location'
case 'alert': return `🚨 ${m.typed_payload?.message || 'Alert'}`.slice(0, 80)
default: return `(${m.message_type || 'message'})`
}
}
// share-to-mesh iframe intent (Phase 3c)
@ -709,26 +823,6 @@ function isImageMime(mime?: string): boolean {
return !!mime && mime.startsWith('image/')
}
async function verifyBlobRoundTrip() {
if (!blobResult.value) return
blobVerifyStatus.value = 'fetching...'
try {
const resp = await fetch(blobResult.value.self_test_url)
if (!resp.ok) {
blobVerifyStatus.value = `FAIL: ${resp.status} ${await resp.text()}`
return
}
const got = await resp.arrayBuffer()
const expected = blobResult.value.size
if (got.byteLength === expected) {
blobVerifyStatus.value = `OK — downloaded ${got.byteLength} bytes, CID verified`
} else {
blobVerifyStatus.value = `FAIL — got ${got.byteLength} bytes, expected ${expected}`
}
} catch (e) {
blobVerifyStatus.value = `FAIL: ${e instanceof Error ? e.message : 'unknown'}`
}
}
</script>
<template>
@ -955,10 +1049,12 @@ async function verifyBlobRoundTrip() {
</div>
<div class="mesh-chat-header-status">
<span v-if="activeChatPeer && peerSessionInfo" class="mesh-session-badge" :class="peerSessionInfo.forward_secrecy ? 'session-ratchet' : peerSessionInfo.has_session ? 'session-static' : 'session-none'" :title="peerSessionInfo.forward_secrecy ? 'Double Ratchet (forward secrecy)' : peerSessionInfo.has_session ? 'Static encryption' : 'No encryption'">&#x1F6E1;</span>
<button v-if="activeChatPeer && peerSessionInfo" class="mesh-session-rotate" :disabled="rotatingPrekeys" :title="'Rotate prekeys'" @click="handleRotatePrekeys">{{ rotatingPrekeys ? '' : '' }}</button>
<span v-if="outboxCount > 0" class="mesh-outbox-badge" :title="outboxCount + ' queued messages waiting for delivery'">📤 {{ outboxCount }}</span>
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div>
</div>
<div ref="chatScrollEl" class="mesh-chat-messages">
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello!
</div>
@ -967,7 +1063,7 @@ async function verifyBlobRoundTrip() {
class="mesh-chat-bubble-wrapper"
:class="msg.direction"
>
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '']" @click="openActionMenu(msg.id)">
<div class="mesh-chat-bubble" :class="[msg.direction, msg.message_type ? 'typed-' + msg.message_type : '', { 'menu-open': actionMenuForId === msg.id }]">
<div v-if="replyTargetPreview(msg)" class="mesh-chat-reply-quote">
{{ replyTargetPreview(msg) }}
</div>
@ -1078,12 +1174,27 @@ async function verifyBlobRoundTrip() {
</button>
</template>
</div>
<!-- Forwarded message -->
<div v-else-if="msg.message_type === 'forward' && msg.typed_payload" class="mesh-chat-bubble-text">
<div class="mesh-chat-forward-header"> Forwarded from {{ msg.typed_payload.orig_name || 'unknown' }}</div>
<div class="mesh-chat-forward-body">{{ msg.plaintext }}</div>
</div>
<!-- Deleted tombstone -->
<div v-else-if="isDeletedMessage(msg)" class="mesh-chat-bubble-text mesh-chat-deleted">🗑 message deleted</div>
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
<span class="mesh-chat-bubble-time">{{ timeAgo(msg.timestamp) }}</span>
<button
v-if="messageKeyFor(msg) && msg.message_type !== 'reaction'"
class="mesh-chat-action-trigger"
:class="{ active: actionMenuForId === msg.id }"
:title="actionMenuForId === msg.id ? 'Close' : 'React / Reply'"
@click.stop="openActionMenu(msg.id, $event)"
>&#x22EF;</button>
</div>
<div v-if="reactionsFor(msg).length > 0" class="mesh-chat-reactions">
<span
@ -1098,14 +1209,22 @@ async function verifyBlobRoundTrip() {
class="mesh-chat-action-menu"
@click.stop
>
<button class="mesh-chat-action-btn" @click="startReplyTo(msg)">Reply</button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startReplyTo(msg)">Reply</button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="forwardToCurrent(msg)">Forward</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
<button
v-for="emoji in QUICK_REACTIONS"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>{{ emoji }}</button>
<button class="mesh-chat-action-btn" @click="closeActionMenu"></button>
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button>
</div>
</div>
</div>
@ -1118,6 +1237,11 @@ async function verifyBlobRoundTrip() {
<span class="mesh-chat-pending-name">Replying to: {{ pendingReply.preview }}</span>
<button class="mesh-chat-pending-clear" @click="clearPendingReply" title="Cancel reply"></button>
</div>
<div v-if="pendingEdit" class="mesh-chat-pending-reply">
<span class="mesh-typed-icon"></span>
<span class="mesh-chat-pending-name">Editing: {{ pendingEdit.original_text }}</span>
<button class="mesh-chat-pending-clear" @click="clearPendingEdit" title="Cancel edit"></button>
</div>
<div v-if="pendingAttachment" class="mesh-chat-pending-attachment">
<span class="mesh-typed-icon">📎</span>
<span class="mesh-chat-pending-name">{{ pendingAttachment.filename || pendingAttachment.mime }}</span>
@ -1128,10 +1252,12 @@ async function verifyBlobRoundTrip() {
<label
v-if="activeChatPeer"
class="glass-button mesh-chat-attach-btn"
:class="{ 'is-busy': attaching }"
:title="attaching ? 'uploading…' : 'Attach file'"
>
<input type="file" @change="handleAttachFile" style="display:none;" :disabled="attaching" />
{{ attaching ? '…' : '📎' }}
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>📎</span>
</label>
<input
v-model="messageText"
@ -1198,31 +1324,6 @@ async function verifyBlobRoundTrip() {
</div>
</div>
<!-- Blob store round-trip test (Phase 3a) -->
<div class="glass-card" style="margin-top: 1rem; padding: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<strong>Attachment test (blob store)</strong>
<span style="font-size: 0.8rem; opacity: 0.7;">Phase 3a upload, self-signed cap, round-trip verify</span>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
<label class="btn" style="cursor: pointer;">
<input type="file" @change="handleBlobUpload" style="display: none;" />
Pick file
</label>
<span v-if="blobUploading">uploading</span>
<span v-if="blobError" style="color: #f87171;">{{ blobError }}</span>
</div>
<div v-if="blobResult" style="margin-top: 0.75rem; font-family: monospace; font-size: 0.85rem;">
<div><strong>cid:</strong> {{ blobResult.cid }}</div>
<div><strong>size:</strong> {{ blobResult.size }} &nbsp; <strong>mime:</strong> {{ blobResult.mime }} &nbsp; <strong>filename:</strong> {{ blobResult.filename || '(none)' }}</div>
<div style="word-break: break-all;"><strong>url:</strong> {{ blobResult.self_test_url }}</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.75rem; align-items: center;">
<button class="btn" @click="verifyBlobRoundTrip">Verify round-trip</button>
<a :href="blobResult.self_test_url" target="_blank" class="btn">Open in new tab</a>
<span v-if="blobVerifyStatus" :style="{ color: blobVerifyStatus.startsWith('OK') ? '#4ade80' : '#f87171' }">{{ blobVerifyStatus }}</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -138,6 +138,10 @@
}
.mesh-session-badge { font-size: 0.75rem; margin-right: 6px; opacity: 0.7; }
.mesh-session-rotate { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); font-size: 0.75rem; line-height: 1; padding: 2px 6px; margin-right: 8px; border-radius: 10px; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.mesh-session-rotate:hover:not(:disabled) { background: rgba(251,146,60,0.2); color: #fff; border-color: rgba(251,146,60,0.4); }
.mesh-session-rotate:disabled { opacity: 0.5; cursor: wait; }
.mesh-outbox-badge { font-size: 0.7rem; padding: 2px 7px; margin-right: 8px; border-radius: 10px; background: rgba(251,146,60,0.2); border: 1px solid rgba(251,146,60,0.4); color: #fff; }
.session-ratchet { color: #4ade80; opacity: 1; }
.session-static { color: #fbbf24; }
.session-none { color: rgba(255,255,255,0.3); }
@ -229,3 +233,71 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
/* Reaction chips and action menu (Phase 2a) */
.mesh-chat-reactions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.mesh-chat-reaction-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); font-size: 0.85rem; line-height: 1.1; }
.mesh-chat-reaction-chip.by-self { background: rgba(251,146,60,0.15); border-color: rgba(251,146,60,0.4); }
.mesh-chat-reaction-count { font-size: 0.7rem; color: rgba(255,255,255,0.55); font-weight: 600; }
.mesh-chat-action-menu { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 8px; padding: 8px 10px; border-radius: 10px; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.1); }
.mesh-chat-action-btn { background: transparent; border: none; color: rgba(255,255,255,0.75); font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; cursor: pointer; }
.mesh-chat-action-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
.mesh-chat-reaction-btn { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 1.15rem; line-height: 1; padding: 6px 10px; border-radius: 8px; cursor: pointer; transition: transform 0.1s ease, background 0.15s ease; }
.mesh-chat-reaction-btn:hover { background: rgba(251,146,60,0.2); transform: scale(1.1); }
.mesh-chat-action-danger { color: rgba(248, 113, 113, 0.9) !important; }
.mesh-chat-action-danger:hover { background: rgba(239,68,68,0.2) !important; color: #fff !important; }
.mesh-chat-forward-header { font-size: 0.75rem; color: rgba(251,146,60,0.85); font-style: italic; margin-bottom: 3px; }
.mesh-chat-forward-body { }
.mesh-chat-deleted { font-style: italic; opacity: 0.55; }
.mesh-chat-edited { font-size: 0.7rem; opacity: 0.55; font-style: italic; }
/* Telegram-style ⋯ action trigger: tiny, ghosted in the meta row, expands on hover or when menu is open */
.mesh-chat-action-trigger {
background: transparent;
border: none;
color: rgba(255,255,255,0.45);
font-size: 1rem;
line-height: 1;
padding: 2px 6px;
margin-left: 2px;
border-radius: 10px;
cursor: pointer;
opacity: 0;
transform: scale(0.85);
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.mesh-chat-bubble:hover .mesh-chat-action-trigger,
.mesh-chat-bubble.menu-open .mesh-chat-action-trigger,
.mesh-chat-action-trigger.active,
.mesh-chat-action-trigger:focus-visible {
opacity: 1;
transform: scale(1);
}
.mesh-chat-action-trigger:hover,
.mesh-chat-action-trigger.active {
background: rgba(255,255,255,0.1);
color: #fff;
}
@media (hover: none) {
.mesh-chat-action-trigger { opacity: 0.7; transform: scale(1); }
}
/* Generic inline spinner for busy buttons */
.mesh-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fb923c; border-radius: 50%; animation: mesh-spin 0.7s linear infinite; vertical-align: middle; }
@keyframes mesh-spin { to { transform: rotate(360deg); } }
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }
/* Reply / attachment pending banner */
.mesh-chat-pending-reply,
.mesh-chat-pending-attachment { display: flex; align-items: flex-start; gap: 8px; padding: 8px 12px; margin: 6px 0; border-radius: 10px; background: rgba(251,146,60,0.1); border: 1px solid rgba(251,146,60,0.25); font-size: 0.85rem; }
.mesh-chat-pending-reply .mesh-typed-icon,
.mesh-chat-pending-attachment .mesh-typed-icon { color: #fb923c; font-size: 1rem; line-height: 1.4; flex: 0 0 auto; }
.mesh-chat-pending-name { flex: 1 1 auto; min-width: 0; color: rgba(255,255,255,0.85); overflow-wrap: anywhere; word-break: break-word; line-height: 1.35; }
.mesh-chat-pending-size { flex: 0 0 auto; color: rgba(255,255,255,0.45); font-size: 0.75rem; margin-left: 4px; }
.mesh-chat-pending-clear { flex: 0 0 auto; align-self: center; margin-left: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.85); width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.95rem; line-height: 1; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
.mesh-chat-pending-clear:hover { background: rgba(239,68,68,0.3); color: #fff; border-color: rgba(239,68,68,0.6); transform: scale(1.08); }
.mesh-chat-pending-clear:active { transform: scale(0.92); }