webapp/src/components/nostr/ChatComponent.vue
padreug 55e051146e feat: Implement message addition callback and enhance scrolling behavior
- Introduce a callback for when new messages are added, allowing for automatic scrolling to the bottom of the chat when relevant peers are selected.
- Update the ChatComponent to reference the scrolling area correctly and improve the logic for scrolling to the latest messages.
- Enhance console logging for better debugging and tracking of message flow and scrolling actions.
2025-08-10 10:50:14 +02:00

510 lines
No EOL
16 KiB
Vue

<template>
<div class="flex flex-col h-full">
<!-- Mobile: Peer List View -->
<div v-if="isMobile && (!selectedPeer || !showChat)" class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold">Chat</h2>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
<span class="hidden sm:inline ml-2">Refresh</span>
</Button>
</div>
<!-- Peer List -->
<div class="flex-1 overflow-hidden">
<div class="p-4 border-b">
<h3 class="font-medium">Peers ({{ peers.length }})</h3>
</div>
<ScrollArea class="h-full">
<div class="p-2 space-y-1">
<div
v-for="peer in peers"
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted active:bg-muted/80'
]"
>
<Avatar class="h-10 w-10 sm:h-8 sm:w-8">
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ peer.username || 'Unknown User' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
</div>
</div>
</ScrollArea>
</div>
</div>
<!-- Mobile: Chat View -->
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
<!-- Chat Header with Back Button -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
@click="goBackToPeers"
class="mr-2"
>
<ArrowLeft class="h-5 w-5" />
</Button>
<Avatar class="h-8 w-8">
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
</Avatar>
<div>
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
<p class="text-xs text-muted-foreground">
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
</p>
</div>
</div>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
</div>
<!-- Messages -->
<ScrollArea class="flex-1 p-4" ref="messagesScrollArea">
<div class="space-y-4">
<div
v-for="message in currentMessages"
:key="message.id"
:class="[
'flex',
message.sent ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-[75%] px-4 py-2 rounded-lg',
message.sent
? 'bg-primary text-primary-foreground'
: 'bg-muted'
]"
>
<p class="text-sm break-words">{{ message.content }}</p>
<p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }}
</p>
</div>
</div>
</div>
<!-- Hidden element at bottom for scrolling -->
<div ref="scrollTarget" class="h-1" />
</ScrollArea>
<!-- Message Input -->
<div class="p-4 border-t bg-background">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<Input
v-model="messageInput"
placeholder="Type a message..."
:disabled="!isConnected || !selectedPeer"
class="flex-1"
/>
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
<Send class="h-4 w-4" />
</Button>
</form>
</div>
</div>
<!-- Desktop: Split View -->
<div v-else-if="!isMobile" class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold">Chat</h2>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
Refresh Peers
</Button>
</div>
<!-- Main Content -->
<div class="flex flex-1 overflow-hidden">
<!-- Peer List -->
<div class="w-80 border-r bg-muted/30">
<div class="p-4 border-b">
<h3 class="font-medium">Peers ({{ peers.length }})</h3>
</div>
<ScrollArea class="h-full">
<div class="p-2 space-y-1">
<div
v-for="peer in peers"
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
]"
>
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ peer.username || 'Unknown User' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
</div>
</div>
</ScrollArea>
</div>
<!-- Chat Area -->
<div class="flex-1 flex flex-col">
<!-- Chat Header - Always present to maintain layout -->
<div class="p-4 border-b" :class="{ 'h-16': !selectedPeer }">
<div v-if="selectedPeer" class="flex items-center space-x-3">
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
<AvatarFallback>{{ getPeerInitials(selectedPeer) }}</AvatarFallback>
</Avatar>
<div>
<h3 class="font-medium">{{ selectedPeer.username || 'Unknown User' }}</h3>
<p class="text-sm text-muted-foreground">
{{ formatPubkey(selectedPeer.pubkey) }}
</p>
</div>
</div>
<div v-else class="h-8"></div>
</div>
<!-- Messages -->
<ScrollArea v-if="selectedPeer" class="flex-1 p-4" ref="messagesScrollArea">
<div class="space-y-4" ref="messagesContainer">
<div
v-for="message in currentMessages"
:key="message.id"
:class="[
'flex',
message.sent ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
message.sent
? 'bg-primary text-primary-foreground'
: 'bg-muted'
]"
>
<p class="text-sm">{{ message.content }}</p>
<p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }}
</p>
</div>
</div>
</div>
<!-- Hidden element at bottom for scrolling -->
<div ref="scrollTarget" class="h-1" />
</ScrollArea>
<!-- Message Input -->
<div v-if="selectedPeer" class="p-4 border-t">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<Input
v-model="messageInput"
placeholder="Type a message..."
:disabled="!isConnected || !selectedPeer"
class="flex-1"
/>
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
<Send class="h-4 w-4" />
</Button>
</form>
</div>
<!-- No Peer Selected -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<MessageSquare class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">No peer selected</h3>
<p class="text-muted-foreground">
Select a peer from the list to start chatting
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Send, RefreshCw, MessageSquare, ArrowLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useNostrChat } from '@/composables/useNostrChat'
import { getAuthToken } from '@/lib/config/lnbits'
import { config } from '@/lib/config'
// Types
interface Peer {
user_id: string
username: string
pubkey: string
}
interface ChatMessage {
id: string
content: string
created_at: number
sent: boolean
}
// State
const peers = ref<Peer[]>([])
const selectedPeer = ref<Peer | null>(null)
const messageInput = ref('')
const isLoading = ref(false)
const showChat = ref(false)
const messagesScrollArea = ref<HTMLElement | null>(null)
const messagesContainer = ref<HTMLElement | null>(null)
const scrollTarget = ref<HTMLElement | null>(null)
// Mobile detection
const isMobile = ref(false)
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 768 // md breakpoint
}
// Mobile navigation
const goBackToPeers = () => {
showChat.value = false
selectedPeer.value = null
}
// Nostr chat composable
const {
isConnected,
messages,
sendMessage: sendNostrMessage,
connect,
disconnect,
subscribeToPeer,
onMessageAdded
} = useNostrChat()
// Computed
const currentMessages = computed(() => {
if (!selectedPeer.value) return []
return messages.value.get(selectedPeer.value.pubkey) || []
})
// Methods
const loadPeers = async () => {
try {
isLoading.value = true
const authToken = getAuthToken()
if (!authToken) {
console.warn('No authentication token found - cannot load peers')
return
}
const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006'
const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
})
console.log('Peers API Response status:', response.status)
if (!response.ok) {
const errorText = await response.text()
console.error('Peers API Error:', response.status, errorText)
throw new Error(`Failed to load peers: ${response.status}`)
}
const responseText = await response.text()
console.log('Peers API Response text:', responseText)
try {
const data = JSON.parse(responseText)
peers.value = data.map((peer: any) => ({
user_id: peer.user_id,
username: peer.username,
pubkey: peer.pubkey
}))
console.log(`Loaded ${peers.value.length} peers`)
} catch (parseError) {
console.error('JSON Parse Error for peers:', parseError)
console.error('Response was:', responseText)
throw new Error('Invalid JSON response from peers API')
}
} catch (error) {
console.error('Failed to load peers:', error)
} finally {
isLoading.value = false
}
}
const refreshPeers = () => {
loadPeers()
}
const selectPeer = async (peer: Peer) => {
selectedPeer.value = peer
messageInput.value = ''
// On mobile, show chat view
if (isMobile.value) {
showChat.value = true
}
// Subscribe to messages from this peer
await subscribeToPeer(peer.pubkey)
// Scroll to bottom to show latest messages when selecting a peer
nextTick(() => {
scrollToBottom()
})
}
const sendMessage = async () => {
if (!selectedPeer.value || !messageInput.value.trim()) return
try {
await sendNostrMessage(selectedPeer.value.pubkey, messageInput.value)
messageInput.value = ''
// Scroll to bottom
nextTick(() => {
scrollToBottom()
})
} catch (error) {
console.error('Failed to send message:', error)
}
}
const scrollToBottom = () => {
console.log('scrollToBottom called')
nextTick(() => {
if (scrollTarget.value) {
console.log('Found scrollTarget, scrolling to bottom')
// Use scrollIntoView on the target element
scrollTarget.value.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
} else {
console.log('No scrollTarget found')
}
})
}
const formatPubkey = (pubkey: string) => {
return pubkey.slice(0, 8) + '...' + pubkey.slice(-8)
}
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const getPeerAvatar = (peer: Peer) => {
// You can implement avatar logic here
return null
}
const getPeerInitials = (peer: Peer) => {
if (peer.username) {
return peer.username.slice(0, 2).toUpperCase()
}
return peer.pubkey.slice(0, 2).toUpperCase()
}
// Lifecycle
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Set up message callback
onMessageAdded.value = (peerPubkey: string) => {
console.log('Message added callback triggered for peer:', peerPubkey)
if (selectedPeer.value && selectedPeer.value.pubkey === peerPubkey) {
console.log('Triggering scroll for current peer')
nextTick(() => {
scrollToBottom()
})
}
}
await connect()
await loadPeers()
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
disconnect()
})
// Watch for new messages and scroll to bottom
watch(currentMessages, (newMessages, oldMessages) => {
console.log('Messages changed:', {
newLength: newMessages.length,
oldLength: oldMessages?.length,
isNewMessage: !oldMessages || newMessages.length > oldMessages.length
})
// Scroll to bottom when new messages are added
if (newMessages.length > 0 && (!oldMessages || newMessages.length > oldMessages.length)) {
console.log('Triggering scroll to bottom')
nextTick(() => {
scrollToBottom()
})
}
})
</script>