refactor: Remove unused components and clean up identity management

- Delete IdentityDialog.vue, useIdentity.ts, useNostr.ts, useNostrFeed.ts, useNostrFeedPreloader.ts, useSocial.ts, and related Nostr client files to streamline the codebase.
- Consolidate identity management and feed handling logic to improve maintainability and reduce complexity.
- Ensure that the application remains functional while enhancing overall clarity for future development.
This commit is contained in:
padreug 2025-08-13 10:11:28 +02:00
parent 06bcc4b91e
commit b074cc4ca3
11 changed files with 299 additions and 2184 deletions

View file

@ -1,306 +0,0 @@
import { type Filter, type Event } from 'nostr-tools'
import { getReplyInfo, EventKinds } from './events'
import { relayHub } from './relayHub'
export interface NostrClientConfig {
relays: string[]
}
export interface NostrNote extends Event {
// Add any additional note-specific fields we want to track
replyCount: number
reactionCount: number
reactions: { [reaction: string]: number }
isReply: boolean
replyTo?: string
mentions: string[]
}
export class NostrClient {
private relays: string[]
// private _isConnected: boolean = false
constructor(config: NostrClientConfig) {
this.relays = config.relays
}
get isConnected(): boolean {
return relayHub.isConnected
}
async connect(): Promise<void> {
try {
// The relay hub should already be initialized by the time this is called
if (!relayHub.isInitialized) {
throw new Error('RelayHub not initialized. Please ensure the app has initialized the relay hub first.')
}
// Check if we're already connected
if (relayHub.isConnected) {
return
}
// Try to connect using the relay hub
await relayHub.connect()
} catch (error) {
throw error
}
}
disconnect(): void {
// Note: We don't disconnect the relay hub here as other components might be using it
// The relay hub will be managed at the app level
}
async fetchNotes(options: {
limit?: number
since?: number // Unix timestamp in seconds
authors?: string[]
includeReplies?: boolean
} = {}): Promise<NostrNote[]> {
const {
limit = 20,
authors,
includeReplies = false
} = options
const filters: Filter[] = [
{
kinds: [EventKinds.TEXT_NOTE],
limit,
...(authors && { authors })
}
]
try {
// Use the relay hub to query events
const allEvents = await relayHub.queryEvents(filters, this.relays)
const noteEvents = [allEvents] // Wrap in array to match expected format
// Flatten and deduplicate events by ID
const uniqueNotes = Array.from(
new Map(
noteEvents.flat().map(event => [event.id, event])
).values()
)
// Process notes with basic info (engagement data disabled for now)
let processedNotes = uniqueNotes
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
return {
...event,
replyCount: 0,
reactionCount: 0,
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
}
})
// Filter out replies if not requested
if (!includeReplies) {
processedNotes = processedNotes.filter(note => !note.isReply)
}
return processedNotes
.sort((a: NostrNote, b: NostrNote) => b.created_at - a.created_at) // Sort by newest first
.slice(0, limit) // Apply limit after processing
} catch (error) {
console.error('Failed to fetch notes:', error)
throw error
}
}
/**
* Publish an event to all connected relays
*/
async publishEvent(event: Event): Promise<void> {
if (!relayHub.isConnected) {
throw new Error('Not connected to any relays')
}
try {
const result = await relayHub.publishEvent(event)
} catch (error) {
console.error('Failed to publish event:', error)
throw error
}
}
/**
* Fetch replies to a specific note
*/
async fetchReplies(noteId: string, limit: number = 50): Promise<NostrNote[]> {
const filter: Filter = {
kinds: [EventKinds.TEXT_NOTE],
'#e': [noteId],
limit
}
try {
const events = await relayHub.queryEvents([filter], this.relays)
// Flatten and deduplicate events by ID
const uniqueEvents = Array.from(
new Map(
events.map(event => [event.id, event])
).values()
)
return uniqueEvents
.sort((a: Event, b: Event) => a.created_at - b.created_at) // Chronological order for replies
.map((event: Event): NostrNote => {
const replyInfo = getReplyInfo(event)
return {
...event,
replyCount: 0, // Individual replies don't need reply counts in this context
reactionCount: 0, // We could add this later
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
}
})
} catch (error) {
console.error('Failed to fetch replies:', error)
throw error
}
}
/**
* Fetch events by kind
*/
async fetchEvents(options: {
kinds: number[]
authors?: string[]
limit?: number
since?: number
until?: number
'#d'?: string[]
}): Promise<Event[]> {
const {
kinds,
authors,
limit = 100,
since,
until,
'#d': dTags
} = options
// Build filter object, only including defined properties
const filter: Filter = {
kinds,
limit
}
if (authors && authors.length > 0) {
filter.authors = authors
}
if (since) {
filter.since = since
}
if (until) {
filter.until = until
}
if (dTags && dTags.length > 0) {
filter['#d'] = dTags
}
const filters: Filter[] = [filter]
try {
console.log('Fetching events with filters:', JSON.stringify(filters, null, 2))
const events = await relayHub.queryEvents(filters, this.relays)
// Deduplicate events by ID
const uniqueEvents = Array.from(
new Map(events.map(event => [event.id, event])).values()
)
return uniqueEvents
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit)
} catch (error) {
console.error('Failed to fetch events:', error)
throw error
}
}
/**
* Fetch user profiles
*/
async fetchProfiles(pubkeys: string[]): Promise<Map<string, any>> {
if (pubkeys.length === 0) return new Map()
const filter: Filter = {
kinds: [EventKinds.PROFILE_METADATA],
authors: pubkeys
}
try {
const events = await relayHub.queryEvents([filter], this.relays)
const profiles = new Map<string, any>()
// Get the latest profile for each pubkey
pubkeys.forEach(pubkey => {
const userEvents = events
.filter(event => event.pubkey === pubkey)
.sort((a, b) => b.created_at - a.created_at)
if (userEvents.length > 0) {
try {
const profile = JSON.parse(userEvents[0].content)
profiles.set(pubkey, profile)
} catch (error) {
console.warn(`Failed to parse profile for ${pubkey}:`, error)
}
}
})
return profiles
} catch (error) {
console.error('Failed to fetch profiles:', error)
throw error
}
}
// Subscribe to new notes in real-time
subscribeToNotes(onNote: (note: NostrNote) => void): () => void {
const filters = [{
kinds: [EventKinds.TEXT_NOTE],
since: Math.floor(Date.now() / 1000)
}]
// Use the relay hub to subscribe
const unsubscribe = relayHub.subscribe({
id: `notes-subscription-${Date.now()}`,
filters,
relays: this.relays,
onEvent: (event: Event) => {
const replyInfo = getReplyInfo(event)
onNote({
...event,
replyCount: 0,
reactionCount: 0,
reactions: {},
isReply: replyInfo.isReply,
replyTo: replyInfo.replyTo,
mentions: replyInfo.mentions
})
}
})
return unsubscribe
}
}

View file

@ -1,156 +0,0 @@
import { finalizeEvent, type EventTemplate, type Event } from 'nostr-tools'
import type { NostrIdentity } from './identity'
import { hexToBytes } from '@/lib/utils/crypto'
/**
* Nostr event kinds
*/
export const EventKinds = {
TEXT_NOTE: 1,
REACTION: 7,
ZAP: 9735,
PROFILE_METADATA: 0,
CONTACT_LIST: 3,
REPOST: 6,
DELETE: 5
} as const
/**
* Create a text note event
*/
export function createTextNote(content: string, identity: NostrIdentity, replyTo?: string): Event {
const eventTemplate: EventTemplate = {
kind: EventKinds.TEXT_NOTE,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content
}
// Add reply tags if this is a reply
if (replyTo) {
eventTemplate.tags.push(['e', replyTo, '', 'reply'])
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a reaction event (like/dislike)
*/
export function createReaction(
targetEventId: string,
targetAuthor: string,
reaction: string,
identity: NostrIdentity
): Event {
const eventTemplate: EventTemplate = {
kind: EventKinds.REACTION,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', targetEventId],
['p', targetAuthor]
],
content: reaction
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a profile metadata event
*/
export function createProfileMetadata(profile: Record<string, any>, identity: NostrIdentity): Event {
const eventTemplate: EventTemplate = {
kind: EventKinds.PROFILE_METADATA,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: JSON.stringify(profile)
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a contact list event (following)
*/
export function createContactList(contacts: string[], identity: NostrIdentity): Event {
const eventTemplate: EventTemplate = {
kind: EventKinds.CONTACT_LIST,
created_at: Math.floor(Date.now() / 1000),
tags: contacts.map(pubkey => ['p', pubkey]),
content: ''
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Create a delete event
*/
export function createDeleteEvent(eventIds: string[], identity: NostrIdentity, reason?: string): Event {
const eventTemplate: EventTemplate = {
kind: EventKinds.DELETE,
created_at: Math.floor(Date.now() / 1000),
tags: eventIds.map(id => ['e', id]),
content: reason || ''
}
return finalizeEvent(eventTemplate, hexToBytes(identity.privateKey))
}
/**
* Extract reply information from event tags
*/
export function getReplyInfo(event: any): { isReply: boolean; replyTo?: string; mentions: string[] } {
const eTags = event.tags.filter((tag: string[]) => tag[0] === 'e')
const pTags = event.tags.filter((tag: string[]) => tag[0] === 'p')
// Find reply target (last e tag with "reply" marker or last e tag)
const replyTag = eTags.find((tag: string[]) => tag[3] === 'reply') || eTags[eTags.length - 1]
return {
isReply: !!replyTag,
replyTo: replyTag?.[1],
mentions: pTags.map((tag: string[]) => tag[1])
}
}
/**
* Extract reactions from events
*/
export function extractReactions(events: any[]): Map<string, { [reaction: string]: number }> {
const reactions = new Map<string, { [reaction: string]: number }>()
events
.filter(event => event.kind === EventKinds.REACTION)
.forEach(event => {
const targetEventId = event.tags.find((tag: string[]) => tag[0] === 'e')?.[1]
if (!targetEventId) return
const reaction = event.content || '👍'
const current = reactions.get(targetEventId) || {}
current[reaction] = (current[reaction] || 0) + 1
reactions.set(targetEventId, current)
})
return reactions
}
/**
* Extract reply counts from events
*/
export function extractReplyCounts(events: any[]): Map<string, number> {
const replyCounts = new Map<string, number>()
events
.filter(event => event.kind === EventKinds.TEXT_NOTE)
.forEach(event => {
const replyInfo = getReplyInfo(event)
if (replyInfo.isReply && replyInfo.replyTo) {
const current = replyCounts.get(replyInfo.replyTo) || 0
replyCounts.set(replyInfo.replyTo, current + 1)
}
})
return replyCounts
}

View file

@ -1,210 +0,0 @@
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import { SecureStorage } from '@/lib/crypto/encryption'
import { bytesToHex, hexToBytes } from '@/lib/utils/crypto'
export interface NostrIdentity {
privateKey: string
publicKey: string
npub: string
nsec: string
}
export interface NostrProfile {
name?: string
display_name?: string
about?: string
picture?: string
banner?: string
website?: string
nip05?: string
lud16?: string // Lightning address
}
export class IdentityManager {
private static readonly STORAGE_KEY = 'nostr_identity'
private static readonly PROFILE_KEY = 'nostr_profile'
/**
* Generate a new Nostr identity
*/
static generateIdentity(): NostrIdentity {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
return {
privateKey: bytesToHex(privateKey),
publicKey,
npub: nip19.npubEncode(publicKey),
nsec: nip19.nsecEncode(privateKey)
}
}
/**
* Import identity from private key (hex or nsec format)
*/
static importIdentity(privateKeyInput: string): NostrIdentity {
let privateKeyBytes: Uint8Array
try {
// Try to decode as nsec first
if (privateKeyInput.startsWith('nsec')) {
const decoded = nip19.decode(privateKeyInput)
if (decoded.type === 'nsec') {
privateKeyBytes = decoded.data
} else {
throw new Error('Invalid nsec format')
}
} else {
// Try as hex string
if (privateKeyInput.length !== 64) {
throw new Error('Private key must be 64 hex characters')
}
privateKeyBytes = hexToBytes(privateKeyInput)
}
if (privateKeyBytes.length !== 32) {
throw new Error('Private key must be 32 bytes')
}
const publicKey = getPublicKey(privateKeyBytes)
return {
privateKey: bytesToHex(privateKeyBytes),
publicKey,
npub: nip19.npubEncode(publicKey),
nsec: nip19.nsecEncode(privateKeyBytes)
}
} catch (error) {
throw new Error(`Failed to import identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* Save identity to localStorage (encrypted)
*/
static async saveIdentity(identity: NostrIdentity, password?: string): Promise<void> {
try {
let dataToStore: any = identity
if (password) {
if (!SecureStorage.isSupported()) {
throw new Error('Secure encryption is not supported in this browser')
}
// Encrypt sensitive data using Web Crypto API
const sensitiveData = JSON.stringify({
privateKey: identity.privateKey,
nsec: identity.nsec
})
const encryptedData = await SecureStorage.encrypt(sensitiveData, password)
dataToStore = {
publicKey: identity.publicKey,
npub: identity.npub,
encrypted: true,
encryptedData
}
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(dataToStore))
} catch (error) {
throw new Error(`Failed to save identity: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
/**
* Load identity from localStorage
*/
static async loadIdentity(password?: string): Promise<NostrIdentity | null> {
try {
const stored = localStorage.getItem(this.STORAGE_KEY)
if (!stored) return null
const storedData = JSON.parse(stored)
if (storedData.encrypted && password) {
if (!SecureStorage.isSupported()) {
throw new Error('Secure encryption is not supported in this browser')
}
// Decrypt sensitive data
const decryptedSensitiveData = await SecureStorage.decrypt(storedData.encryptedData, password)
const { privateKey, nsec } = JSON.parse(decryptedSensitiveData)
return {
privateKey,
publicKey: storedData.publicKey,
npub: storedData.npub,
nsec
}
} else if (storedData.encrypted && !password) {
throw new Error('Password required to decrypt stored identity')
}
// Non-encrypted identity (legacy support)
const identity = storedData as NostrIdentity
// Validate the loaded identity
if (!identity.privateKey || !identity.publicKey) {
throw new Error('Invalid stored identity')
}
return identity
} catch (error) {
console.error('Failed to load identity:', error)
throw error
}
}
/**
* Clear stored identity
*/
static clearIdentity(): void {
localStorage.removeItem(this.STORAGE_KEY)
localStorage.removeItem(this.PROFILE_KEY)
}
/**
* Check if identity exists in storage
*/
static hasStoredIdentity(): boolean {
return !!localStorage.getItem(this.STORAGE_KEY)
}
/**
* Check if stored identity is encrypted
*/
static isStoredIdentityEncrypted(): boolean {
try {
const stored = localStorage.getItem(this.STORAGE_KEY)
if (!stored) return false
const storedData = JSON.parse(stored)
return !!storedData.encrypted
} catch {
return false
}
}
/**
* Save user profile
*/
static saveProfile(profile: NostrProfile): void {
localStorage.setItem(this.PROFILE_KEY, JSON.stringify(profile))
}
/**
* Load user profile
*/
static loadProfile(): NostrProfile | null {
try {
const stored = localStorage.getItem(this.PROFILE_KEY)
return stored ? JSON.parse(stored) : null
} catch (error) {
console.error('Failed to load profile:', error)
return null
}
}
}

View file

@ -1,7 +1,6 @@
// Notification manager that integrates Nostr events with push notifications
// Notification manager for push notifications
import { pushService, type NotificationPayload } from './push'
import { configUtils } from '@/lib/config'
import type { NostrNote } from '@/lib/nostr/client'
export interface NotificationOptions {
enabled: boolean
@ -64,168 +63,25 @@ export class NotificationManager {
this.saveOptions()
}
// Check if notifications should be sent for a note
shouldNotify(note: NostrNote, userPubkey?: string): boolean {
if (!this.options.enabled) return false
// Admin announcements
if (this.options.adminAnnouncements && configUtils.isAdminPubkey(note.pubkey)) {
return true
}
// Mentions (if user is mentioned in the note)
if (this.options.mentions && userPubkey && note.mentions.includes(userPubkey)) {
return true
}
// Replies (if it's a reply to user's note)
if (this.options.replies && userPubkey && note.isReply && note.replyTo) {
// We'd need to check if the reply is to the user's note
// This would require additional context about the user's notes
return false
}
return false
}
// Create notification payload from Nostr note
private createNotificationPayload(note: NostrNote): NotificationPayload {
const isAdmin = configUtils.isAdminPubkey(note.pubkey)
let title = 'New Note'
let body = note.content
let tag = 'nostr-note'
if (isAdmin) {
title = '🚨 Admin Announcement'
tag = 'admin-announcement'
} else if (note.isReply) {
title = 'Reply'
tag = 'reply'
} else if (note.mentions.length > 0) {
title = 'Mention'
tag = 'mention'
}
// Truncate long content
if (body.length > 100) {
body = body.slice(0, 100) + '...'
}
return {
title,
body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag,
requireInteraction: isAdmin, // Admin announcements require interaction
data: {
noteId: note.id,
pubkey: note.pubkey,
isAdmin,
url: '/',
timestamp: note.created_at
},
actions: isAdmin ? [
{
action: 'view',
title: 'View'
},
{
action: 'dismiss',
title: 'Dismiss'
}
] : [
{
action: 'view',
title: 'View'
}
]
}
}
// Send notification for a Nostr note
async notifyForNote(note: NostrNote, userPubkey?: string): Promise<void> {
try {
if (!this.shouldNotify(note, userPubkey)) {
return
}
if (!pushService.isSupported()) {
console.warn('Push notifications not supported')
return
}
const isSubscribed = await pushService.isSubscribed()
if (!isSubscribed) {
console.log('User not subscribed to push notifications')
return
}
const payload = this.createNotificationPayload(note)
await pushService.showLocalNotification(payload)
console.log('Notification sent for note:', note.id)
} catch (error) {
console.error('Failed to send notification for note:', error)
}
}
// Send test notification
// Send a test notification
async sendTestNotification(): Promise<void> {
const testPayload: NotificationPayload = {
title: '🚨 Test Admin Announcement',
body: 'This is a test notification to verify push notifications are working correctly.',
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag: 'test-notification',
requireInteraction: true,
if (!this.options.enabled) {
throw new Error('Notifications are disabled')
}
const payload: NotificationPayload = {
title: '🧪 Test Notification',
body: 'This is a test notification from Ario',
tag: 'test',
icon: '/apple-touch-icon.png',
badge: '/apple-touch-icon.png',
data: {
url: '/',
type: 'test',
url: window.location.origin,
timestamp: Date.now()
},
actions: [
{
action: 'view',
title: 'View App'
},
{
action: 'dismiss',
title: 'Dismiss'
}
]
}
await pushService.showLocalNotification(testPayload)
}
// Handle background notification processing
async processBackgroundNote(noteData: any): Promise<void> {
// This would be called from the service worker
// when receiving push notifications from a backend
try {
const payload = this.createNotificationPayload(noteData)
// Show notification via service worker
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready
await registration.showNotification(payload.title, payload)
}
} catch (error) {
console.error('Failed to process background notification:', error)
}
}
// Check if user has denied notifications
isBlocked(): boolean {
return pushService.getPermission() === 'denied'
}
// Check if notifications are enabled and configured
isConfigured(): boolean {
return pushService.isSupported() && configUtils.hasPushConfig()
await pushService.sendNotification(payload)
}
}