Refactor imports and remove legacy composables for improved code clarity
- Simplify imports in app.ts by removing unused SERVICE_TOKENS. - Eliminate the NavigationItem interface in Navbar.vue as its functionality is now managed by useModularNavigation. - Introduce new legacy composable stubs for useNostrChat and useRelayHub, indicating a shift towards modular chat and relay services. - Update MyTicketsPage.vue to correct the import path for useUserTickets, enhancing module organization. - Refactor ChatService to improve type handling for event tags, ensuring better type safety. Remove ChatComponent, useNostrChat composable, and ChatPage for a modular chat architecture - Delete ChatComponent.vue to streamline chat functionality. - Remove legacy useNostrChat composable, transitioning to a more modular chat service approach. - Eliminate ChatPage.vue as part of the refactor to enhance code organization and maintainability.
This commit is contained in:
parent
fbac1e079e
commit
ee8dd37761
7 changed files with 12 additions and 657 deletions
|
|
@ -4,7 +4,7 @@ import { createPinia } from 'pinia'
|
||||||
// Core plugin system
|
// Core plugin system
|
||||||
import { pluginManager } from './core/plugin-manager'
|
import { pluginManager } from './core/plugin-manager'
|
||||||
import { eventBus } from './core/event-bus'
|
import { eventBus } from './core/event-bus'
|
||||||
import { container, SERVICE_TOKENS } from './core/di-container'
|
import { container } from './core/di-container'
|
||||||
|
|
||||||
// App configuration
|
// App configuration
|
||||||
import appConfig from './app.config'
|
import appConfig from './app.config'
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,7 @@ import { useMarketStore } from '@/stores/market'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useModularNavigation } from '@/composables/useModularNavigation'
|
import { useModularNavigation } from '@/composables/useModularNavigation'
|
||||||
|
|
||||||
interface NavigationItem {
|
// NavigationItem interface removed as it's handled by useModularNavigation
|
||||||
name: string
|
|
||||||
href: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
|
||||||
|
|
@ -1,627 +0,0 @@
|
||||||
<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>
|
|
||||||
7
src/composables/useRelayHub.ts
Normal file
7
src/composables/useRelayHub.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Legacy composable stub - replaced by modular base module services
|
||||||
|
export function useRelayHub() {
|
||||||
|
return {
|
||||||
|
connectedRelays: [],
|
||||||
|
status: 'disconnected'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { eventBus } from '@/core/event-bus'
|
import { eventBus } from '@/core/event-bus'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { nip04, getEventHash, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
|
import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
|
||||||
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types'
|
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types'
|
||||||
import { getAuthToken } from '@/lib/config/lnbits'
|
import { getAuthToken } from '@/lib/config/lnbits'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
|
@ -439,7 +439,7 @@ export class ChatService {
|
||||||
try {
|
try {
|
||||||
const isFromUs = event.pubkey === userPubkey
|
const isFromUs = event.pubkey === userPubkey
|
||||||
const peerPubkey = isFromUs
|
const peerPubkey = isFromUs
|
||||||
? event.tags.find(tag => tag[0] === 'p')?.[1] // Get recipient from tag
|
? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag
|
||||||
: event.pubkey // Sender is the peer
|
: event.pubkey // Sender is the peer
|
||||||
|
|
||||||
if (!peerPubkey || peerPubkey === userPubkey) continue
|
if (!peerPubkey || peerPubkey === userPubkey) continue
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<!-- eslint-disable vue/multi-word-component-names -->
|
<!-- eslint-disable vue/multi-word-component-names -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { useUserTickets } from '@/composables/useUserTickets'
|
import { useUserTickets } from '../composables/useUserTickets'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="container mx-auto p-4 h-[calc(100vh-3.5rem-2rem)] lg:h-[calc(100vh-4rem-2rem)] xl:h-[calc(100vh-5rem-2rem)]">
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h1 class="text-2xl font-bold">Nostr Chat</h1>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Chat with other LNBits users using Nostr relays
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat Component -->
|
|
||||||
<div class="flex-1 border rounded-lg overflow-hidden">
|
|
||||||
<ChatComponent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ChatComponent from '@/components/nostr/ChatComponent.vue'
|
|
||||||
</script>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue