Add ProfileService and integrate profiles management into NostrFeed module
- Introduced ProfileService to handle user profiles, including fetching and displaying profile information. - Updated NostrFeed module to register ProfileService in the DI container and initialize it during installation. - Enhanced NostrFeed.vue to utilize the profiles service for displaying user names alongside posts. - Created useProfiles composable for managing profile-related functionality, including fetching and subscribing to profile updates. These changes improve user engagement by providing richer profile information within the feed, enhancing the overall user experience.
This commit is contained in:
parent
310612a2c5
commit
45391cbaa1
6 changed files with 443 additions and 47 deletions
|
|
@ -134,6 +134,7 @@ export const SERVICE_TOKENS = {
|
|||
|
||||
// Feed services
|
||||
FEED_SERVICE: Symbol('feedService'),
|
||||
PROFILE_SERVICE: Symbol('profileService'),
|
||||
|
||||
// Events services
|
||||
EVENTS_SERVICE: Symbol('eventsService'),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Megaphone, RefreshCw, AlertCircle, Reply, Heart, Share } from 'lucide-vue-next'
|
||||
import { useFeed } from '../composables/useFeed'
|
||||
import { useProfiles } from '../composables/useProfiles'
|
||||
import appConfig from '@/app.config'
|
||||
import type { ContentFilter } from '../services/FeedService'
|
||||
import MarketProduct from './MarketProduct.vue'
|
||||
|
|
@ -35,6 +36,17 @@ const { posts: notes, isLoading, error, refreshFeed } = useFeed({
|
|||
contentFilters: props.contentFilters
|
||||
})
|
||||
|
||||
// Use profiles service for display names
|
||||
const { getDisplayName, fetchProfiles } = useProfiles()
|
||||
|
||||
// Watch for new posts and fetch their profiles
|
||||
watch(notes, async (newNotes) => {
|
||||
if (newNotes.length > 0) {
|
||||
const pubkeys = [...new Set(newNotes.map(note => note.pubkey))]
|
||||
await fetchProfiles(pubkeys)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Check if we have admin pubkeys configured
|
||||
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||
|
||||
|
|
@ -175,20 +187,25 @@ function onReplyToNote(note: any) {
|
|||
|
||||
<!-- Posts List - Full height scroll -->
|
||||
<div v-else class="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div class="divide-y-2 divide-muted/30">
|
||||
<div v-for="note in notes" :key="note.id">
|
||||
<div>
|
||||
<div v-for="(note, index) in notes" :key="note.id" :class="{ 'bg-muted/20': index % 2 === 1 }">
|
||||
<!-- Market Product Component (kind 30018) -->
|
||||
<template v-if="note.kind === 30018">
|
||||
<div class="p-3">
|
||||
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
v-if="isAdminPost(note.pubkey)"
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
<span>{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}</span>
|
||||
<div class="p-3 border-b border-border/40">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
v-if="isAdminPost(note.pubkey)"
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
<span class="text-sm font-medium">{{ getDisplayName(note.pubkey) }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||
</span>
|
||||
</div>
|
||||
<MarketProduct
|
||||
v-if="getMarketProductData(note)"
|
||||
|
|
@ -219,31 +236,34 @@ function onReplyToNote(note: any) {
|
|||
<!-- Regular Text Posts and Other Event Types -->
|
||||
<div
|
||||
v-else
|
||||
class="p-3 hover:bg-accent/50 transition-colors"
|
||||
class="p-3 hover:bg-accent/50 transition-colors border-b border-border/40"
|
||||
>
|
||||
<!-- Note Header -->
|
||||
<div class="flex items-center flex-wrap gap-1.5 mb-2">
|
||||
<Badge
|
||||
v-if="isAdminPost(note.pubkey)"
|
||||
variant="default"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="note.isReply"
|
||||
variant="secondary"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
Reply
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isMarketEvent({ kind: note.kind })"
|
||||
variant="outline"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
{{ getMarketEventType({ kind: note.kind }) }}
|
||||
</Badge>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
v-if="isAdminPost(note.pubkey)"
|
||||
variant="default"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="note.isReply"
|
||||
variant="secondary"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
Reply
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isMarketEvent({ kind: note.kind })"
|
||||
variant="outline"
|
||||
class="text-xs px-1.5 py-0.5"
|
||||
>
|
||||
{{ getMarketEventType({ kind: note.kind }) }}
|
||||
</Badge>
|
||||
<span class="text-sm font-medium">{{ getDisplayName(note.pubkey) }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||
</span>
|
||||
|
|
|
|||
90
src/modules/nostr-feed/composables/useProfiles.ts
Normal file
90
src/modules/nostr-feed/composables/useProfiles.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ProfileService } from '../services/ProfileService'
|
||||
|
||||
/**
|
||||
* Composable for managing user profiles in the feed
|
||||
*/
|
||||
export function useProfiles() {
|
||||
const profileService = injectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
|
||||
|
||||
// Reactive state
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Get display name for a pubkey
|
||||
*/
|
||||
const getDisplayName = (pubkey: string): string => {
|
||||
if (!profileService) return formatPubkey(pubkey)
|
||||
return profileService.getDisplayName(pubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profiles for a list of pubkeys
|
||||
*/
|
||||
const fetchProfiles = async (pubkeys: string[]): Promise<void> => {
|
||||
if (!profileService || pubkeys.length === 0) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
await profileService.fetchProfiles(pubkeys)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch profiles'
|
||||
console.error('Failed to fetch profiles:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to profile updates for active users
|
||||
*/
|
||||
const subscribeToProfileUpdates = async (pubkeys: string[]): Promise<void> => {
|
||||
if (!profileService) return
|
||||
|
||||
try {
|
||||
await profileService.subscribeToProfileUpdates(pubkeys)
|
||||
} catch (err) {
|
||||
console.error('Failed to subscribe to profile updates:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full profile for a pubkey
|
||||
*/
|
||||
const getProfile = async (pubkey: string) => {
|
||||
if (!profileService) return null
|
||||
return await profileService.getProfile(pubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pubkey as fallback display name
|
||||
*/
|
||||
const formatPubkey = (pubkey: string): string => {
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached profiles
|
||||
*/
|
||||
const profiles = computed(() => {
|
||||
if (!profileService) return new Map()
|
||||
return profileService.profiles
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
profiles,
|
||||
|
||||
// Methods
|
||||
getDisplayName,
|
||||
fetchProfiles,
|
||||
subscribeToProfileUpdates,
|
||||
getProfile,
|
||||
formatPubkey
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { container, SERVICE_TOKENS } from '@/core/di-container'
|
|||
import NostrFeed from './components/NostrFeed.vue'
|
||||
import { useFeed } from './composables/useFeed'
|
||||
import { FeedService } from './services/FeedService'
|
||||
import { ProfileService } from './services/ProfileService'
|
||||
|
||||
/**
|
||||
* Nostr Feed Module Plugin
|
||||
|
|
@ -17,18 +18,27 @@ export const nostrFeedModule: ModulePlugin = {
|
|||
async install(app: App) {
|
||||
console.log('nostr-feed module: Starting installation...')
|
||||
|
||||
// Register FeedService in DI container
|
||||
// Register services in DI container
|
||||
const feedService = new FeedService()
|
||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||
console.log('nostr-feed module: FeedService registered in DI container')
|
||||
const profileService = new ProfileService()
|
||||
|
||||
// Initialize the service
|
||||
console.log('nostr-feed module: Initializing FeedService...')
|
||||
await feedService.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
})
|
||||
console.log('nostr-feed module: FeedService initialized')
|
||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||
console.log('nostr-feed module: Services registered in DI container')
|
||||
|
||||
// Initialize services
|
||||
console.log('nostr-feed module: Initializing services...')
|
||||
await Promise.all([
|
||||
feedService.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
}),
|
||||
profileService.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
})
|
||||
])
|
||||
console.log('nostr-feed module: Services initialized')
|
||||
|
||||
// Register components globally
|
||||
app.component('NostrFeed', NostrFeed)
|
||||
|
|
|
|||
275
src/modules/nostr-feed/services/ProfileService.ts
Normal file
275
src/modules/nostr-feed/services/ProfileService.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { ref, reactive } from 'vue'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { Event as NostrEvent, Filter } from 'nostr-tools'
|
||||
|
||||
export interface UserProfile {
|
||||
pubkey: string
|
||||
name?: string
|
||||
display_name?: string
|
||||
about?: string
|
||||
picture?: string
|
||||
nip05?: string
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export class ProfileService extends BaseService {
|
||||
protected readonly metadata = {
|
||||
name: 'ProfileService',
|
||||
version: '1.0.0',
|
||||
dependencies: []
|
||||
}
|
||||
|
||||
protected relayHub: any = null
|
||||
|
||||
// Profile cache - reactive for UI updates
|
||||
private _profiles = reactive(new Map<string, UserProfile>())
|
||||
private _isLoading = ref(false)
|
||||
private currentSubscription: string | null = null
|
||||
private currentUnsubscribe: (() => void) | null = null
|
||||
|
||||
// Track which profiles we've requested to avoid duplicate requests
|
||||
private requestedProfiles = new Set<string>()
|
||||
|
||||
protected async onInitialize(): Promise<void> {
|
||||
console.log('ProfileService: Starting initialization...')
|
||||
|
||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||
|
||||
if (!this.relayHub) {
|
||||
throw new Error('RelayHub service not available')
|
||||
}
|
||||
|
||||
console.log('ProfileService: Initialization complete')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile for a pubkey, fetching if not cached
|
||||
*/
|
||||
async getProfile(pubkey: string): Promise<UserProfile | null> {
|
||||
// Return cached profile if available
|
||||
if (this._profiles.has(pubkey)) {
|
||||
return this._profiles.get(pubkey)!
|
||||
}
|
||||
|
||||
// If not requested yet, fetch it
|
||||
if (!this.requestedProfiles.has(pubkey)) {
|
||||
await this.fetchProfile(pubkey)
|
||||
}
|
||||
|
||||
return this._profiles.get(pubkey) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a pubkey (returns formatted pubkey if no profile)
|
||||
*/
|
||||
getDisplayName(pubkey: string): string {
|
||||
const profile = this._profiles.get(pubkey)
|
||||
if (profile?.display_name) return profile.display_name
|
||||
if (profile?.name) return profile.name
|
||||
|
||||
// Return formatted pubkey as fallback
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile for specific pubkey
|
||||
*/
|
||||
private async fetchProfile(pubkey: string): Promise<void> {
|
||||
if (!this.relayHub || this.requestedProfiles.has(pubkey)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.requestedProfiles.add(pubkey)
|
||||
|
||||
try {
|
||||
if (!this.relayHub.isConnected) {
|
||||
await this.relayHub.connect()
|
||||
}
|
||||
|
||||
const subscriptionId = `profile-${pubkey}-${Date.now()}`
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [0], // Profile metadata
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
}
|
||||
|
||||
console.log(`ProfileService: Fetching profile for ${pubkey.slice(0, 8)}...`)
|
||||
|
||||
const unsubscribe = this.relayHub.subscribe({
|
||||
id: subscriptionId,
|
||||
filters: [filter],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
this.handleProfileEvent(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log(`Profile subscription ${subscriptionId} complete`)
|
||||
// Clean up subscription after getting the profile
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch profile for ${pubkey}:`, error)
|
||||
this.requestedProfiles.delete(pubkey) // Allow retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming profile event
|
||||
*/
|
||||
private handleProfileEvent(event: NostrEvent): void {
|
||||
try {
|
||||
const metadata = JSON.parse(event.content)
|
||||
|
||||
const profile: UserProfile = {
|
||||
pubkey: event.pubkey,
|
||||
name: metadata.name,
|
||||
display_name: metadata.display_name,
|
||||
about: metadata.about,
|
||||
picture: metadata.picture,
|
||||
nip05: metadata.nip05,
|
||||
updated_at: event.created_at
|
||||
}
|
||||
|
||||
// Only update if this is newer than what we have
|
||||
const existing = this._profiles.get(event.pubkey)
|
||||
if (!existing || event.created_at > existing.updated_at) {
|
||||
this._profiles.set(event.pubkey, profile)
|
||||
console.log(`ProfileService: Updated profile for ${event.pubkey.slice(0, 8)}...`, profile.display_name || profile.name)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to parse profile metadata:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fetch profiles for multiple pubkeys
|
||||
*/
|
||||
async fetchProfiles(pubkeys: string[]): Promise<void> {
|
||||
const unfetchedPubkeys = pubkeys.filter(pubkey =>
|
||||
!this._profiles.has(pubkey) && !this.requestedProfiles.has(pubkey)
|
||||
)
|
||||
|
||||
if (unfetchedPubkeys.length === 0) return
|
||||
|
||||
console.log(`ProfileService: Bulk fetching ${unfetchedPubkeys.length} profiles`)
|
||||
|
||||
try {
|
||||
if (!this.relayHub?.isConnected) {
|
||||
await this.relayHub?.connect()
|
||||
}
|
||||
|
||||
const subscriptionId = `profiles-bulk-${Date.now()}`
|
||||
|
||||
// Mark all as requested
|
||||
unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.add(pubkey))
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [0],
|
||||
authors: unfetchedPubkeys,
|
||||
limit: unfetchedPubkeys.length
|
||||
}
|
||||
|
||||
const unsubscribe = this.relayHub.subscribe({
|
||||
id: subscriptionId,
|
||||
filters: [filter],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
this.handleProfileEvent(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log(`Bulk profile subscription ${subscriptionId} complete`)
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk fetch profiles:', error)
|
||||
// Remove from requested so they can be retried
|
||||
unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.delete(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time profile updates for active users
|
||||
*/
|
||||
async subscribeToProfileUpdates(pubkeys: string[]): Promise<void> {
|
||||
if (this.currentSubscription) {
|
||||
await this.unsubscribeFromProfiles()
|
||||
}
|
||||
|
||||
if (pubkeys.length === 0) return
|
||||
|
||||
try {
|
||||
if (!this.relayHub?.isConnected) {
|
||||
await this.relayHub?.connect()
|
||||
}
|
||||
|
||||
const subscriptionId = `profile-updates-${Date.now()}`
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [0],
|
||||
authors: pubkeys
|
||||
}
|
||||
|
||||
console.log(`ProfileService: Subscribing to profile updates for ${pubkeys.length} users`)
|
||||
|
||||
const unsubscribe = this.relayHub.subscribe({
|
||||
id: subscriptionId,
|
||||
filters: [filter],
|
||||
onEvent: (event: NostrEvent) => {
|
||||
this.handleProfileEvent(event)
|
||||
},
|
||||
onEose: () => {
|
||||
console.log(`Profile updates subscription ${subscriptionId} ready`)
|
||||
}
|
||||
})
|
||||
|
||||
this.currentSubscription = subscriptionId
|
||||
this.currentUnsubscribe = unsubscribe
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to subscribe to profile updates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from profile updates
|
||||
*/
|
||||
async unsubscribeFromProfiles(): Promise<void> {
|
||||
if (this.currentUnsubscribe) {
|
||||
this.currentUnsubscribe()
|
||||
this.currentSubscription = null
|
||||
this.currentUnsubscribe = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear profile cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._profiles.clear()
|
||||
this.requestedProfiles.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached profiles
|
||||
*/
|
||||
get profiles(): Map<string, UserProfile> {
|
||||
return this._profiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
protected async onDestroy(): Promise<void> {
|
||||
await this.unsubscribeFromProfiles()
|
||||
this.clearCache()
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
<Button
|
||||
@click="showComposer = true"
|
||||
size="lg"
|
||||
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20"
|
||||
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
|
||||
>
|
||||
<Plus class="h-6 w-6 stroke-[2.5]" />
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue