Make drafts persistent

This commit makes drafts persistent.

It does so by:
1. Converting `DraftsArtifacts` into Nostr events
2. Wrapping those Nostr events into NIP-37 notes
3. Saving those NIP-37 notes into NostrDB
4. Loading those same notes at startup
5. Unwrapping NIP-37 notes into Nostr events
6. Parsing that into `DraftsArtifacts`, loaded into DamusState
7. PostView can then load these drafts

Furthermore, a UX indicator was added to show when a draft has been
saved.

Limitations:
1. No encoding/decoding roundtrip guarantees. That would require
   extensive and heavy refactoring which is out of the scope of this
   commit.
2. We rely on `UserSettings` to keep track of note ids, while we do not
   have Ndb query capabilities
3. No NIP-37 relay sync support has been added yet, as that adds
   important privacy and sync conflict considerations which are out of
   the scope of this ticket, which is ensuring people don't lose their
   progress while writing notes.
4. The main use cases and scenarios have been tested. Because of (1),
   there may be some small inconsistencies on the stored version of the
   draft, but care was taken to keep the substantial portions of the
   content intact.

Closes: https://github.com/damus-io/damus/issues/1862
Changelog-Added: Added local persistence of note drafts
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-01-15 17:47:24 +09:00
parent 74d5bee1f6
commit 24c3e61a4b
12 changed files with 667 additions and 58 deletions

View File

@@ -0,0 +1,146 @@
//
// NIP37Draft.swift
// damus
//
// Created by Daniel DAquino on 2025-01-20.
//
import NostrSDK
import Foundation
/// This models a NIP-37 draft.
///
/// It is an immutable data structure that automatically makes both sides of a NIP-37 draft available: Its unwrapped form and wrapped form.
///
/// This is useful for keeping it or passing it around to other functions when both sides will be used, or it is not known which side of it will be used.
///
/// Just initialize it, and read its properties.
struct NIP37Draft {
// MARK: Properties
// Implementation note: Must be immutable to maintain integrity of the structure.
/// The wrapped version of the draft. That is, a NIP-37 note with draft contents encrypted.
let wrapped_note: NdbNote
/// The unwrapped version of the draft. That is, the actual note that was being drafted.
let unwrapped_note: NdbNote
/// The unique ID of the draft, as per NIP-37
var id: String? {
return self.wrapped_note.referenced_params.first?.param.string()
}
// MARK: Initialization
/// Basic initializer
///
/// ## Implementation notes
///
/// - Using this externally defeats the whole purpose of using this struct, so this is kept private.
private init(wrapped_note: NdbNote, unwrapped_note: NdbNote) {
self.wrapped_note = wrapped_note
self.unwrapped_note = unwrapped_note
}
/// Initializes object with a wrapped NIP-37 note, if the keys can decrypt it.
/// - Parameters:
/// - wrapped_note: NIP-37 note
/// - keypair: The keys to decrypt
init?(wrapped_note: NdbNote, keypair: FullKeypair) throws {
self.wrapped_note = wrapped_note
guard let unwrapped_note = try Self.unwrap(note: wrapped_note, keypair: keypair) else { return nil }
self.unwrapped_note = unwrapped_note
}
/// Initializes object with an event to be wrapped into a NIP-37 draft
/// - Parameters:
/// - unwrapped_note: a note to be wrapped
/// - draft_id: the unique ID of this draft, as per NIP-37
/// - keypair: the keys to use for encrypting
init?(unwrapped_note: NdbNote, draft_id: String, keypair: FullKeypair) throws {
self.unwrapped_note = unwrapped_note
guard let wrapped_note = try Self.wrap(note: unwrapped_note, draft_id: draft_id, keypair: keypair) else { return nil }
self.wrapped_note = wrapped_note
}
// MARK: Static functions
// Use these when you just need to wrap/unwrap once
/// A function that wraps a note into NIP-37 draft event
/// - Parameters:
/// - note: the note that needs to be wrapped
/// - draft_id: the unique ID of the draft, as per NIP-37
/// - keypair: the keys to use for encrypting
/// - Returns: A NIP-37 draft, if it succeeds.
static func wrap(note: NdbNote, draft_id: String, keypair: FullKeypair) throws -> NdbNote? {
let note_json_data = try JSONEncoder().encode(note)
guard let note_json_string = String(data: note_json_data, encoding: .utf8) else {
throw NIP37DraftEventError.encoding_error
}
guard let secret_key = SecretKey.from(privkey: keypair.privkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let contents = try? nip44Encrypt(secretKey: secret_key, publicKey: pubkey, content: note_json_string, version: Nip44Version.v2) else {
return nil
}
var tags = [
["d", draft_id],
["k", String(note.kind)],
]
if let replied_to_note = note.direct_replies() {
tags.append(["e", replied_to_note.hex()])
}
guard let wrapped_event = NostrEvent(
content: contents,
keypair: keypair.to_keypair(),
kind: NostrKind.draft.rawValue,
tags: tags
) else { return nil }
return wrapped_event
}
/// A function that unwraps and decrypts a NIP-37 draft
/// - Parameters:
/// - note: NIP-37 note to be unwrapped
/// - keypair: The keys to use for decrypting
/// - Returns: The unwrapped note, if it can be decrypted/unwrapped.
static func unwrap(note: NdbNote, keypair: FullKeypair) throws -> NdbNote? {
let wrapped_note = note
guard wrapped_note.known_kind == .draft else { return nil }
guard let private_key = SecretKey.from(privkey: keypair.privkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
throw NIP37DraftEventError.invalid_keypair
}
guard let draft_event_json = try? nip44Decrypt(
secretKey: private_key,
publicKey: pubkey,
payload: wrapped_note.content
) else { return nil }
return NdbNote.owned_from_json(json: draft_event_json)
}
enum NIP37DraftEventError: Error {
case invalid_keypair
case encoding_error
}
}
// MARK: - Convenience extensions
fileprivate extension PublicKey {
static func from(pubkey: Pubkey) -> PublicKey? {
return try? PublicKey.parse(publicKey: pubkey.hex())
}
}
fileprivate extension SecretKey {
static func from(privkey: Privkey) -> SecretKey? {
return try? SecretKey.parse(secretKey: privkey.hex())
}
}