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:
parent
06bcc4b91e
commit
b074cc4ca3
11 changed files with 299 additions and 2184 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue