diff --git a/app/components/messages/ChatItem.vue b/app/components/messages/ChatItem.vue
index 79f4574..0bdc273 100644
--- a/app/components/messages/ChatItem.vue
+++ b/app/components/messages/ChatItem.vue
@@ -27,10 +27,13 @@
-
{{ chat.lastMessage }}
+
+
+ {{ lastMessagePreview }}
+
{{ chat.unreadCount }}
@@ -66,7 +69,9 @@ interface Chat {
profilePicture?: string
lastMessage: string
lastMessageAt: Date
+ lastMessageType?: string
unreadCount: number
+ isGroup?: boolean
}
interface Props {
@@ -74,13 +79,57 @@ interface Props {
active?: boolean
}
-defineProps
()
+const props = defineProps()
defineEmits<{
click: []
}>()
const showDebug = ref(false)
+// Message type to icon mapping
+const messageTypeIcons: Record = {
+ 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 = {
+ 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) => {
try {
await navigator.clipboard.writeText(text)
diff --git a/app/components/messages/MessageBubble.vue b/app/components/messages/MessageBubble.vue
index a1fce7e..239a4ed 100644
--- a/app/components/messages/MessageBubble.vue
+++ b/app/components/messages/MessageBubble.vue
@@ -154,14 +154,22 @@
>
-
+
+
+
+
@@ -165,6 +166,8 @@ const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string]
sendVoice: [audioFile: File]
cancelReply: []
+ typing: []
+ recording: [isRecording: boolean]
}>()
// Refs for file inputs
@@ -266,18 +269,21 @@ const handleSend = () => {
const startRecording = async () => {
const success = await startAudioRecording()
- if (!success) {
- // Show error toast
+ 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(() => {
diff --git a/app/components/messages/ReactionPicker.vue b/app/components/messages/ReactionPicker.vue
new file mode 100644
index 0000000..b6939da
--- /dev/null
+++ b/app/components/messages/ReactionPicker.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
Elegir reacciรณn
+
+
+
+
+
+
+
+ Cancelar
+
+
+
+
+
+
+
diff --git a/app/composables/usePresence.ts b/app/composables/usePresence.ts
new file mode 100644
index 0000000..1d8d2ae
--- /dev/null
+++ b/app/composables/usePresence.ts
@@ -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) {
+ const { on } = useRealtime()
+
+ // Store of presence states by JID
+ const presences = ref({})
+
+ // Debounce timer for composing presence
+ let composingTimeout: NodeJS.Timeout | null = null
+
+ // Subscribe to presence updates for a contact
+ const subscribeToPresence = async (jid: string): Promise => {
+ 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 => {
+ 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 => {
+ 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 => {
+ await sendPresence(jid, 'recording')
+ }
+
+ // Stop typing/recording indicator
+ const stopIndicator = async (jid: string): Promise => {
+ 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
+ }
+}
diff --git a/app/pages/messages/index.vue b/app/pages/messages/index.vue
index c1daafb..26e922c 100644
--- a/app/pages/messages/index.vue
+++ b/app/pages/messages/index.vue
@@ -131,7 +131,10 @@
{{ selectedChat.name }}
-
{{ selectedChat.jid }}
+
+ {{ currentChatPresence }}
+
+
{{ selectedChat.jid }}
@@ -199,6 +204,16 @@ const { instances, fetchInstances } = useInstances()
const { isConnected, lastEvent, on } = useRealtime()
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 selectedChat = ref(null)
const chats = ref([])
@@ -251,6 +266,11 @@ watch(selectedChat, async (chat) => {
loadingMessages.value = true
try {
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) {
console.error('Error loading messages:', e)
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(() => {
if (!searchQuery.value) return chats.value
return chats.value.filter(chat =>
@@ -382,9 +408,37 @@ const handleReply = (message: any) => {
}
// Handle react action
-const handleReact = (message: any) => {
- // TODO: Show emoji picker
- console.log('React to:', message)
+const handleReact = async (message: any, emoji: string) => {
+ if (!selectedInstance.value?.value) return
+
+ 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
diff --git a/server/api/events/stream.get.ts b/server/api/events/stream.get.ts
index 2bab420..b46b1cf 100644
--- a/server/api/events/stream.get.ts
+++ b/server/api/events/stream.get.ts
@@ -40,6 +40,12 @@ export default defineEventHandler(async (event) => {
},
'message.status': (data: any) => {
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`)
}
}
diff --git a/server/api/messages/[instanceId]/chats.get.ts b/server/api/messages/[instanceId]/chats.get.ts
index ede07db..87d4b9a 100644
--- a/server/api/messages/[instanceId]/chats.get.ts
+++ b/server/api/messages/[instanceId]/chats.get.ts
@@ -12,6 +12,7 @@ interface ChatRow {
unread_count: number
last_message_at: Date | null
last_message: string | null
+ last_message_type: string | null
}
export default defineEventHandler(async (event) => {
diff --git a/server/api/messages/[instanceId]/react.post.ts b/server/api/messages/[instanceId]/react.post.ts
new file mode 100644
index 0000000..bc86ae2
--- /dev/null
+++ b/server/api/messages/[instanceId]/react.post.ts
@@ -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'
+ })
+ }
+})
diff --git a/server/api/presence/[instanceId]/[jid].get.ts b/server/api/presence/[instanceId]/[jid].get.ts
new file mode 100644
index 0000000..aab7859
--- /dev/null
+++ b/server/api/presence/[instanceId]/[jid].get.ts
@@ -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'
+ })
+ }
+})
diff --git a/server/api/presence/[instanceId]/send.post.ts b/server/api/presence/[instanceId]/send.post.ts
new file mode 100644
index 0000000..e603ea6
--- /dev/null
+++ b/server/api/presence/[instanceId]/send.post.ts
@@ -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'
+ })
+ }
+})
diff --git a/server/api/presence/[instanceId]/subscribe.post.ts b/server/api/presence/[instanceId]/subscribe.post.ts
new file mode 100644
index 0000000..fc372b9
--- /dev/null
+++ b/server/api/presence/[instanceId]/subscribe.post.ts
@@ -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'
+ })
+ }
+})
diff --git a/server/api/webhooks/[id]/test.post.ts b/server/api/webhooks/[id]/test.post.ts
index 4a2ff65..45438e9 100644
--- a/server/api/webhooks/[id]/test.post.ts
+++ b/server/api/webhooks/[id]/test.post.ts
@@ -58,7 +58,16 @@ export default defineEventHandler(async (event) => {
const controller = new AbortController()
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',
headers,
body,
diff --git a/server/services/webhooks/dispatcher.ts b/server/services/webhooks/dispatcher.ts
index 0cd5a88..6e79d79 100644
--- a/server/services/webhooks/dispatcher.ts
+++ b/server/services/webhooks/dispatcher.ts
@@ -123,11 +123,19 @@ class WebhookDispatcher {
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 timeout = setTimeout(() => controller.abort(), webhook.timeout_ms)
try {
- const response = await fetch(webhook.url, {
+ const response = await fetch(targetUrl, {
method: 'POST',
headers,
body,