Files
whatsappNucleo/app/components/messages/MessageBubble.vue
josedario87 8f44826e64
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
Fix: Usar URL interna para debug webhook receiver (bypass authentik)
2025-12-02 21:27:45 -06:00

339 lines
10 KiB
Vue

<template>
<div
class="flex flex-col group/message"
:class="message.fromMe ? 'items-end' : 'items-start'"
>
<!-- Sender name for groups -->
<span
v-if="!message.fromMe && isGroup && senderName"
class="text-xs font-medium mb-1 ml-1"
:style="{ color: senderColor }"
>
{{ senderName }}
</span>
<div
class="relative max-w-[70%] rounded-lg overflow-hidden"
:class="bubbleClass"
>
<!-- Quoted message -->
<div v-if="message.quoted" class="px-2 pt-2">
<MessageQuoted
:quoted="message.quoted"
@click="$emit('scrollToMessage', $event)"
/>
</div>
<!-- Message content based on type -->
<div :class="contentPadding">
<!-- Text message -->
<p
v-if="message.type === 'text' && message.content"
class="whitespace-pre-wrap break-words"
:class="textClass"
>
{{ message.content }}
</p>
<!-- Image -->
<MessageImage
v-else-if="message.type === 'image' && message.media"
:media="message.media"
:instance-id="instanceId"
:message-id="message.messageId"
/>
<!-- Video -->
<MessageVideo
v-else-if="message.type === 'video' && message.media"
:media="message.media"
:instance-id="instanceId"
:message-id="message.messageId"
/>
<!-- Audio -->
<MessageAudio
v-else-if="message.type === 'audio' && message.media"
:media="message.media"
:instance-id="instanceId"
:message-id="message.messageId"
:from-me="message.fromMe"
/>
<!-- Document -->
<MessageDocument
v-else-if="message.type === 'document' && message.media"
:media="message.media"
:instance-id="instanceId"
:message-id="message.messageId"
:from-me="message.fromMe"
/>
<!-- Sticker -->
<MessageSticker
v-else-if="message.type === 'sticker' && message.media"
:media="message.media"
:instance-id="instanceId"
:message-id="message.messageId"
/>
<!-- Contact -->
<MessageContact
v-else-if="message.type === 'contact' && message.contact"
:contact="message.contact"
:from-me="message.fromMe"
/>
<!-- Location -->
<MessageLocation
v-else-if="message.type === 'location' && message.location"
:location="message.location"
:from-me="message.fromMe"
/>
<!-- Unknown/unsupported type -->
<div
v-else-if="message.type === 'unknown' || !message.content"
class="flex items-center gap-2 text-sm"
:class="message.fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
>
<UIcon name="i-lucide-help-circle" class="w-4 h-4" />
<span>Mensaje no soportado</span>
</div>
<!-- Caption for media -->
<p
v-if="message.caption"
class="mt-2 whitespace-pre-wrap break-words"
:class="textClass"
>
{{ message.caption }}
</p>
</div>
<!-- Reactions -->
<div
v-if="message.reactions && message.reactions.length > 0"
class="flex flex-wrap gap-1 px-2 pb-1"
>
<span
v-for="(reaction, i) in groupedReactions"
:key="i"
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs"
:class="message.fromMe ? 'bg-white/20' : 'bg-[var(--wa-bg-light)]'"
:title="reaction.reactors.join(', ')"
>
<span>{{ reaction.emoji }}</span>
<span v-if="reaction.count > 1" class="text-[10px] opacity-70">{{ reaction.count }}</span>
</span>
</div>
<!-- Footer with time and status -->
<div class="flex items-center justify-end gap-1 px-2 pb-1.5" :class="{ 'pt-1': !message.content && !message.caption }">
<span class="text-[11px]" :class="message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'">
{{ formatTime(message.timestamp) }}
</span>
<UIcon
v-if="message.fromMe"
:name="statusIcon"
class="w-4 h-4"
:class="statusColor"
/>
</div>
<!-- Action buttons on hover -->
<div
class="absolute top-1 opacity-0 group-hover/message:opacity-100 transition-opacity flex gap-0.5"
:class="message.fromMe ? 'left-1' : 'right-1'"
>
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
title="Responder"
@click="$emit('reply', message)"
>
<UIcon name="i-lucide-reply" class="w-4 h-4" />
</button>
<div class="relative">
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
title="Reaccionar"
@click.stop="showReactionPicker = !showReactionPicker"
>
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
</button>
<ReactionPicker
:visible="showReactionPicker"
position="bottom"
@select="handleReaction"
@close="showReactionPicker = false"
/>
</div>
<button
class="p-1 rounded-full hover:bg-black/10"
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
title="Debug"
@click="showDebug = !showDebug"
>
<UIcon name="i-lucide-bug" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<!-- Debug panel -->
<div
v-if="showDebug"
class="max-w-[90%] mt-1 p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono overflow-x-auto"
>
<div class="flex items-center justify-between mb-1">
<span class="text-gray-400">Message:</span>
<button
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Copiar al portapapeles"
@click="copyToClipboard(JSON.stringify(message, null, 2))"
>
<UIcon name="i-lucide-copy" class="w-3 h-3" />
</button>
</div>
<pre class="text-green-400 whitespace-pre-wrap">{{ JSON.stringify(message, null, 2) }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import type { Message, ReactionInfo } from '~/types/message'
import { stringToColor } from '~/types/message'
// Sub-components
import MessageQuoted from './content/MessageQuoted.vue'
import MessageImage from './content/MessageImage.vue'
import MessageVideo from './content/MessageVideo.vue'
import MessageAudio from './content/MessageAudio.vue'
import MessageDocument from './content/MessageDocument.vue'
import MessageSticker from './content/MessageSticker.vue'
import MessageContact from './content/MessageContact.vue'
import MessageLocation from './content/MessageLocation.vue'
import ReactionPicker from './ReactionPicker.vue'
interface Props {
message: Message
instanceId: string
isGroup?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isGroup: false
})
const emit = defineEmits<{
reply: [message: Message]
react: [message: Message, emoji: string]
scrollToMessage: [id: string]
}>()
const showDebug = ref(false)
const showReactionPicker = ref(false)
// Handle reaction selection
const handleReaction = (emoji: string) => {
emit('react', props.message, emoji)
showReactionPicker.value = false
}
// Computed properties
const senderName = computed(() => {
if (props.message.fromMe) return null
return props.message.pushName || props.message.participant?.split('@')[0] || null
})
const senderColor = computed(() => {
return stringToColor(props.message.participant || props.message.fromJid)
})
const bubbleClass = computed(() => {
const base = props.message.fromMe ? 'bubble-out' : 'bubble-in'
// Stickers don't have background
if (props.message.type === 'sticker') {
return 'bg-transparent'
}
return base
})
const contentPadding = computed(() => {
// Media messages have less padding
if (['image', 'video', 'sticker'].includes(props.message.type)) {
return 'p-1'
}
return 'px-3 py-2'
})
const textClass = computed(() => {
return props.message.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
})
// Group reactions by emoji
const groupedReactions = computed(() => {
if (!props.message.reactions) return []
const groups: Record<string, { emoji: string; count: number; reactors: string[] }> = {}
for (const reaction of props.message.reactions) {
if (!groups[reaction.emoji]) {
groups[reaction.emoji] = { emoji: reaction.emoji, count: 0, reactors: [] }
}
groups[reaction.emoji].count++
groups[reaction.emoji].reactors.push(reaction.reactorName || reaction.reactorJid)
}
return Object.values(groups)
})
const statusIcon = computed(() => {
const icons: Record<string, string> = {
pending: 'i-lucide-clock',
sent: 'i-lucide-check',
delivered: 'i-lucide-check-check',
read: 'i-lucide-check-check',
failed: 'i-lucide-alert-circle'
}
return icons[props.message.status] || 'i-lucide-check'
})
const statusColor = computed(() => {
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
if (props.message.status === 'failed') return 'text-red-500'
return props.message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
})
// Methods
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('es-AR', {
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
} catch (err) {
console.error('Failed to copy:', err)
}
}
</script>
<style scoped>
.bubble-in {
background-color: var(--wa-surface);
border-top-left-radius: 4px;
}
.bubble-out {
background-color: var(--wa-green-dark);
border-top-right-radius: 4px;
}
</style>