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:
padreug 2025-09-17 01:11:53 +02:00
parent 310612a2c5
commit 45391cbaa1
6 changed files with 443 additions and 47 deletions

View file

@ -134,6 +134,7 @@ export const SERVICE_TOKENS = {
// Feed services
FEED_SERVICE: Symbol('feedService'),
PROFILE_SERVICE: Symbol('profileService'),
// Events services
EVENTS_SERVICE: Symbol('eventsService'),

View file

@ -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>

View 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
}
}

View file

@ -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)

View 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()
}
}

View file

@ -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>