Files
whatsappNucleo/app/components/messages/MessageInput.vue
josedario87 cb846d0c56
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s
Feat: Agregar soporte para envío de Contacts, Polls y Events
- Backend: Nuevo soporte en endpoint /send para tipos contact, poll, event
- UI: Modales para crear y enviar contactos, encuestas y eventos
- Visualización: Componentes MessagePoll y MessageEvent para mostrar mensajes recibidos
- Tipos: Agregar PollInfo, EventInfo y tipo 'event' a MessageType
2025-12-04 12:06:35 -06:00

527 lines
14 KiB
Vue

<template>
<div class="space-y-2">
<!-- Debug Panel -->
<div
v-if="showDebug"
class="p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-60 overflow-auto"
>
<div class="flex items-center justify-between mb-2">
<span class="text-gray-400">Estado del Input:</span>
<button
@click="copyDebugInfo"
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
title="Copiar al portapapeles"
>
<UIcon name="i-lucide-copy" class="w-3 h-3" />
</button>
</div>
<pre class="text-green-400 whitespace-pre-wrap">{{ debugInfo }}</pre>
</div>
<!-- Reply preview with animation -->
<div
v-if="replyingTo"
class="flex items-center gap-2 p-2 rounded-lg bg-[var(--wa-bg-light)]"
>
<div class="w-1 h-8 rounded-full bg-[var(--wa-green-light)]" />
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-[var(--wa-green-light)]">
{{ replyingTo.fromMe ? 'Tú' : (replyingTo.pushName || 'Mensaje') }}
</p>
<p class="text-sm text-[var(--wa-text-muted)] truncate">
{{ replyingTo.content || getTypePlaceholder(replyingTo.type) }}
</p>
</div>
<button
class="p-1 rounded-full hover:bg-[var(--wa-border)]"
@click="$emit('cancelReply')"
>
<UIcon name="i-lucide-x" class="w-4 h-4 text-[var(--wa-text-muted)]" />
</button>
</div>
<!-- Media preview -->
<MessagesMediaPreview
v-if="selectedFiles.length > 0"
:files="selectedFiles"
:caption="caption"
@remove="removeFile"
@update:caption="caption = $event"
@update:stickerModes="stickerModes = $event"
/>
<!-- Recording indicator -->
<div
v-if="isRecording"
class="flex items-center gap-3 p-3 rounded-lg bg-red-900/30"
>
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse" />
<span class="text-red-400 font-medium">{{ formatDuration(recordingDuration) }}</span>
<div class="flex-1" />
<button
class="p-2 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/30"
@click="cancelRecording"
title="Cancelar"
>
<UIcon name="i-lucide-trash-2" class="w-5 h-5" />
</button>
<button
class="p-2 rounded-full bg-green-500 text-white hover:bg-green-600"
@click="sendVoiceMessage"
title="Enviar"
>
<UIcon name="i-lucide-send" class="w-5 h-5" />
</button>
</div>
<!-- Main input area -->
<div
v-else
class="flex items-end gap-2"
:class="{ 'ring-2 ring-[var(--wa-green-light)] rounded-lg': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleDrop"
>
<!-- Attachment button -->
<UDropdownMenu :items="attachmentMenuItems" :content="{ side: 'top', align: 'start' }">
<UButton
variant="ghost"
icon="i-lucide-paperclip"
class="text-[var(--wa-text-muted)]"
/>
</UDropdownMenu>
<!-- Hidden file inputs -->
<input
ref="imageInput"
type="file"
accept="image/*"
multiple
hidden
@change="handleFileSelect($event, 'image')"
/>
<input
ref="videoInput"
type="file"
accept="video/*"
multiple
hidden
@change="handleFileSelect($event, 'video')"
/>
<input
ref="audioInput"
type="file"
accept="audio/*"
multiple
hidden
@change="handleFileSelect($event, 'audio')"
/>
<input
ref="documentInput"
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
multiple
hidden
@change="handleFileSelect($event, 'document')"
/>
<!-- Text input -->
<div class="flex-1">
<UTextarea
v-model="message"
:placeholder="inputPlaceholder"
:rows="1"
autoresize
:maxrows="5"
class="bg-[var(--wa-bg-light)]"
@keydown.enter.exact.prevent="handleSend"
@input="emit('typing')"
/>
</div>
<!-- Debug button -->
<UButton
variant="ghost"
icon="i-lucide-bug"
class="text-gray-400"
title="Debug input state"
@click="showDebug = !showDebug"
/>
<!-- Send / Mic button -->
<UButton
v-if="hasContent"
icon="i-lucide-send"
@click="handleSend"
/>
<UButton
v-else
icon="i-lucide-mic"
:disabled="!audioRecorderSupported"
@click="startRecording"
:title="audioRecorderSupported ? 'Grabar nota de voz' : 'Grabación no soportada'"
/>
</div>
<!-- Drag hint -->
<div
v-if="isDragging"
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg pointer-events-none z-10"
>
<div class="text-center">
<UIcon name="i-lucide-upload" class="w-12 h-12 text-white mb-2" />
<p class="text-white font-medium">Suelta los archivos aquí</p>
</div>
</div>
<!-- Contact Send Modal -->
<MessagesContactSendModal
v-model:open="showContactModal"
@send="handleSendContact"
/>
<!-- Poll Send Modal -->
<MessagesPollSendModal
v-model:open="showPollModal"
@send="handleSendPoll"
/>
<!-- Event Send Modal -->
<MessagesEventSendModal
v-model:open="showEventModal"
@send="handleSendEvent"
/>
</div>
</template>
<script setup lang="ts">
import type { Message, MessageType } from '~/types/message'
import { getMessageTypePlaceholder } from '~/types/message'
import { useAudioRecorder } from '~/composables/useAudioRecorder'
interface Props {
replyingTo?: Message | null
}
const props = withDefaults(defineProps<Props>(), {
replyingTo: null
})
interface ContactInfo {
displayName: string
phoneNumber: string
organization?: string
}
interface PollData {
name: string
options: string[]
selectableCount: number
}
interface EventData {
name: string
startDate: string
endDate?: string
description?: string
location?: {
name?: string
address?: string
latitude?: number
longitude?: number
}
}
const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
sendVoice: [audioFile: File]
sendContact: [contacts: ContactInfo[], quotedId?: string]
sendPoll: [poll: PollData, quotedId?: string]
sendEvent: [event: EventData, quotedId?: string]
cancelReply: []
typing: []
recording: [isRecording: boolean]
}>()
// Refs for file inputs
const imageInput = ref<HTMLInputElement | null>(null)
const videoInput = ref<HTMLInputElement | null>(null)
const audioInput = ref<HTMLInputElement | null>(null)
const documentInput = ref<HTMLInputElement | null>(null)
// State
const message = ref('')
const selectedFiles = ref<File[]>([])
const caption = ref('')
const isDragging = ref(false)
const stickerModes = ref<boolean[]>([])
const showDebug = ref(false)
// Modal states
const showContactModal = ref(false)
const showPollModal = ref(false)
const showEventModal = ref(false)
// File size limits (in bytes) - should match server
const MAX_SIZES: Record<string, number> = {
image: 16 * 1024 * 1024, // 16 MB
video: 64 * 1024 * 1024, // 64 MB
audio: 16 * 1024 * 1024, // 16 MB
document: 100 * 1024 * 1024, // 100 MB
}
const toast = useToast()
// Audio recorder
const {
isRecording,
duration: recordingDuration,
isSupported: audioRecorderSupported,
startRecording: startAudioRecording,
stopRecording,
cancelRecording: cancelAudioRecording,
getAudioFile,
clearAudio
} = useAudioRecorder()
// Computed
const hasContent = computed(() => {
return message.value.trim().length > 0 || selectedFiles.value.length > 0
})
const inputPlaceholder = computed(() => {
if (selectedFiles.value.length > 0) {
const count = selectedFiles.value.length
return `${count} archivo${count > 1 ? 's' : ''} seleccionado${count > 1 ? 's' : ''} - Presiona enviar`
}
return 'Escribe un mensaje...'
})
// Debug info computed
const debugInfo = computed(() => {
const filesInfo = selectedFiles.value.map((f, i) => ({
index: i,
name: f.name,
type: f.type,
size: formatFileSize(f.size),
sizeBytes: f.size,
asSticker: stickerModes.value[i] || false
}))
return JSON.stringify({
message: message.value,
caption: caption.value,
filesCount: selectedFiles.value.length,
files: filesInfo,
stickerModes: stickerModes.value,
hasContent: hasContent.value,
replyingTo: props.replyingTo ? {
messageId: props.replyingTo.messageId,
type: props.replyingTo.type,
fromMe: props.replyingTo.fromMe
} : null,
isRecording: isRecording.value,
isDragging: isDragging.value
}, null, 2)
})
// Copy debug info to clipboard
const copyDebugInfo = async () => {
try {
await navigator.clipboard.writeText(debugInfo.value)
toast.add({
title: 'Copiado al portapapeles',
icon: 'i-lucide-check',
color: 'success',
duration: 2000
})
} catch (err) {
console.error('Failed to copy:', err)
toast.add({
title: 'Error al copiar',
icon: 'i-lucide-x',
color: 'error',
duration: 2000
})
}
}
// Attachment menu
const attachmentMenuItems = [
[{
label: 'Imagen',
icon: 'i-lucide-image',
onSelect: () => imageInput.value?.click()
}],
[{
label: 'Video',
icon: 'i-lucide-video',
onSelect: () => videoInput.value?.click()
}],
[{
label: 'Audio',
icon: 'i-lucide-music',
onSelect: () => audioInput.value?.click()
}],
[{
label: 'Documento',
icon: 'i-lucide-file',
onSelect: () => documentInput.value?.click()
}],
[{
label: 'Contacto',
icon: 'i-lucide-user',
onSelect: () => showContactModal.value = true
}],
[{
label: 'Encuesta',
icon: 'i-lucide-bar-chart-2',
onSelect: () => showPollModal.value = true
}],
[{
label: 'Evento',
icon: 'i-lucide-calendar',
onSelect: () => showEventModal.value = true
}]
]
// Methods
const handleFileSelect = (event: Event, type: string) => {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
addFiles(Array.from(input.files))
input.value = '' // Reset input
}
}
const getFileMediaType = (file: File): string => {
if (file.type.startsWith('image/')) return 'image'
if (file.type.startsWith('video/')) return 'video'
if (file.type.startsWith('audio/')) return 'audio'
return 'document'
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
const addFiles = (files: File[]) => {
// Limit to 10 files
const remaining = 10 - selectedFiles.value.length
const toAdd = files.slice(0, remaining)
// Validate file sizes
const validFiles: File[] = []
for (const file of toAdd) {
const mediaType = getFileMediaType(file)
const maxSize = MAX_SIZES[mediaType]
if (file.size > maxSize) {
toast.add({
title: 'Archivo muy grande',
description: `${file.name} (${formatFileSize(file.size)}) excede el límite de ${formatFileSize(maxSize)} para ${mediaType}`,
color: 'error',
duration: 5000
})
} else {
validFiles.push(file)
}
}
if (validFiles.length > 0) {
// Use spread operator to trigger reactivity
selectedFiles.value = [...selectedFiles.value, ...validFiles]
}
}
const handleDrop = (event: DragEvent) => {
isDragging.value = false
if (event.dataTransfer?.files) {
addFiles(Array.from(event.dataTransfer.files))
}
}
const removeFile = (index: number) => {
// Use filter to maintain reactivity
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
}
const handleSend = () => {
if (!hasContent.value) return
emit(
'send',
message.value.trim(),
[...selectedFiles.value],
caption.value,
props.replyingTo?.messageId,
[...stickerModes.value]
)
// Clear state
message.value = ''
selectedFiles.value = []
caption.value = ''
stickerModes.value = []
}
const startRecording = async () => {
const success = await startAudioRecording()
if (success) {
emit('recording', true)
} else {
console.error('Failed to start recording')
}
}
const cancelRecording = () => {
cancelAudioRecording()
emit('recording', false)
}
const sendVoiceMessage = () => {
stopRecording()
emit('recording', false)
// Wait a bit for the blob to be ready
setTimeout(() => {
const audioFile = getAudioFile()
if (audioFile) {
emit('sendVoice', audioFile)
clearAudio()
}
}, 100)
}
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const getTypePlaceholder = (type: MessageType): string => {
return getMessageTypePlaceholder(type)
}
// Handle contact send from modal
const handleSendContact = (contacts: ContactInfo[]) => {
emit('sendContact', contacts, props.replyingTo?.messageId)
}
// Handle poll send from modal
const handleSendPoll = (poll: PollData) => {
emit('sendPoll', poll, props.replyingTo?.messageId)
}
// Handle event send from modal
const handleSendEvent = (eventData: EventData) => {
emit('sendEvent', eventData, props.replyingTo?.messageId)
}
</script>