Enhance market module with new chat and events features
- Introduce chat module with components, services, and composables for real-time messaging. - Implement events module with API service, components, and ticket purchasing functionality. - Update app configuration to include new modules and their respective settings. - Refactor existing components to integrate with the new chat and events features. - Enhance market store and services to support new functionalities and improve order management. - Update routing to accommodate new views for chat and events, ensuring seamless navigation.
This commit is contained in:
parent
519a9003d4
commit
e40ac91417
46 changed files with 6305 additions and 3264 deletions
627
src/modules/chat/components/ChatComponent.vue
Normal file
627
src/modules/chat/components/ChatComponent.vue
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
<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>
|
||||
<!-- Total unread count -->
|
||||
<Badge v-if="totalUnreadCount > 0" class="bg-blue-500 text-white text-xs">
|
||||
{{ totalUnreadCount }} unread
|
||||
</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 flex flex-col overflow-hidden">
|
||||
<div class="p-4 border-b flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-medium">Peers ({{ filteredPeers.length }})</h3>
|
||||
<span v-if="isSearching" class="text-xs text-muted-foreground">
|
||||
{{ resultCount }} found
|
||||
</span>
|
||||
</div>
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search peers by name or pubkey..."
|
||||
class="pl-10 pr-10"
|
||||
/>
|
||||
<div v-if="searchQuery" class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearSearch"
|
||||
class="h-6 w-6 p-0 hover:bg-muted"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<span class="sr-only">Clear search</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea class="flex-1">
|
||||
<div class="p-2 space-y-1">
|
||||
<!-- No results message -->
|
||||
<div v-if="isSearching && filteredPeers.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Search class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">No peers found matching "{{ searchQuery }}"</p>
|
||||
<p class="text-xs mt-1">Try searching by username or pubkey</p>
|
||||
</div>
|
||||
|
||||
<!-- Peer list -->
|
||||
<div
|
||||
v-for="peer in filteredPeers"
|
||||
:key="peer.user_id"
|
||||
@click="selectPeer(peer)"
|
||||
:class="[
|
||||
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation relative',
|
||||
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>
|
||||
<!-- Unread message indicator -->
|
||||
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
|
||||
<Badge class="bg-blue-500 text-white h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
|
||||
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
|
||||
</Badge>
|
||||
</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>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Badge v-if="isConnected" variant="default" class="text-xs">
|
||||
Connected
|
||||
</Badge>
|
||||
<Badge v-else variant="secondary" class="text-xs">
|
||||
Disconnected
|
||||
</Badge>
|
||||
<!-- Unread count for current peer -->
|
||||
<Badge v-if="selectedPeer && getUnreadCount(selectedPeer.pubkey) > 0" class="bg-blue-500 text-white text-xs">
|
||||
{{ getUnreadCount(selectedPeer.pubkey) }} unread
|
||||
</Badge>
|
||||
</div>
|
||||
</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-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 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>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Full Layout -->
|
||||
<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>
|
||||
<!-- Total unread count -->
|
||||
<Badge v-if="totalUnreadCount > 0" class="bg-blue-500 text-white text-xs">
|
||||
{{ totalUnreadCount }} unread
|
||||
</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 flex flex-col">
|
||||
<div class="p-4 border-b flex-shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-medium">Peers ({{ filteredPeers.length }})</h3>
|
||||
<span v-if="isSearching" class="text-xs text-muted-foreground">
|
||||
{{ resultCount }} found
|
||||
</span>
|
||||
</div>
|
||||
<!-- Search Input -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search peers by name or pubkey..."
|
||||
class="pl-10 pr-10"
|
||||
/>
|
||||
<div v-if="searchQuery" class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearSearch"
|
||||
class="h-6 w-6 p-0 hover:bg-muted"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
<span class="sr-only">Clear search</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea class="flex-1">
|
||||
<div class="p-2 space-y-1">
|
||||
<!-- No results message -->
|
||||
<div v-if="isSearching && filteredPeers.length === 0" class="text-center py-8 text-muted-foreground">
|
||||
<Search class="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">No peers found matching "{{ searchQuery }}"</p>
|
||||
<p class="text-xs mt-1">Try searching by username or pubkey</p>
|
||||
</div>
|
||||
|
||||
<!-- Peer list -->
|
||||
<div
|
||||
v-for="peer in filteredPeers"
|
||||
:key="peer.user_id"
|
||||
@click="selectPeer(peer)"
|
||||
:class="[
|
||||
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors relative',
|
||||
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>
|
||||
<!-- Unread message indicator -->
|
||||
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
|
||||
<Badge class="bg-blue-500 text-white h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
|
||||
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
|
||||
</Badge>
|
||||
</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, Search, X } 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 { nostrChat } from '@/composables/useNostrChat'
|
||||
|
||||
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||
|
||||
// Types
|
||||
interface Peer {
|
||||
user_id: string
|
||||
username: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
// State
|
||||
const peers = computed(() => nostrChat.peers.value)
|
||||
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)
|
||||
|
||||
// Nostr chat composable (singleton)
|
||||
const {
|
||||
isConnected,
|
||||
messages,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribeToPeer,
|
||||
sendMessage: sendNostrMessage,
|
||||
onMessageAdded,
|
||||
markMessagesAsRead,
|
||||
getUnreadCount,
|
||||
totalUnreadCount,
|
||||
getLatestMessageTimestamp
|
||||
} = nostrChat
|
||||
|
||||
// Computed
|
||||
const currentMessages = computed(() => {
|
||||
if (!selectedPeer.value) return []
|
||||
const peerMessages = messages.value.get(selectedPeer.value.pubkey) || []
|
||||
|
||||
// Sort messages by timestamp (oldest first) to ensure chronological order
|
||||
const sortedMessages = [...peerMessages].sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
return sortedMessages
|
||||
})
|
||||
|
||||
// Sort peers by latest message timestamp (newest first) and unread status
|
||||
const sortedPeers = computed(() => {
|
||||
const sorted = [...peers.value].sort((a, b) => {
|
||||
const aTimestamp = getLatestMessageTimestamp(a.pubkey)
|
||||
const bTimestamp = getLatestMessageTimestamp(b.pubkey)
|
||||
const aUnreadCount = getUnreadCount(a.pubkey)
|
||||
const bUnreadCount = getUnreadCount(b.pubkey)
|
||||
|
||||
// First, sort by unread count (peers with unread messages appear first)
|
||||
if (aUnreadCount > 0 && bUnreadCount === 0) return -1
|
||||
if (aUnreadCount === 0 && bUnreadCount > 0) return 1
|
||||
|
||||
// Then, sort by latest message timestamp (newest first)
|
||||
if (aTimestamp !== bTimestamp) {
|
||||
return bTimestamp - aTimestamp
|
||||
}
|
||||
|
||||
// Finally, sort alphabetically by username for peers with same timestamp
|
||||
return (a.username || '').localeCompare(b.username || '')
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
// Fuzzy search for peers
|
||||
// This integrates the useFuzzySearch composable to provide intelligent search functionality
|
||||
// for finding peers by username or pubkey with typo tolerance and scoring
|
||||
const {
|
||||
searchQuery,
|
||||
filteredItems: filteredPeers,
|
||||
isSearching,
|
||||
resultCount,
|
||||
clearSearch
|
||||
} = useFuzzySearch(sortedPeers, {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'username', weight: 0.7 }, // Username has higher weight for better UX
|
||||
{ name: 'pubkey', weight: 0.3 } // Pubkey has lower weight but still searchable
|
||||
],
|
||||
threshold: 0.3, // Fuzzy matching threshold (0.0 = perfect, 1.0 = match anything)
|
||||
distance: 100, // Maximum distance for fuzzy matching
|
||||
ignoreLocation: true, // Ignore location for better performance
|
||||
useExtendedSearch: false, // Don't use extended search syntax
|
||||
minMatchCharLength: 1, // Minimum characters to match
|
||||
shouldSort: true, // Sort results by relevance
|
||||
findAllMatches: false, // Don't find all matches for performance
|
||||
location: 0, // Start search from beginning
|
||||
isCaseSensitive: false, // Case insensitive search
|
||||
},
|
||||
resultLimit: 50, // Limit results for performance
|
||||
matchAllWhenSearchEmpty: true, // Show all peers when search is empty
|
||||
minSearchLength: 1, // Start searching after 1 character
|
||||
})
|
||||
|
||||
// Check if device is mobile
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth < 768 // md breakpoint
|
||||
}
|
||||
|
||||
// Mobile navigation
|
||||
const goBackToPeers = () => {
|
||||
showChat.value = false
|
||||
selectedPeer.value = null
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
|
||||
|
||||
const refreshPeers = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await nostrChat.loadPeers()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh peers:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectPeer = async (peer: Peer) => {
|
||||
selectedPeer.value = peer
|
||||
messageInput.value = ''
|
||||
|
||||
// Mark messages as read for this peer
|
||||
markMessagesAsRead(peer.pubkey)
|
||||
|
||||
// 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 = () => {
|
||||
nextTick(() => {
|
||||
if (scrollTarget.value) {
|
||||
// Use scrollIntoView on the target element
|
||||
scrollTarget.value.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (selectedPeer.value && selectedPeer.value.pubkey === peerPubkey) {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If not connected, connect
|
||||
if (!isConnected.value) {
|
||||
await connect()
|
||||
}
|
||||
|
||||
// If no peers loaded, load them
|
||||
if (peers.value.length === 0) {
|
||||
await nostrChat.loadPeers()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
disconnect()
|
||||
})
|
||||
|
||||
// Watch for connection state changes
|
||||
watch(isConnected, async () => {
|
||||
// Note: Peer subscriptions are handled by the preloader
|
||||
})
|
||||
|
||||
// Watch for new messages and scroll to bottom
|
||||
watch(currentMessages, (newMessages, oldMessages) => {
|
||||
// Scroll to bottom when new messages are added
|
||||
if (newMessages.length > 0 && (!oldMessages || newMessages.length > oldMessages.length)) {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
82
src/modules/chat/composables/useChat.ts
Normal file
82
src/modules/chat/composables/useChat.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { injectService } from '@/core/di-container'
|
||||
import type { ChatService } from '../services/chat-service'
|
||||
import type { ChatPeer } from '../types'
|
||||
|
||||
// Service token for chat service
|
||||
export const CHAT_SERVICE_TOKEN = Symbol('chatService')
|
||||
|
||||
export function useChat() {
|
||||
const chatService = injectService<ChatService>(CHAT_SERVICE_TOKEN)
|
||||
|
||||
if (!chatService) {
|
||||
throw new Error('ChatService not available. Make sure chat module is installed.')
|
||||
}
|
||||
|
||||
const selectedPeer = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const peers = computed(() => chatService.allPeers.value)
|
||||
const totalUnreadCount = computed(() => chatService.totalUnreadCount.value)
|
||||
|
||||
const currentMessages = computed(() => {
|
||||
return selectedPeer.value ? chatService.getMessages(selectedPeer.value) : []
|
||||
})
|
||||
|
||||
const currentPeer = computed(() => {
|
||||
return selectedPeer.value ? chatService.getPeer(selectedPeer.value) : undefined
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectPeer = (peerPubkey: string) => {
|
||||
selectedPeer.value = peerPubkey
|
||||
chatService.markAsRead(peerPubkey)
|
||||
}
|
||||
|
||||
const sendMessage = async (content: string) => {
|
||||
if (!selectedPeer.value || !content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await chatService.sendMessage(selectedPeer.value, content.trim())
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to send message'
|
||||
console.error('Send message error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addPeer = (pubkey: string, name?: string): ChatPeer => {
|
||||
return chatService.addPeer(pubkey, name)
|
||||
}
|
||||
|
||||
const markAsRead = (peerPubkey: string) => {
|
||||
chatService.markAsRead(peerPubkey)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedPeer,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
peers,
|
||||
totalUnreadCount,
|
||||
currentMessages,
|
||||
currentPeer,
|
||||
|
||||
// Methods
|
||||
selectPeer,
|
||||
sendMessage,
|
||||
addPeer,
|
||||
markAsRead
|
||||
}
|
||||
}
|
||||
106
src/modules/chat/index.ts
Normal file
106
src/modules/chat/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { App } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { container } from '@/core/di-container'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
|
||||
// Import chat components and services
|
||||
import ChatComponent from './components/ChatComponent.vue'
|
||||
import { ChatService } from './services/chat-service'
|
||||
import { useChat, CHAT_SERVICE_TOKEN } from './composables/useChat'
|
||||
import type { ChatConfig } from './types'
|
||||
|
||||
/**
|
||||
* Chat Module Plugin
|
||||
* Provides Nostr-based encrypted chat functionality
|
||||
*/
|
||||
export const chatModule: ModulePlugin = {
|
||||
name: 'chat',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
async install(app: App, options?: { config?: ChatConfig }) {
|
||||
console.log('💬 Installing chat module...')
|
||||
|
||||
const config: ChatConfig = {
|
||||
maxMessages: 500,
|
||||
autoScroll: true,
|
||||
showTimestamps: true,
|
||||
notificationsEnabled: true,
|
||||
soundEnabled: false,
|
||||
...options?.config
|
||||
}
|
||||
|
||||
// Create and register chat service
|
||||
const chatService = new ChatService(config)
|
||||
container.provide(CHAT_SERVICE_TOKEN, chatService)
|
||||
|
||||
// Register global components
|
||||
app.component('ChatComponent', ChatComponent)
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
setupEventListeners(chatService)
|
||||
|
||||
console.log('✅ Chat module installed successfully')
|
||||
},
|
||||
|
||||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling chat module...')
|
||||
|
||||
// Clean up chat service
|
||||
const chatService = container.inject<ChatService>(CHAT_SERVICE_TOKEN)
|
||||
if (chatService) {
|
||||
chatService.destroy()
|
||||
container.remove(CHAT_SERVICE_TOKEN)
|
||||
}
|
||||
|
||||
console.log('✅ Chat module uninstalled')
|
||||
},
|
||||
|
||||
routes: [
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
component: () => import('./views/ChatPage.vue'),
|
||||
meta: {
|
||||
title: 'Nostr Chat',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
components: {
|
||||
ChatComponent
|
||||
},
|
||||
|
||||
composables: {
|
||||
useChat
|
||||
},
|
||||
|
||||
services: {
|
||||
chatService: CHAT_SERVICE_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
// Private function to set up event listeners
|
||||
function setupEventListeners(chatService: ChatService) {
|
||||
// Listen for auth events to clear chat data on logout
|
||||
eventBus.on('auth:logout', () => {
|
||||
chatService.destroy()
|
||||
})
|
||||
|
||||
// Listen for Nostr events that might be chat messages
|
||||
eventBus.on('nostr:event', (event) => {
|
||||
// TODO: Process incoming Nostr events for encrypted DMs
|
||||
console.log('Received Nostr event in chat module:', event)
|
||||
})
|
||||
|
||||
// Emit chat events for other modules to listen to
|
||||
// This is already handled by the ChatService via eventBus
|
||||
}
|
||||
|
||||
export default chatModule
|
||||
|
||||
// Re-export types and composables for external use
|
||||
export type { ChatMessage, ChatPeer, ChatConfig } from './types'
|
||||
export { useChat } from './composables/useChat'
|
||||
240
src/modules/chat/services/chat-service.ts
Normal file
240
src/modules/chat/services/chat-service.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types'
|
||||
|
||||
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
|
||||
const PEERS_KEY = 'nostr-chat-peers'
|
||||
|
||||
export class ChatService {
|
||||
private messages = ref<Map<string, ChatMessage[]>>(new Map())
|
||||
private peers = ref<Map<string, ChatPeer>>(new Map())
|
||||
private config: ChatConfig
|
||||
|
||||
constructor(config: ChatConfig) {
|
||||
this.config = config
|
||||
this.loadPeersFromStorage()
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
get allPeers() {
|
||||
return computed(() => Array.from(this.peers.value.values()))
|
||||
}
|
||||
|
||||
get totalUnreadCount() {
|
||||
return computed(() => {
|
||||
return Array.from(this.peers.value.values())
|
||||
.reduce((total, peer) => total + peer.unreadCount, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// Get messages for a specific peer
|
||||
getMessages(peerPubkey: string): ChatMessage[] {
|
||||
return this.messages.value.get(peerPubkey) || []
|
||||
}
|
||||
|
||||
// Get peer by pubkey
|
||||
getPeer(pubkey: string): ChatPeer | undefined {
|
||||
return this.peers.value.get(pubkey)
|
||||
}
|
||||
|
||||
// Add or update a peer
|
||||
addPeer(pubkey: string, name?: string): ChatPeer {
|
||||
let peer = this.peers.value.get(pubkey)
|
||||
|
||||
if (!peer) {
|
||||
peer = {
|
||||
pubkey,
|
||||
name: name || `User ${pubkey.slice(0, 8)}`,
|
||||
unreadCount: 0,
|
||||
lastSeen: Date.now()
|
||||
}
|
||||
|
||||
this.peers.value.set(pubkey, peer)
|
||||
this.savePeersToStorage()
|
||||
|
||||
eventBus.emit('chat:peer-added', { peer }, 'chat-service')
|
||||
} else if (name && name !== peer.name) {
|
||||
peer.name = name
|
||||
this.savePeersToStorage()
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
// Add a message
|
||||
addMessage(peerPubkey: string, message: ChatMessage): void {
|
||||
if (!this.messages.value.has(peerPubkey)) {
|
||||
this.messages.value.set(peerPubkey, [])
|
||||
}
|
||||
|
||||
const peerMessages = this.messages.value.get(peerPubkey)!
|
||||
|
||||
// Avoid duplicates
|
||||
if (!peerMessages.some(m => m.id === message.id)) {
|
||||
peerMessages.push(message)
|
||||
|
||||
// Sort by timestamp
|
||||
peerMessages.sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
// Limit message count
|
||||
if (peerMessages.length > this.config.maxMessages) {
|
||||
peerMessages.splice(0, peerMessages.length - this.config.maxMessages)
|
||||
}
|
||||
|
||||
// Update peer info
|
||||
const peer = this.addPeer(peerPubkey)
|
||||
peer.lastMessage = message
|
||||
peer.lastSeen = Date.now()
|
||||
|
||||
// Update unread count if message is not sent by us
|
||||
if (!message.sent) {
|
||||
this.updateUnreadCount(peerPubkey, message)
|
||||
}
|
||||
|
||||
// Emit events
|
||||
const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received'
|
||||
eventBus.emit(eventType, { message, peerPubkey }, 'chat-service')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark messages as read for a peer
|
||||
markAsRead(peerPubkey: string): void {
|
||||
const peer = this.peers.value.get(peerPubkey)
|
||||
if (peer && peer.unreadCount > 0) {
|
||||
peer.unreadCount = 0
|
||||
|
||||
// Save unread state
|
||||
const unreadData: UnreadMessageData = {
|
||||
lastReadTimestamp: Date.now(),
|
||||
unreadCount: 0,
|
||||
processedMessageIds: new Set()
|
||||
}
|
||||
this.saveUnreadData(peerPubkey, unreadData)
|
||||
|
||||
eventBus.emit('chat:unread-count-changed', {
|
||||
peerPubkey,
|
||||
count: 0,
|
||||
totalUnread: this.totalUnreadCount.value
|
||||
}, 'chat-service')
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message
|
||||
async sendMessage(peerPubkey: string, content: string): Promise<void> {
|
||||
try {
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
||||
|
||||
if (!relayHub || !authService?.user?.value?.privkey) {
|
||||
throw new Error('Required services not available')
|
||||
}
|
||||
|
||||
// Create message
|
||||
const message: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
sent: true,
|
||||
pubkey: authService.user.value.pubkey
|
||||
}
|
||||
|
||||
// Add to local messages immediately
|
||||
this.addMessage(peerPubkey, message)
|
||||
|
||||
// TODO: Implement actual Nostr message sending
|
||||
// This would involve encrypting the message and publishing to relays
|
||||
console.log('Sending message:', { peerPubkey, content })
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
private updateUnreadCount(peerPubkey: string, message: ChatMessage): void {
|
||||
const unreadData = this.getUnreadData(peerPubkey)
|
||||
|
||||
if (!unreadData.processedMessageIds.has(message.id)) {
|
||||
unreadData.processedMessageIds.add(message.id)
|
||||
unreadData.unreadCount++
|
||||
|
||||
const peer = this.peers.value.get(peerPubkey)
|
||||
if (peer) {
|
||||
peer.unreadCount = unreadData.unreadCount
|
||||
this.savePeersToStorage()
|
||||
}
|
||||
|
||||
this.saveUnreadData(peerPubkey, unreadData)
|
||||
|
||||
eventBus.emit('chat:unread-count-changed', {
|
||||
peerPubkey,
|
||||
count: unreadData.unreadCount,
|
||||
totalUnread: this.totalUnreadCount.value
|
||||
}, 'chat-service')
|
||||
}
|
||||
}
|
||||
|
||||
private getUnreadData(peerPubkey: string): UnreadMessageData {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`)
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
return {
|
||||
...data,
|
||||
processedMessageIds: new Set(data.processedMessageIds || [])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load unread data for peer:', peerPubkey, error)
|
||||
}
|
||||
|
||||
return {
|
||||
lastReadTimestamp: 0,
|
||||
unreadCount: 0,
|
||||
processedMessageIds: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void {
|
||||
try {
|
||||
const serializable = {
|
||||
...data,
|
||||
processedMessageIds: Array.from(data.processedMessageIds)
|
||||
}
|
||||
localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializable))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save unread data for peer:', peerPubkey, error)
|
||||
}
|
||||
}
|
||||
|
||||
private loadPeersFromStorage(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(PEERS_KEY)
|
||||
if (stored) {
|
||||
const peersArray = JSON.parse(stored) as ChatPeer[]
|
||||
peersArray.forEach(peer => {
|
||||
this.peers.value.set(peer.pubkey, peer)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load peers from storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private savePeersToStorage(): void {
|
||||
try {
|
||||
const peersArray = Array.from(this.peers.value.values())
|
||||
localStorage.setItem(PEERS_KEY, JSON.stringify(peersArray))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save peers to storage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy(): void {
|
||||
this.messages.value.clear()
|
||||
this.peers.value.clear()
|
||||
}
|
||||
}
|
||||
57
src/modules/chat/types/index.ts
Normal file
57
src/modules/chat/types/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Chat module types
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string
|
||||
created_at: number
|
||||
sent: boolean
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export interface ChatPeer {
|
||||
pubkey: string
|
||||
name?: string
|
||||
lastMessage?: ChatMessage
|
||||
unreadCount: number
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
export interface NostrRelayConfig {
|
||||
url: string
|
||||
read?: boolean
|
||||
write?: boolean
|
||||
}
|
||||
|
||||
export interface UnreadMessageData {
|
||||
lastReadTimestamp: number
|
||||
unreadCount: number
|
||||
processedMessageIds: Set<string>
|
||||
}
|
||||
|
||||
export interface ChatConfig {
|
||||
maxMessages: number
|
||||
autoScroll: boolean
|
||||
showTimestamps: boolean
|
||||
notificationsEnabled: boolean
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
// Events emitted by chat module
|
||||
export interface ChatEvents {
|
||||
'chat:message-received': {
|
||||
message: ChatMessage
|
||||
peerPubkey: string
|
||||
}
|
||||
'chat:message-sent': {
|
||||
message: ChatMessage
|
||||
peerPubkey: string
|
||||
}
|
||||
'chat:peer-added': {
|
||||
peer: ChatPeer
|
||||
}
|
||||
'chat:unread-count-changed': {
|
||||
peerPubkey: string
|
||||
count: number
|
||||
totalUnread: number
|
||||
}
|
||||
}
|
||||
9
src/modules/chat/views/ChatPage.vue
Normal file
9
src/modules/chat/views/ChatPage.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<div class="h-full w-full">
|
||||
<ChatComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatComponent from '../components/ChatComponent.vue'
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue