Files
whatsappNucleo/app/components/messages/MessageInput.vue
josedario87 5df59747fe
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m9s
Feat: Agregar animacion de crecimiento vertical para previews
- Usar Vue Transition con slide-up para MediaPreview y ReplyPreview
- Animar altura, opacidad y espaciado
- Duracion 0.3s con ease
2025-12-04 10:33:42 -06:00

397 lines
10 KiB
Vue

<template>
<div class="space-y-2">
<!-- Reply preview with animation -->
<Transition name="slide-up">
<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>
</Transition>
<!-- Media preview with animation -->
<Transition name="slide-up">
<MediaPreview
v-if="selectedFiles.length > 0"
:files="selectedFiles"
:caption="caption"
@remove="removeFile"
@update:caption="caption = $event"
@update:stickerModes="stickerModes = $event"
/>
</Transition>
<!-- 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>
<!-- 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>
</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
})
const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
sendVoice: [audioFile: File]
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[]>([])
// 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...'
})
// 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()
}]
]
// 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)
}
</script>
<style scoped>
/* Slide up animation for previews */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
.slide-up-enter-to,
.slide-up-leave-from {
opacity: 1;
max-height: 200px;
}
</style>