Fix: Usar URL interna para debug webhook receiver (bypass authentik)
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
This commit is contained in:
@@ -27,10 +27,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-sm text-[var(--wa-text-muted)] truncate">{{ chat.lastMessage }}</p>
|
<div class="flex items-center gap-1 text-sm text-[var(--wa-text-muted)] truncate">
|
||||||
|
<UIcon v-if="messageTypeIcon" :name="messageTypeIcon" class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{ lastMessagePreview }}</span>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="chat.unreadCount > 0"
|
v-if="chat.unreadCount > 0"
|
||||||
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5"
|
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{{ chat.unreadCount }}
|
{{ chat.unreadCount }}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,7 +69,9 @@ interface Chat {
|
|||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
lastMessage: string
|
lastMessage: string
|
||||||
lastMessageAt: Date
|
lastMessageAt: Date
|
||||||
|
lastMessageType?: string
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
|
isGroup?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -74,13 +79,57 @@ interface Props {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
click: []
|
click: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showDebug = ref(false)
|
const showDebug = ref(false)
|
||||||
|
|
||||||
|
// Message type to icon mapping
|
||||||
|
const messageTypeIcons: Record<string, string> = {
|
||||||
|
image: 'i-lucide-image',
|
||||||
|
video: 'i-lucide-video',
|
||||||
|
audio: 'i-lucide-music',
|
||||||
|
document: 'i-lucide-file',
|
||||||
|
sticker: 'i-lucide-sticker',
|
||||||
|
contact: 'i-lucide-contact',
|
||||||
|
location: 'i-lucide-map-pin'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message type to placeholder text
|
||||||
|
const messageTypePlaceholders: Record<string, string> = {
|
||||||
|
image: 'Foto',
|
||||||
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
document: 'Documento',
|
||||||
|
sticker: 'Sticker',
|
||||||
|
contact: 'Contacto',
|
||||||
|
location: 'Ubicación'
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageTypeIcon = computed(() => {
|
||||||
|
const type = props.chat.lastMessageType
|
||||||
|
if (!type || type === 'text') return null
|
||||||
|
return messageTypeIcons[type] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastMessagePreview = computed(() => {
|
||||||
|
const type = props.chat.lastMessageType
|
||||||
|
|
||||||
|
// If there's text content, show it
|
||||||
|
if (props.chat.lastMessage) {
|
||||||
|
return props.chat.lastMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show placeholder based on type
|
||||||
|
if (type && messageTypePlaceholders[type]) {
|
||||||
|
return messageTypePlaceholders[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
|
|||||||
@@ -154,14 +154,22 @@
|
|||||||
>
|
>
|
||||||
<UIcon name="i-lucide-reply" class="w-4 h-4" />
|
<UIcon name="i-lucide-reply" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="p-1 rounded-full hover:bg-black/10"
|
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)]'"
|
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||||
title="Reaccionar"
|
title="Reaccionar"
|
||||||
@click="$emit('react', message)"
|
@click.stop="showReactionPicker = !showReactionPicker"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
|
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<ReactionPicker
|
||||||
|
:visible="showReactionPicker"
|
||||||
|
position="bottom"
|
||||||
|
@select="handleReaction"
|
||||||
|
@close="showReactionPicker = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="p-1 rounded-full hover:bg-black/10"
|
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)]'"
|
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||||
@@ -206,6 +214,7 @@ import MessageDocument from './content/MessageDocument.vue'
|
|||||||
import MessageSticker from './content/MessageSticker.vue'
|
import MessageSticker from './content/MessageSticker.vue'
|
||||||
import MessageContact from './content/MessageContact.vue'
|
import MessageContact from './content/MessageContact.vue'
|
||||||
import MessageLocation from './content/MessageLocation.vue'
|
import MessageLocation from './content/MessageLocation.vue'
|
||||||
|
import ReactionPicker from './ReactionPicker.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message
|
message: Message
|
||||||
@@ -217,13 +226,20 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
isGroup: false
|
isGroup: false
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
reply: [message: Message]
|
reply: [message: Message]
|
||||||
react: [message: Message]
|
react: [message: Message, emoji: string]
|
||||||
scrollToMessage: [id: string]
|
scrollToMessage: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showDebug = ref(false)
|
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
|
// Computed properties
|
||||||
const senderName = computed(() => {
|
const senderName = computed(() => {
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
:maxrows="5"
|
:maxrows="5"
|
||||||
class="bg-[var(--wa-bg-light)]"
|
class="bg-[var(--wa-bg-light)]"
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
|
@input="emit('typing')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,6 +166,8 @@ const emit = defineEmits<{
|
|||||||
send: [content: string, files: File[], caption: string, quotedId?: string]
|
send: [content: string, files: File[], caption: string, quotedId?: string]
|
||||||
sendVoice: [audioFile: File]
|
sendVoice: [audioFile: File]
|
||||||
cancelReply: []
|
cancelReply: []
|
||||||
|
typing: []
|
||||||
|
recording: [isRecording: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Refs for file inputs
|
// Refs for file inputs
|
||||||
@@ -266,18 +269,21 @@ const handleSend = () => {
|
|||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
const success = await startAudioRecording()
|
const success = await startAudioRecording()
|
||||||
if (!success) {
|
if (success) {
|
||||||
// Show error toast
|
emit('recording', true)
|
||||||
|
} else {
|
||||||
console.error('Failed to start recording')
|
console.error('Failed to start recording')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelRecording = () => {
|
const cancelRecording = () => {
|
||||||
cancelAudioRecording()
|
cancelAudioRecording()
|
||||||
|
emit('recording', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendVoiceMessage = () => {
|
const sendVoiceMessage = () => {
|
||||||
stopRecording()
|
stopRecording()
|
||||||
|
emit('recording', false)
|
||||||
|
|
||||||
// Wait a bit for the blob to be ready
|
// Wait a bit for the blob to be ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
125
app/components/messages/ReactionPicker.vue
Normal file
125
app/components/messages/ReactionPicker.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="absolute z-50 bg-[var(--wa-bg-dark)] rounded-full shadow-lg border border-[var(--wa-border)] p-1 flex items-center gap-1"
|
||||||
|
:class="position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="emoji in quickReactions"
|
||||||
|
:key="emoji"
|
||||||
|
class="w-8 h-8 flex items-center justify-center text-lg hover:bg-[var(--wa-bg-light)] rounded-full transition-transform hover:scale-125"
|
||||||
|
@click="selectReaction(emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center text-lg hover:bg-[var(--wa-bg-light)] rounded-full"
|
||||||
|
@click="showFullPicker = true"
|
||||||
|
title="Más emojis"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-plus" class="w-4 h-4 text-[var(--wa-text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full emoji picker modal -->
|
||||||
|
<UModal v-model="showFullPicker">
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 text-[var(--wa-text)]">Elegir reacción</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-8 gap-2 max-h-64 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="emoji in allEmojis"
|
||||||
|
:key="emoji"
|
||||||
|
class="w-10 h-10 flex items-center justify-center text-2xl hover:bg-[var(--wa-bg-light)] rounded transition-transform hover:scale-110"
|
||||||
|
@click="selectReaction(emoji); showFullPicker = false"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<UButton variant="ghost" @click="showFullPicker = false">
|
||||||
|
Cancelar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
visible: false,
|
||||||
|
position: 'top'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [emoji: string]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showFullPicker = ref(false)
|
||||||
|
|
||||||
|
// Quick reactions (WhatsApp style)
|
||||||
|
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
|
||||||
|
|
||||||
|
// Extended emoji list
|
||||||
|
const allEmojis = [
|
||||||
|
// Smileys
|
||||||
|
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||||
|
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||||
|
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
|
||||||
|
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
|
||||||
|
'🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬',
|
||||||
|
'😮💨', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||||
|
// Gestures
|
||||||
|
'👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🤟',
|
||||||
|
'🤘', '🤙', '👈', '👉', '👆', '👇', '☝️', '👋',
|
||||||
|
'🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲',
|
||||||
|
'🤝', '🙏', '✍️', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||||
|
// Hearts
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||||
|
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
|
||||||
|
'💘', '💝', '💟', '♥️', '💌', '💋', '👄', '👅',
|
||||||
|
// Objects
|
||||||
|
'🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉',
|
||||||
|
'⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🎱', '🎮',
|
||||||
|
'🎵', '🎶', '🎤', '🎧', '🎸', '🎹', '🎺', '🎻',
|
||||||
|
'🍕', '🍔', '🍟', '🌭', '🍿', '🧁', '🍰', '🎂',
|
||||||
|
'☕', '🍵', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸',
|
||||||
|
// Nature
|
||||||
|
'🌸', '🌺', '🌹', '🌷', '🌻', '🌼', '💐', '🌿',
|
||||||
|
'☀️', '🌙', '⭐', '🌟', '✨', '💫', '🔥', '💧',
|
||||||
|
'🌈', '☁️', '⛈️', '❄️', '☃️', '⚡', '🌊', '🌍',
|
||||||
|
// Animals
|
||||||
|
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
|
||||||
|
'🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔',
|
||||||
|
'🦄', '🐝', '🦋', '🐌', '🐞', '🐜', '🦗', '🕷️'
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectReaction = (emoji: string) => {
|
||||||
|
emit('select', emoji)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
onMounted(() => {
|
||||||
|
const handleClickOutside = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay to avoid immediate close
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
166
app/composables/usePresence.ts
Normal file
166
app/composables/usePresence.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Composable for managing presence (typing indicators, online status)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PresenceState = 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
||||||
|
|
||||||
|
interface PresenceInfo {
|
||||||
|
presence: PresenceState | null
|
||||||
|
lastSeen: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresenceStore {
|
||||||
|
[jid: string]: PresenceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePresence(instanceId: Ref<string | null>) {
|
||||||
|
const { on } = useRealtime()
|
||||||
|
|
||||||
|
// Store of presence states by JID
|
||||||
|
const presences = ref<PresenceStore>({})
|
||||||
|
|
||||||
|
// Debounce timer for composing presence
|
||||||
|
let composingTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
// Subscribe to presence updates for a contact
|
||||||
|
const subscribeToPresence = async (jid: string): Promise<void> => {
|
||||||
|
if (!instanceId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/presence/${instanceId.value}/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { jid }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[usePresence] Error subscribing:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send own presence update
|
||||||
|
const sendPresence = async (jid: string, presence: PresenceState): Promise<void> => {
|
||||||
|
if (!instanceId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/presence/${instanceId.value}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { jid, presence }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[usePresence] Error sending presence:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send composing presence with automatic paused after timeout
|
||||||
|
const sendTyping = async (jid: string): Promise<void> => {
|
||||||
|
if (!instanceId.value) return
|
||||||
|
|
||||||
|
// Clear previous timeout
|
||||||
|
if (composingTimeout) {
|
||||||
|
clearTimeout(composingTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send composing
|
||||||
|
await sendPresence(jid, 'composing')
|
||||||
|
|
||||||
|
// Auto-pause after 5 seconds of no typing
|
||||||
|
composingTimeout = setTimeout(async () => {
|
||||||
|
await sendPresence(jid, 'paused')
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send recording presence
|
||||||
|
const sendRecording = async (jid: string): Promise<void> => {
|
||||||
|
await sendPresence(jid, 'recording')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop typing/recording indicator
|
||||||
|
const stopIndicator = async (jid: string): Promise<void> => {
|
||||||
|
if (composingTimeout) {
|
||||||
|
clearTimeout(composingTimeout)
|
||||||
|
composingTimeout = null
|
||||||
|
}
|
||||||
|
await sendPresence(jid, 'paused')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get presence for a JID
|
||||||
|
const getPresence = (jid: string): PresenceInfo => {
|
||||||
|
return presences.value[jid] || { presence: null, lastSeen: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get presence text for display
|
||||||
|
const getPresenceText = (jid: string): string | null => {
|
||||||
|
const presence = getPresence(jid)
|
||||||
|
if (!presence.presence) return null
|
||||||
|
|
||||||
|
switch (presence.presence) {
|
||||||
|
case 'composing':
|
||||||
|
return 'escribiendo...'
|
||||||
|
case 'recording':
|
||||||
|
return 'grabando audio...'
|
||||||
|
case 'available':
|
||||||
|
return 'en línea'
|
||||||
|
case 'unavailable':
|
||||||
|
if (presence.lastSeen) {
|
||||||
|
return `última vez ${formatLastSeen(presence.lastSeen)}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
case 'paused':
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format last seen date
|
||||||
|
const formatLastSeen = (date: Date): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return 'hace un momento'
|
||||||
|
if (minutes < 60) return `hace ${minutes} min`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `hace ${hours}h`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days === 1) return 'ayer'
|
||||||
|
if (days < 7) return `hace ${days} días`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('es', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for presence updates from SSE
|
||||||
|
onMounted(() => {
|
||||||
|
on('presence.update', (data: any) => {
|
||||||
|
if (data.instanceId !== instanceId.value) return
|
||||||
|
|
||||||
|
// Update presence store
|
||||||
|
for (const [participantJid, presence] of Object.entries(data.presences)) {
|
||||||
|
const presenceData = presence as { lastKnownPresence: string; lastSeen?: number }
|
||||||
|
presences.value[participantJid] = {
|
||||||
|
presence: presenceData.lastKnownPresence as PresenceState,
|
||||||
|
lastSeen: presenceData.lastSeen ? new Date(presenceData.lastSeen * 1000) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (composingTimeout) {
|
||||||
|
clearTimeout(composingTimeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
presences: readonly(presences),
|
||||||
|
subscribeToPresence,
|
||||||
|
sendPresence,
|
||||||
|
sendTyping,
|
||||||
|
sendRecording,
|
||||||
|
stopIndicator,
|
||||||
|
getPresence,
|
||||||
|
getPresenceText
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,10 @@
|
|||||||
<UAvatar :alt="selectedChat.name" size="md" />
|
<UAvatar :alt="selectedChat.name" size="md" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
|
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
|
||||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
|
<p v-if="currentChatPresence" class="text-sm text-[var(--wa-green-light)]">
|
||||||
|
{{ currentChatPresence }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="showSelectedChatDebug = !showSelectedChatDebug"
|
@click="showSelectedChatDebug = !showSelectedChatDebug"
|
||||||
@@ -180,6 +183,8 @@
|
|||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@send-voice="handleSendVoice"
|
@send-voice="handleSendVoice"
|
||||||
@cancel-reply="replyingTo = null"
|
@cancel-reply="replyingTo = null"
|
||||||
|
@typing="handleTyping"
|
||||||
|
@recording="handleRecordingPresence"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -199,6 +204,16 @@ const { instances, fetchInstances } = useInstances()
|
|||||||
const { isConnected, lastEvent, on } = useRealtime()
|
const { isConnected, lastEvent, on } = useRealtime()
|
||||||
|
|
||||||
const selectedInstance = ref<{ label: string; value: string } | null>(null)
|
const selectedInstance = ref<{ label: string; value: string } | null>(null)
|
||||||
|
|
||||||
|
// Presence composable
|
||||||
|
const instanceIdRef = computed(() => selectedInstance.value?.value || null)
|
||||||
|
const {
|
||||||
|
subscribeToPresence,
|
||||||
|
sendTyping,
|
||||||
|
sendRecording,
|
||||||
|
stopIndicator,
|
||||||
|
getPresenceText
|
||||||
|
} = usePresence(instanceIdRef)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedChat = ref<any>(null)
|
const selectedChat = ref<any>(null)
|
||||||
const chats = ref<any[]>([])
|
const chats = ref<any[]>([])
|
||||||
@@ -251,6 +266,11 @@ watch(selectedChat, async (chat) => {
|
|||||||
loadingMessages.value = true
|
loadingMessages.value = true
|
||||||
try {
|
try {
|
||||||
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}`)
|
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}`)
|
||||||
|
|
||||||
|
// Subscribe to presence updates for this chat (if not a group)
|
||||||
|
if (!chat.isGroup && chat.jid) {
|
||||||
|
await subscribeToPresence(chat.jid)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading messages:', e)
|
console.error('Error loading messages:', e)
|
||||||
messages.value = []
|
messages.value = []
|
||||||
@@ -259,6 +279,12 @@ watch(selectedChat, async (chat) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed presence text for current chat
|
||||||
|
const currentChatPresence = computed(() => {
|
||||||
|
if (!selectedChat.value?.jid) return null
|
||||||
|
return getPresenceText(selectedChat.value.jid)
|
||||||
|
})
|
||||||
|
|
||||||
const filteredChats = computed(() => {
|
const filteredChats = computed(() => {
|
||||||
if (!searchQuery.value) return chats.value
|
if (!searchQuery.value) return chats.value
|
||||||
return chats.value.filter(chat =>
|
return chats.value.filter(chat =>
|
||||||
@@ -382,9 +408,37 @@ const handleReply = (message: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle react action
|
// Handle react action
|
||||||
const handleReact = (message: any) => {
|
const handleReact = async (message: any, emoji: string) => {
|
||||||
// TODO: Show emoji picker
|
if (!selectedInstance.value?.value) return
|
||||||
console.log('React to:', message)
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/messages/${selectedInstance.value.value}/react`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
messageId: message.messageId,
|
||||||
|
emoji
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error sending reaction:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle typing event from input
|
||||||
|
const handleTyping = () => {
|
||||||
|
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
|
||||||
|
sendTyping(selectedChat.value.jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recording presence
|
||||||
|
const handleRecordingPresence = (isRecording: boolean) => {
|
||||||
|
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
sendRecording(selectedChat.value.jid)
|
||||||
|
} else {
|
||||||
|
stopIndicator(selectedChat.value.jid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload chats for current instance
|
// Reload chats for current instance
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
'message.status': (data: any) => {
|
'message.status': (data: any) => {
|
||||||
res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`)
|
res.write(`event: message.status\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
},
|
||||||
|
'message.reaction': (data: any) => {
|
||||||
|
res.write(`event: message.reaction\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
},
|
||||||
|
'presence.update': (data: any) => {
|
||||||
|
res.write(`event: presence.update\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface ChatRow {
|
|||||||
unread_count: number
|
unread_count: number
|
||||||
last_message_at: Date | null
|
last_message_at: Date | null
|
||||||
last_message: string | null
|
last_message: string | null
|
||||||
|
last_message_type: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|||||||
62
server/api/messages/[instanceId]/react.post.ts
Normal file
62
server/api/messages/[instanceId]/react.post.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/messages/:instanceId/react
|
||||||
|
* Send a reaction to a message
|
||||||
|
*/
|
||||||
|
import { baileysManager } from '../../../services/baileys/manager'
|
||||||
|
import { query } from '../../../utils/database'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const username = getHeader(event, 'x-authentik-username')
|
||||||
|
if (!username) {
|
||||||
|
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = getRouterParam(event, 'instanceId')
|
||||||
|
if (!instanceId) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing instanceId' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ messageId: string; emoji: string }>(event)
|
||||||
|
if (!body?.messageId) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing messageId in request body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// emoji can be empty string to remove reaction
|
||||||
|
if (body.emoji === undefined) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing emoji in request body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the message to find its JID
|
||||||
|
const msgResult = await query<{ raw_message: any }>(
|
||||||
|
`SELECT raw_message FROM messages WHERE instance_id = $1 AND message_id = $2`,
|
||||||
|
[instanceId, body.messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (msgResult.rows.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Message not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessage = msgResult.rows[0].raw_message
|
||||||
|
const jid = rawMessage?.key?.remoteJid
|
||||||
|
|
||||||
|
if (!jid) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Could not determine message JID' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await baileysManager.sendReaction(instanceId, jid, body.messageId, body.emoji)
|
||||||
|
|
||||||
|
return { success: true, messageId: body.messageId, emoji: body.emoji }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[React API] Error sending reaction:', error)
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message || 'Error sending reaction'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
42
server/api/presence/[instanceId]/[jid].get.ts
Normal file
42
server/api/presence/[instanceId]/[jid].get.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/presence/:instanceId/:jid
|
||||||
|
* Get cached presence for a contact
|
||||||
|
*/
|
||||||
|
import { query } from '../../../utils/database'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const username = getHeader(event, 'x-authentik-username')
|
||||||
|
if (!username) {
|
||||||
|
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = getRouterParam(event, 'instanceId')
|
||||||
|
const jid = getRouterParam(event, 'jid')
|
||||||
|
|
||||||
|
if (!instanceId || !jid) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing instanceId or jid' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await query<{ presence: string; last_seen: Date }>(
|
||||||
|
`SELECT presence, last_seen FROM presence_cache
|
||||||
|
WHERE instance_id = $1 AND jid = $2`,
|
||||||
|
[instanceId, jid]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return { presence: null, lastSeen: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
presence: result.rows[0].presence,
|
||||||
|
lastSeen: result.rows[0].last_seen
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Presence API] Error getting presence:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Error getting presence'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
44
server/api/presence/[instanceId]/send.post.ts
Normal file
44
server/api/presence/[instanceId]/send.post.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/presence/:instanceId/send
|
||||||
|
* Send presence update (composing, recording, available, unavailable, paused)
|
||||||
|
*/
|
||||||
|
import { baileysManager } from '../../../services/baileys/manager'
|
||||||
|
|
||||||
|
type PresenceType = 'composing' | 'recording' | 'available' | 'unavailable' | 'paused'
|
||||||
|
|
||||||
|
const VALID_PRESENCES: PresenceType[] = ['composing', 'recording', 'available', 'unavailable', 'paused']
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const username = getHeader(event, 'x-authentik-username')
|
||||||
|
if (!username) {
|
||||||
|
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = getRouterParam(event, 'instanceId')
|
||||||
|
if (!instanceId) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing instanceId' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ jid: string; presence: PresenceType }>(event)
|
||||||
|
if (!body?.jid) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing jid in request body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body?.presence || !VALID_PRESENCES.includes(body.presence)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Invalid presence. Must be one of: ${VALID_PRESENCES.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await baileysManager.sendPresence(instanceId, body.jid, body.presence)
|
||||||
|
return { success: true, jid: body.jid, presence: body.presence }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Presence API] Error sending presence:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message || 'Error sending presence'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
33
server/api/presence/[instanceId]/subscribe.post.ts
Normal file
33
server/api/presence/[instanceId]/subscribe.post.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/presence/:instanceId/subscribe
|
||||||
|
* Subscribe to presence updates for a contact
|
||||||
|
*/
|
||||||
|
import { baileysManager } from '../../../services/baileys/manager'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const username = getHeader(event, 'x-authentik-username')
|
||||||
|
if (!username) {
|
||||||
|
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceId = getRouterParam(event, 'instanceId')
|
||||||
|
if (!instanceId) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing instanceId' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ jid: string }>(event)
|
||||||
|
if (!body?.jid) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Missing jid in request body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await baileysManager.subscribeToPresence(instanceId, body.jid)
|
||||||
|
return { success: true, jid: body.jid }
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Presence API] Error subscribing:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: error.message || 'Error subscribing to presence'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -58,7 +58,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||||
|
|
||||||
const response = await fetch(webhook.url, {
|
// Check if the URL is pointing to our own debug receiver
|
||||||
|
// If so, use internal URL to bypass authentik
|
||||||
|
let targetUrl = webhook.url
|
||||||
|
if (webhook.url.includes('/api/debug/webhook-receiver')) {
|
||||||
|
// Use internal URL (works in Docker/local environments)
|
||||||
|
const internalPort = process.env.PORT || 3000
|
||||||
|
targetUrl = `http://localhost:${internalPort}/api/debug/webhook-receiver`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -123,11 +123,19 @@ class WebhookDispatcher {
|
|||||||
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the URL is pointing to our own debug receiver
|
||||||
|
// If so, use internal URL to bypass authentik
|
||||||
|
let targetUrl = webhook.url
|
||||||
|
if (webhook.url.includes('/api/debug/webhook-receiver')) {
|
||||||
|
const internalPort = process.env.PORT || 3000
|
||||||
|
targetUrl = `http://localhost:${internalPort}/api/debug/webhook-receiver`
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
|
const timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(webhook.url, {
|
const response = await fetch(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
|||||||
Reference in New Issue
Block a user