groupchat/client/src/components/message-bubble.riot
Jason Tudisco 2e1a0ac858 feat: add SHA-256 integrity hashes to messages with copy/link buttons
Add a hash column to messages table computed from SHA-256(created_at + content)
to ensure message integrity. Existing messages get backfilled during migration.
All messages now show copy and permalink buttons on hover, with hash-based
URL fragments that auto-scroll and highlight the target message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:11:03 -06:00

495 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<message-bubble>
<div class={'message ' + (props.message?.is_ai ? 'ai-message' : '') + (props.isOwn ? ' own-message' : '')}>
<div class="message-avatar-col">
<div class={'message-avatar ' + (props.message?.is_ai ? 'ai-avatar' : '')}>
<svg if={props.message?.is_ai} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<line x1="12" y1="2" x2="12" y2="8"/>
<circle cx="12" cy="2" r="1" fill="currentColor"/>
<circle cx="9" cy="14" r="1.5" fill="currentColor"/>
<circle cx="15" cy="14" r="1.5" fill="currentColor"/>
</svg>
<img if={!props.message?.is_ai}
src={getMessageAvatar(props.message)}
alt={props.message?.sender_name}
width="32"
height="32"
class="avatar-img"
loading="lazy"
/>
</div>
</div>
<div class="message-body">
<div class={'message-header ' + (props.isOwn ? 'own' : '')}>
<span class="sender-name">{props.message?.sender_name}</span>
<span class="message-time">{formatTime(props.message?.created_at)}</span>
</div>
<div if={hasToolResults()} class="tool-results-section">
<div each={tr in getToolResults()} class="tool-result-item">
<button class="tool-result-toggle" onclick={toggleToolResult}>
<span class="tool-result-icon">{tr.tool === 'brave_search' ? '🔍' : tr.tool === 'web_fetch' ? '🌐' : '⚙️'}</span>
<span class="tool-result-label">{tr.tool === 'brave_search' ? 'Search' : tr.tool === 'web_fetch' ? 'Fetched' : tr.tool}: {tr.input}</span>
<span class="tool-result-arrow">▼</span>
</button>
<div class="tool-result-body collapsed">
<pre class="tool-result-content">{tr.result}</pre>
</div>
</div>
</div>
<div if={props.message?.image_url} class="message-image-wrap">
<img src={props.message.image_url} alt="Attached image" class="message-image" loading="lazy" onclick={openImageFullscreen} />
</div>
<div if={props.isStreaming} class="message-content streaming-content">{props.message?.content}<span class="streaming-cursor">▌</span></div>
<div if={!props.isStreaming} class="message-content markdown-content"></div>
<div if={!props.isStreaming} class="message-actions-bar">
<button class="msg-action-btn" onclick={copyFullMessage} title="Copy message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button if={props.message?.hash} class="msg-action-btn" onclick={copyMessageLink} title="Copy link to message">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
<template if={props.message?.is_ai && props.message?.ai_meta}>
<span class="ai-stat-item model">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
{formatModel(props.message.ai_meta.model)}
</span>
<span class="ai-stat-item" title="Generation speed">
⚡ {calcSpeed(props.message.ai_meta)} tok/sec
</span>
<span class="ai-stat-item" title="Completion tokens">
🎯 {props.message.ai_meta.completion_tokens} tokens
</span>
<span class="ai-stat-item" title="Response time">
⏱ {(props.message.ai_meta.response_ms / 1000).toFixed(1)}s
</span>
</template>
</div>
</div>
</div>
<style>
.message {
display: flex;
gap: var(--space-sm);
max-width: 80%;
animation: fadeIn 0.2s ease;
}
.own-message {
margin-left: auto;
flex-direction: row-reverse;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.message-avatar-col {
flex-shrink: 0;
padding-top: 2px;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
object-fit: cover;
}
.ai-avatar {
background: var(--accent);
color: white;
}
.message-body {
min-width: 0;
}
.message-header {
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin-bottom: 2px;
}
.message-header.own {
justify-content: flex-end;
flex-direction: row-reverse;
}
.sender-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.ai-message .sender-name {
color: var(--accent);
}
.message-time {
font-size: var(--text-xs);
color: var(--text-muted);
}
.message-content {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
line-height: 1.6;
word-wrap: break-word;
}
.message:not(.own-message):not(.ai-message) .message-content {
background: var(--bg-tertiary);
border-top-left-radius: var(--radius-sm);
}
.own-message .message-content {
background: var(--accent);
color: white;
border-top-right-radius: var(--radius-sm);
}
.own-message .message-content :global(code) {
background: rgba(255, 255, 255, 0.15);
}
.own-message .message-content :global(a) {
color: white;
text-decoration: underline;
}
.ai-message .message-content {
background: var(--ai-bg);
border: 1px solid var(--ai-border);
border-top-left-radius: var(--radius-sm);
}
.tool-results-section {
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-result-item {
border-radius: var(--radius-md);
overflow: hidden;
}
.tool-result-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast);
text-align: left;
}
.tool-result-toggle:hover {
background: var(--bg-hover);
}
.tool-result-toggle.open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.tool-result-icon {
flex-shrink: 0;
}
.tool-result-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tool-result-arrow {
flex-shrink: 0;
font-size: 10px;
transition: transform 0.2s;
}
.tool-result-toggle.open .tool-result-arrow {
transform: rotate(180deg);
}
.tool-result-body {
border: 1px solid var(--border);
border-top: none;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
background: var(--bg-primary);
}
.tool-result-body.collapsed {
display: none;
}
.tool-result-content {
margin: 0;
padding: 10px 12px;
font-size: 11px;
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.message-actions-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: 4px;
padding: 4px var(--space-sm);
font-size: 11px;
color: var(--text-muted);
flex-wrap: wrap;
opacity: 0;
transition: opacity var(--transition-fast);
}
.message:hover .message-actions-bar,
.message-actions-bar:focus-within {
opacity: 1;
}
/* Always show for AI messages with stats */
.ai-message .message-actions-bar {
opacity: 1;
}
.msg-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
color: var(--text-muted);
padding: 3px;
border-radius: var(--radius-sm);
line-height: 1;
}
.msg-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.msg-action-btn.copied {
color: var(--success);
}
.ai-stat-item {
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
.ai-stat-item.model {
color: var(--accent);
font-weight: 500;
}
.message-image-wrap {
margin-bottom: 4px;
padding: 4px;
border-radius: var(--radius-md);
overflow: hidden;
}
.message-image {
max-width: 320px;
max-height: 280px;
border-radius: var(--radius-md);
cursor: pointer;
display: block;
object-fit: contain;
transition: opacity 0.15s;
}
.message-image:hover {
opacity: 0.9;
}
.streaming-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.streaming-cursor {
animation: cursor-blink 0.8s step-end infinite;
color: var(--accent);
font-weight: 300;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
<script>
import { renderMarkdown } from '../services/markdown.js'
import { avatarFromHash } from '../services/avatar.js'
export default {
/** Prefer custom avatar_url, fall back to Gravatar hash */
getMessageAvatar(msg) {
if (msg?.avatar_url) return msg.avatar_url
return avatarFromHash(msg?.avatar_hash, 32)
},
onMounted() {
this.renderContent()
},
onUpdated() {
this.renderContent()
},
renderContent() {
if (this.props.isStreaming) return // Don't markdown-render while streaming
const el = this.$('.message-content.markdown-content')
if (el && this.props.message?.content) {
el.innerHTML = renderMarkdown(this.props.message.content)
// Inject copy buttons into code blocks
el.querySelectorAll('pre').forEach((pre) => {
if (pre.querySelector('.code-copy-btn')) return
const btn = document.createElement('button')
btn.className = 'code-copy-btn'
btn.textContent = 'Copy'
btn.addEventListener('click', () => {
const code = pre.querySelector('code')
const text = code ? code.textContent : pre.textContent
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Copied!'
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = 'Copy'
btn.classList.remove('copied')
}, 2000)
})
})
pre.appendChild(btn)
})
}
},
formatTime(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
},
formatModel(model) {
if (!model) return 'unknown'
// "openai/gpt-4o" → "gpt-4o", "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet"
const parts = model.split('/')
return parts.length > 1 ? parts[parts.length - 1] : model
},
calcSpeed(meta) {
if (!meta || !meta.completion_tokens || !meta.response_ms) return ''
const seconds = meta.response_ms / 1000
if (seconds === 0) return ''
return (meta.completion_tokens / seconds).toFixed(1)
},
hasToolResults() {
const tr = this.props.message?.ai_meta?.tool_results
return tr && tr.length > 0
},
getToolResults() {
return this.props.message?.ai_meta?.tool_results || []
},
toggleToolResult(e) {
const toggle = e.currentTarget
const body = toggle.nextElementSibling
const isOpen = toggle.classList.contains('open')
if (isOpen) {
toggle.classList.remove('open')
body.classList.add('collapsed')
} else {
toggle.classList.add('open')
body.classList.remove('collapsed')
}
},
openImageFullscreen(e) {
const url = e.target.src
window.open(url, '_blank')
},
copyFullMessage(e) {
e.preventDefault()
e.stopPropagation()
const content = this.props.message?.content
if (!content) return
const btn = e.currentTarget
navigator.clipboard.writeText(content).then(() => {
btn.classList.add('copied')
btn.title = 'Copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy message'
}, 2000)
})
},
copyMessageLink(e) {
e.preventDefault()
e.stopPropagation()
const hash = this.props.message?.hash
if (!hash) return
const url = `${window.location.origin}${window.location.pathname}#${hash}`
const btn = e.currentTarget
navigator.clipboard.writeText(url).then(() => {
btn.classList.add('copied')
btn.title = 'Link copied!'
setTimeout(() => {
btn.classList.remove('copied')
btn.title = 'Copy link to message'
}, 2000)
})
},
}
</script>
</message-bubble>