almost there
This commit is contained in:
parent
c7aa88bec6
commit
b230e22ed1
2 changed files with 408 additions and 82 deletions
190
src/components/ui/message-bubble/MessageBubble.vue
Normal file
190
src/components/ui/message-bubble/MessageBubble.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink, Copy, Check } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
sent: boolean
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
content: string
|
||||
timestamp: number
|
||||
showTimestamp?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showTimestamp: true
|
||||
})
|
||||
|
||||
const copiedValues = ref(new Set<string>())
|
||||
|
||||
const bubbleClasses = computed(() => {
|
||||
return cn(
|
||||
'relative flex flex-col break-words min-w-[120px] max-w-[90%] md:max-w-[75%] text-sm',
|
||||
props.sent
|
||||
? 'bg-gradient-to-br from-purple-300 to-purple-400 text-gray-900'
|
||||
: 'bg-gradient-to-br from-gray-700 to-gray-800 text-gray-100',
|
||||
// First message in group
|
||||
props.isFirst && (props.sent ? 'rounded-t-xl rounded-l-xl' : 'rounded-t-xl rounded-r-xl'),
|
||||
// Last message in group
|
||||
props.isLast && (props.sent ? 'rounded-b-xl rounded-l-xl' : 'rounded-b-xl rounded-r-xl'),
|
||||
// Single message
|
||||
props.isFirst && props.isLast && 'rounded-xl',
|
||||
// Middle messages
|
||||
!props.isFirst && !props.isLast && (props.sent ? 'rounded-l-xl' : 'rounded-r-xl')
|
||||
)
|
||||
})
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Regular expressions for detecting special content
|
||||
const BITCOIN_ADDRESS_REGEX = /\b([13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-zAC-HJ-NP-Z02-9]{11,71})\b/g
|
||||
const TXID_REGEX = /\b[a-fA-F0-9]{64}\b/g
|
||||
const LIGHTNING_INVOICE_REGEX = /lnbc[a-zA-Z0-9]+/g
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedValues.value.add(text)
|
||||
setTimeout(() => {
|
||||
copiedValues.value.delete(text)
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSpecialContent = (type: 'address' | 'txid' | 'invoice', value: string) => {
|
||||
const isCopied = copiedValues.value.has(value)
|
||||
const buttonClasses = 'inline-flex items-center gap-1 px-2 h-[22px] text-[11px] font-medium rounded bg-gray-800/20 hover:bg-gray-800/30 transition-colors'
|
||||
|
||||
if (type === 'txid') {
|
||||
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy txid</button><a href="https://mempool.space/tx/${value}" target="_blank" rel="noopener noreferrer" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></svg>mempool.space</a></div>`
|
||||
}
|
||||
|
||||
if (type === 'address') {
|
||||
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy wallet</button><a href="https://mempool.space/address/${value}" target="_blank" rel="noopener noreferrer" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10v10M7 17 17 7"/></svg>mempool.space</a></div>`
|
||||
}
|
||||
|
||||
if (type === 'invoice') {
|
||||
return `<div class="font-mono text-[11px] leading-normal break-all opacity-90">${value}</div><div class="flex flex-wrap items-center gap-1.5 mt-1.5"><button onclick="window.copyToClipboard('${value}')" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${isCopied ? '<path d="M20 6 9 17l-5-5"/>' : '<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z"/>'}</svg>Copy invoice</button><a href="lightning:${value}" class="${buttonClasses}"><svg class="h-3 w-3 mr-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m13 2-2 2.5h3L12 7"/><path d="M12 22v-3"/><path d="M12 17v-2"/><path d="M12 12V9"/></svg>Pay</a></div>`
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const formattedContent = computed(() => {
|
||||
let processedText = props.content
|
||||
|
||||
// Check if the content is JSON
|
||||
try {
|
||||
const jsonContent = JSON.parse(processedText)
|
||||
if (typeof jsonContent === 'object') {
|
||||
return JSON.stringify(jsonContent, null, 2)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, continue with normal processing
|
||||
}
|
||||
|
||||
// Replace Bitcoin addresses with formatted content
|
||||
processedText = processedText.replace(BITCOIN_ADDRESS_REGEX, (address) =>
|
||||
formatSpecialContent('address', address)
|
||||
)
|
||||
|
||||
// Replace transaction IDs with formatted content
|
||||
processedText = processedText.replace(TXID_REGEX, (txid) =>
|
||||
formatSpecialContent('txid', txid)
|
||||
)
|
||||
|
||||
// Replace Lightning invoices with formatted content
|
||||
processedText = processedText.replace(LIGHTNING_INVOICE_REGEX, (invoice) =>
|
||||
formatSpecialContent('invoice', invoice)
|
||||
)
|
||||
|
||||
return processedText
|
||||
})
|
||||
|
||||
const isJson = computed(() => {
|
||||
try {
|
||||
JSON.parse(props.content)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Expose the copyToClipboard function to the window object for the onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).copyToClipboard = copyToClipboard
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="bubbleClasses">
|
||||
<div class="px-3 py-2 break-words whitespace-pre-wrap">
|
||||
<span v-if="isJson" class="font-mono text-[11px]">
|
||||
<pre class="select-text p-2 rounded">{{ JSON.stringify(JSON.parse(content), null, 2) }}</pre>
|
||||
</span>
|
||||
<span v-else class="select-text text-[13px] leading-relaxed" v-html="formattedContent"></span>
|
||||
</div>
|
||||
<span v-if="showTimestamp && isLast" class="text-[10px] text-gray-500/80 px-3 pb-1 select-none"
|
||||
:class="{ 'self-end': sent }">{{ formatTime(timestamp) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.break-words {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.select-text {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.select-none {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(button) {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(button:focus-visible),
|
||||
:deep(a:focus-visible) {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for JSON */
|
||||
:deep(pre) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Special content container styles */
|
||||
:deep(.special-content) {
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue