- 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.
510 lines
No EOL
16 KiB
Vue
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> |