PinnedUsersListRepositoryImpl.ts raw

   1  import { PinnedUsersListRepository, PinnedUsersList, Pubkey, tryToPinnedUsersList } from '@/domain'
   2  import { ExtendedKind } from '@/constants'
   3  import client from '@/services/client.service'
   4  import indexedDb from '@/services/indexed-db.service'
   5  import { Event as NostrEvent } from 'nostr-tools'
   6  import { RepositoryDependencies } from './types'
   7  
   8  /**
   9   * Function to decrypt private pins (NIP-04)
  10   */
  11  export type DecryptFn = (ciphertext: string, pubkey: string) => Promise<string>
  12  
  13  /**
  14   * Function to encrypt private pins (NIP-04)
  15   */
  16  export type EncryptFn = (plaintext: string, pubkey: string) => Promise<string>
  17  
  18  /**
  19   * Dependencies for PinnedUsersList repository
  20   */
  21  export interface PinnedUsersListRepositoryDependencies extends RepositoryDependencies {
  22    /**
  23     * NIP-04 decrypt function for private pins
  24     */
  25    decrypt: DecryptFn
  26  
  27    /**
  28     * NIP-04 encrypt function for private pins
  29     */
  30    encrypt: EncryptFn
  31  
  32    /**
  33     * The current user's pubkey (for encryption/decryption)
  34     */
  35    currentUserPubkey: string
  36  }
  37  
  38  /**
  39   * IndexedDB + Relay implementation of PinnedUsersListRepository
  40   *
  41   * Uses IndexedDB for local caching and the client service for relay fetching.
  42   * Handles NIP-04 encryption/decryption for private pins.
  43   */
  44  export class PinnedUsersListRepositoryImpl implements PinnedUsersListRepository {
  45    constructor(private readonly deps: PinnedUsersListRepositoryDependencies) {}
  46  
  47    async findByOwner(pubkey: Pubkey): Promise<PinnedUsersList | null> {
  48      // Try cache first
  49      const cachedEvent = await indexedDb.getReplaceableEvent(pubkey.hex, ExtendedKind.PINNED_USERS)
  50      let event = cachedEvent
  51  
  52      // Fetch from relays if not cached
  53      if (!event) {
  54        event = await client.fetchPinnedUsersList(pubkey.hex)
  55      }
  56  
  57      if (!event) return null
  58  
  59      // Create the aggregate from the event
  60      const pinnedUsersList = tryToPinnedUsersList(event)
  61      if (!pinnedUsersList) return null
  62  
  63      // Decrypt private pins if this is the current user's list
  64      if (event.pubkey === this.deps.currentUserPubkey && event.content) {
  65        try {
  66          // Try to get decrypted content from cache
  67          const cacheKey = `pinned:${event.id}`
  68          let decryptedContent = await indexedDb.getDecryptedContent(cacheKey)
  69  
  70          if (!decryptedContent) {
  71            decryptedContent = await this.deps.decrypt(event.content, event.pubkey)
  72            await indexedDb.putDecryptedContent(cacheKey, decryptedContent)
  73          }
  74  
  75          const privateTags = JSON.parse(decryptedContent)
  76          pinnedUsersList.setPrivatePins(privateTags)
  77        } catch {
  78          // Decryption failed, proceed with empty private pins
  79        }
  80      }
  81  
  82      return pinnedUsersList
  83    }
  84  
  85    async save(pinnedUsersList: PinnedUsersList): Promise<void> {
  86      // Encrypt private pins
  87      const privateTags = pinnedUsersList.toPrivateTags()
  88      let encryptedContent = ''
  89  
  90      if (privateTags.length > 0) {
  91        const plaintext = JSON.stringify(privateTags)
  92        encryptedContent = await this.deps.encrypt(plaintext, this.deps.currentUserPubkey)
  93      }
  94  
  95      // Set encrypted content on the aggregate before creating draft
  96      pinnedUsersList.setEncryptedContent(encryptedContent)
  97  
  98      const draftEvent = pinnedUsersList.toDraftEvent()
  99      const publishedEvent = await this.deps.publish(draftEvent)
 100  
 101      // Update cache
 102      await indexedDb.putReplaceableEvent(publishedEvent)
 103  
 104      // Cache the decrypted content
 105      if (encryptedContent) {
 106        const cacheKey = `pinned:${publishedEvent.id}`
 107        await indexedDb.putDecryptedContent(cacheKey, JSON.stringify(privateTags))
 108      }
 109    }
 110  
 111    /**
 112     * Save and return the published event (for UI state updates)
 113     */
 114    async saveAndGetEvent(pinnedUsersList: PinnedUsersList): Promise<{ event: NostrEvent; privateTags: string[][] }> {
 115      const privateTags = pinnedUsersList.toPrivateTags()
 116      let encryptedContent = ''
 117  
 118      if (privateTags.length > 0) {
 119        const plaintext = JSON.stringify(privateTags)
 120        encryptedContent = await this.deps.encrypt(plaintext, this.deps.currentUserPubkey)
 121      }
 122  
 123      pinnedUsersList.setEncryptedContent(encryptedContent)
 124  
 125      const draftEvent = pinnedUsersList.toDraftEvent()
 126      const publishedEvent = await this.deps.publish(draftEvent)
 127  
 128      await indexedDb.putReplaceableEvent(publishedEvent)
 129  
 130      if (encryptedContent) {
 131        const cacheKey = `pinned:${publishedEvent.id}`
 132        await indexedDb.putDecryptedContent(cacheKey, JSON.stringify(privateTags))
 133      }
 134  
 135      return { event: publishedEvent, privateTags }
 136    }
 137  }
 138