From bdacc06a2b724f42a70e8ed9628e1c76bf8e4c6d Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 13 Apr 2026 18:50:08 -0400 Subject: [PATCH] feat(mesh-ui): Telegram-style action menu + Forward/Edit/Delete/ReadReceipt/rotate/outbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- neode-ui/src/stores/mesh.ts | 68 ++++++ neode-ui/src/views/Mesh.vue | 309 ++++++++++++++++-------- neode-ui/src/views/mesh/mesh-styles.css | 72 ++++++ 3 files changed, 345 insertions(+), 104 deletions(-) diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 5a4f0dd5..113c80c2 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -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, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 591b725e..d9c818f0 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -129,6 +129,30 @@ async function sendArchMessage() { const togglingOffGrid = ref(false) const peerSessionInfo = ref(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>(() => { + const out = new Map() + 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/ 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(null) -const blobError = ref(null) -const blobVerifyStatus = ref(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(null) const actionMenuForId = ref(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(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>(new Map()) // contactId โ†’ last acked seq +let receiptDebounce: ReturnType | 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(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'}` - } -} + +
+
โ†ช Forwarded from {{ msg.typed_payload.orig_name || 'unknown' }}
+
{{ msg.plaintext }}
+
+ +
๐Ÿ—‘ message deleted
{{ msg.plaintext }}
E2E + (edited) ✓✓ {{ timeAgo(msg.timestamp) }} +
- + + + + - + > + + {{ emoji }} + +
@@ -1118,6 +1237,11 @@ async function verifyBlobRoundTrip() { Replying to: {{ pendingReply.preview }} +
+ โœŽ + Editing: {{ pendingEdit.original_text }} + +
๐Ÿ“Ž {{ pendingAttachment.filename || pendingAttachment.mime }} @@ -1128,10 +1252,12 @@ async function verifyBlobRoundTrip() {
- -
-
- Attachment test (blob store) - Phase 3a โ€” upload, self-signed cap, round-trip verify -
-
- - uploadingโ€ฆ - {{ blobError }} -
-
-
cid: {{ blobResult.cid }}
-
size: {{ blobResult.size }}   mime: {{ blobResult.mime }}   filename: {{ blobResult.filename || '(none)' }}
-
url: {{ blobResult.self_test_url }}
-
- - Open in new tab - {{ blobVerifyStatus }} -
-
-
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index 476a8207..8540ede1 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -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); }