commit 24c3e61a4bd57ed64edee704416bb3c6598deabe
parent 74d5bee1f6c4c3807fbdd41458a36dd059c896ad
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Wed, 15 Jan 2025 17:47:24 +0900
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>
Diffstat:
12 files changed, 667 insertions(+), 58 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -1454,6 +1454,9 @@
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; };
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; };
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; };
+ D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
+ D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
+ D755B28F2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; };
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */; };
D767066F2C8BB3CF00F09726 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D76874F32AE3632B00FB0F68 /* ProfileZapLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */; };
@@ -1497,6 +1500,10 @@
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
+ D7BEE6F32D37AE1B00CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F22D37AE1B00CF659F /* NostrSDK */; };
+ D7BEE6F52D37B20400CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F42D37B20400CF659F /* NostrSDK */; };
+ D7BEE6F72D37B21400CF659F /* NostrSDK in Frameworks */ = {isa = PBXBuildFile; productRef = D7BEE6F62D37B21400CF659F /* NostrSDK */; };
+ D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEE6F82D37B37400CF659F /* DraftTests.swift */; };
D7C48C0B2D12DE0C00A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */; };
D7C48C0D2D12E34900A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0C2D12E34900A3BACF /* SwiftyCrop */; };
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */ = {isa = PBXBuildFile; productRef = D7C48C0E2D12E35600A3BACF /* SwiftyCrop */; };
@@ -2436,6 +2443,7 @@
D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; };
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = "<group>"; };
D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = "<group>"; };
+ D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = "<group>"; };
D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = "<group>"; };
D767066E2C8BB3CE00F09726 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = "<group>"; };
D76874F22AE3632B00FB0F68 /* ProfileZapLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileZapLinkView.swift; sourceTree = "<group>"; };
@@ -2459,6 +2467,7 @@
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
+ D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = "<group>"; };
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
D7CB5D442B116FE800AD4105 /* Contacts+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Contacts+.swift"; sourceTree = "<group>"; };
@@ -2514,6 +2523,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ D7BEE6F32D37AE1B00CF659F /* NostrSDK in Frameworks */,
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
3A0A30BB2C21397A00F8C9BC /* EmojiPicker in Frameworks */,
D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */,
@@ -2544,6 +2554,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ D7BEE6F52D37B20400CF659F /* NostrSDK in Frameworks */,
82D6FC862CD9A4A600C925F4 /* MarkdownUI in Frameworks */,
82D6FC8A2CD9A54600C925F4 /* SwipeActions in Frameworks */,
D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */,
@@ -2560,6 +2571,7 @@
files = (
D703D7AF2C670FB700A400EA /* MarkdownUI in Frameworks */,
D73E5F9D2C6AA8E3007EB227 /* SwipeActions in Frameworks */,
+ D7BEE6F72D37B21400CF659F /* NostrSDK in Frameworks */,
D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */,
D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */,
D7C48C0F2D12E35600A3BACF /* SwiftyCrop in Frameworks */,
@@ -3567,6 +3579,7 @@
4CE6DEE527F7A08100C66700 /* damus */ = {
isa = PBXGroup;
children = (
+ D755B28B2D3E7D6500BBEEFA /* NIP37 */,
4C45E5002BED4CE10025A428 /* NIP10 */,
4C1D4FB32A7967990024F453 /* build-git-hash.txt */,
4CA3529C2A76AE47003BB08B /* Notify */,
@@ -3613,6 +3626,7 @@
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */,
4C90BD1B283AC38E008EE7EF /* Bech32Tests.swift */,
E02B54172B4DFADA0077FF42 /* Bech32ObjectTests.swift */,
+ D7BEE6F82D37B37400CF659F /* DraftTests.swift */,
4C363A9F2828A8DD006E126D /* LikeTests.swift */,
4C363A9D2828A822006E126D /* ReplyTests.swift */,
4CE6DEF727F7A08200C66700 /* damusTests.swift */,
@@ -3884,6 +3898,14 @@
path = Purple;
sourceTree = "<group>";
};
+ D755B28B2D3E7D6500BBEEFA /* NIP37 */ = {
+ isa = PBXGroup;
+ children = (
+ D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */,
+ );
+ path = NIP37;
+ sourceTree = "<group>";
+ };
D78DB85D2C20FE9E00F0AB12 /* Chat */ = {
isa = PBXGroup;
children = (
@@ -3990,6 +4012,7 @@
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
D70D90972CDED61800CD0534 /* CodeScanner */,
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */,
+ D7BEE6F22D37AE1B00CF659F /* NostrSDK */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -4056,6 +4079,7 @@
82D6FC892CD9A54600C925F4 /* SwipeActions */,
D7F360282CEBBE34009D34DA /* CodeScanner */,
D7C48C0C2D12E34900A3BACF /* SwiftyCrop */,
+ D7BEE6F42D37B20400CF659F /* NostrSDK */,
);
productName = "share extension";
productReference = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */;
@@ -4084,6 +4108,7 @@
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
D70D909B2CDED7B200CD0534 /* CodeScanner */,
D7C48C0E2D12E35600A3BACF /* SwiftyCrop */,
+ D7BEE6F62D37B21400CF659F /* NostrSDK */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -4193,6 +4218,7 @@
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */,
+ D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -4566,6 +4592,7 @@
4C363A8828236948006E126D /* BlocksView.swift in Sources */,
4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */,
3A23838E2A297DD200E5AA2E /* ZapButtonModel.swift in Sources */,
+ D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */,
4C1D4FB12A7958E60024F453 /* VersionInfo.swift in Sources */,
D7FF94002AC7AC5300FD969D /* RelayURL.swift in Sources */,
@@ -4817,6 +4844,7 @@
75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */,
E0E024112B7C19C20075735D /* TranslationTests.swift in Sources */,
F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */,
+ D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */,
B501062D2B363036003874F5 /* AuthIntegrationTests.swift in Sources */,
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,
D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */,
@@ -5098,6 +5126,7 @@
82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */,
82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */,
82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */,
+ D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */,
82D6FBA22CD99F7900C925F4 /* ZapType.swift in Sources */,
82D6FBA32CD99F7900C925F4 /* NewEventsBits.swift in Sources */,
@@ -5788,6 +5817,7 @@
D703D7472C67092700A400EA /* UserSettingsStore.swift in Sources */,
D703D7852C670C6100A400EA /* Notify.swift in Sources */,
D703D7532C670A2600A400EA /* Wallet.swift in Sources */,
+ D755B28F2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
D703D75F2C670AA200A400EA /* NostrEvent.swift in Sources */,
D703D7442C67086800A400EA /* HeadlessDamusState.swift in Sources */,
D703D7922C670D2900A400EA /* RelayURL.swift in Sources */,
@@ -6747,6 +6777,14 @@
minimumVersion = 1.14.1;
};
};
+ D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/rust-nostr/nostr-sdk-swift";
+ requirement = {
+ kind = revision;
+ revision = 999316a0962d49ad8b7b98d214b9ee6eea694149;
+ };
+ };
D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/benedom/SwiftyCrop";
@@ -6868,6 +6906,21 @@
package = D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */;
productName = SnapshotTesting;
};
+ D7BEE6F22D37AE1B00CF659F /* NostrSDK */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
+ productName = NostrSDK;
+ };
+ D7BEE6F42D37B20400CF659F /* NostrSDK */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
+ productName = NostrSDK;
+ };
+ D7BEE6F62D37B21400CF659F /* NostrSDK */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D7BEE6F12D37AE1B00CF659F /* XCRemoteSwiftPackageReference "nostr-sdk-swift" */;
+ productName = NostrSDK;
+ };
D7C48C0A2D12DE0C00A3BACF /* SwiftyCrop */ = {
isa = XCSwiftPackageProductDependency;
package = D7C48C092D12DE0C00A3BACF /* XCRemoteSwiftPackageReference "SwiftyCrop" */;
diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "0d806129a33991730dd1aa3d38c47a745f9e9e6ff44999f4a7f28b695f024832",
+ "originHash" : "cc593053634546d736b9cbddccd9952f1f568c97012df5400d99fa313a1100d2",
"pins" : [
{
"identity" : "codescanner",
@@ -46,6 +46,14 @@
}
},
{
+ "identity" : "nostr-sdk-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/rust-nostr/nostr-sdk-swift",
+ "state" : {
+ "revision" : "999316a0962d49ad8b7b98d214b9ee6eea694149"
+ }
+ },
+ {
"identity" : "secp256k1.swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jb55/secp256k1.swift",
diff --git a/damus/Components/SelectableText.swift b/damus/Components/SelectableText.swift
@@ -63,7 +63,7 @@ struct SelectableText: View {
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
- action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
+ action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)
diff --git a/damus/Models/DraftsModel.swift b/damus/Models/DraftsModel.swift
@@ -6,14 +6,45 @@
//
import Foundation
+import SwiftUICore
+import UIKit
+/// Represents artifacts in a post draft, which is rendered by `PostView`
+///
+/// ## Implementation notes
+///
+/// - This is NOT `Codable` because we store these persistently as NIP-37 drafts in NostrDB, instead of directly encoding the object.
+/// - `NSMutableAttributedString` is the bottleneck for making this `Codable`, and replacing that with another type requires a very large refactor.
+/// - Encoding/decoding logic is lossy, and is not fully round-trippable. This class does a best effort attempt at encoding and recovering as much information as possible, but the information is dispersed into many different places, types, and functions around the code, making round-trip guarantees very difficult without severely refactoring `PostView`, `TextViewWrapper`, and other associated classes, unfortunately. These are the known limitations at the moment:
+/// - Image metadata is lost on decoding
+/// - The `filtered_pubkeys` filter effectively gets applied upon encoding, causing them to change upon decoding
+///
class DraftArtifacts: Equatable {
+ /// The text content of the note draft
+ ///
+ /// ## Implementation notes
+ ///
+ /// - This serves as the backing model for `PostView` and `TextViewWrapper`. It might be cleaner to use a specialized data model for this in the future and render to attributed string in real time, but that will require a big refactor. See https://github.com/damus-io/damus/issues/1862#issuecomment-2585756932
var content: NSMutableAttributedString
+ /// A list of media items that have been attached to the note draft.
var media: [UploadedMedia]
+ /// The references for this note, which will be translated into tags once the event is published.
+ var references: [RefId]
+ /// Pubkeys that should be filtered out from the references
+ ///
+ /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove.
+ var filtered_pubkeys: Set<Pubkey> = []
- init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = []) {
+ /// A unique ID for this draft that allows us to address these if we need to.
+ ///
+ /// This will be the unique identifier in the NIP-37 note
+ let id: String
+
+ init(content: NSMutableAttributedString = NSMutableAttributedString(string: ""), media: [UploadedMedia] = [], references: [RefId], id: String) {
self.content = content
self.media = media
+ self.references = references
+ self.id = id
}
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
@@ -22,11 +53,217 @@ class DraftArtifacts: Equatable {
lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content
)
}
+
+
+ // MARK: Encoding and decoding functions to and from NIP-37 nostr events
+
+ /// Converts the draft artifacts into a NIP-37 draft event that can be saved into NostrDB or any Nostr relay
+ ///
+ /// - Parameters:
+ /// - action: The post action for this draft, which provides necessary context for the draft (e.g. Is it meant to highlight something? Reply to something?)
+ /// - damus_state: The damus state, needed for encrypting, fetching Nostr data depedencies, and forming the NIP-37 draft
+ /// - references: references in the post?
+ /// - Returns: The NIP-37 draft packaged in a way that can be easily wrapped/unwrapped.
+ func to_nip37_draft(action: PostAction, damus_state: DamusState) throws -> NIP37Draft? {
+ guard let keypair = damus_state.keypair.to_full() else { return nil }
+ let post = build_post(state: damus_state, action: action, draft: self)
+ guard let note = post.to_event(keypair: keypair) else { return nil }
+ return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair)
+ }
+
+ /// Instantiates a draft object from a NIP-37 draft
+ /// - Parameters:
+ /// - nip37_draft: The NIP-37 draft object
+ /// - damus_state: Damus state of the user who wants to load this draft object. Needed for pulling profiles from Ndb, and decrypting contents.
+ /// - Returns: A draft artifacts object, or `nil` if such cannot be loaded.
+ static func from(nip37_draft: NIP37Draft, damus_state: DamusState) -> DraftArtifacts? {
+ return Self.from(
+ event: nip37_draft.unwrapped_note,
+ draft_id: nip37_draft.id ?? UUID().uuidString, // Generate random UUID as the draft ID if none is specified. It is always better to have an ID that we can use for addressing later.
+ damus_state: damus_state
+ )
+ }
+
+ /// Load a draft artifacts object from a plain, unwrapped NostrEvent
+ ///
+ /// This function will parse the contents of a Nostr Event and turn it into an editable draft that we can use.
+ ///
+ /// - Parameters:
+ /// - event: The Nostr event to use as a template
+ /// - draft_id: The unique ID of this draft, used for keeping draft identities stable. UUIDs are recommended but not required.
+ /// - damus_state: The user's Damus state, used for fetching profiles in NostrDB
+ /// - Returns: The draft that can be loaded into `PostView`.
+ static func from(event: NostrEvent, draft_id: String, damus_state: DamusState) -> DraftArtifacts {
+ let parsed_blocks = parse_note_content(content: .init(note: event, keypair: damus_state.keypair))
+ return Self.from(parsed_blocks: parsed_blocks, references: Array(event.references), draft_id: draft_id, damus_state: damus_state)
+ }
+
+ /// Load a draft artifacts object from parsed Nostr event blocks
+ ///
+ /// - Parameters:
+ /// - parsed_blocks: The blocks parsed from a Nostr event
+ /// - references: The references in the Nostr event
+ /// - draft_id: The unique ID of the draft as per NIP-37
+ /// - damus_state: Damus state, used for fetching profile info in NostrDB
+ /// - Returns: The draft that can be loaded into `PostView`.
+ static func from(parsed_blocks: Blocks, references: [RefId], draft_id: String, damus_state: DamusState) -> DraftArtifacts {
+ let rich_text_content: NSMutableAttributedString = .init(string: "")
+ var media: [UploadedMedia] = []
+ for block in parsed_blocks.blocks {
+ switch block {
+ case .mention(let mention):
+ if case .pubkey(let pubkey) = mention.ref {
+ // A profile reference, format things properly.
+ let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile
+ let profile_name = parse_display_name(profile: profile, pubkey: pubkey).username
+ guard let url_address = URL(string: block.asString) else {
+ rich_text_content.append(.init(string: block.asString))
+ continue
+ }
+ let attributed_string = NSMutableAttributedString(
+ string: "@\(profile_name)",
+ attributes: [
+ .link: url_address,
+ .foregroundColor: UIColor(Color.accentColor)
+ ]
+ )
+ rich_text_content.append(attributed_string)
+ }
+ else if case .note(_) = mention.ref {
+ // These note references occur when we quote a note, and since that is tracked via `PostAction` in `PostView`, ignore it here to avoid attaching the same event twice in a note
+ continue
+ }
+ else {
+ // Other references
+ rich_text_content.append(.init(string: block.asString))
+ }
+ case .url(let url):
+ if isSupportedImage(url: url) {
+ // Image, add that to our media attachments
+ // TODO: Add metadata decoding support
+ media.append(UploadedMedia(localURL: url, uploadedURL: url, metadata: .none))
+ continue
+ }
+ else {
+ // Normal URL, plain text
+ rich_text_content.append(.init(string: block.asString))
+ }
+ case .invoice(_), .relay(_), .hashtag(_), .text(_):
+ // Everything else is currently plain text.
+ rich_text_content.append(.init(string: block.asString))
+ }
+ }
+ return DraftArtifacts(content: rich_text_content, media: media, references: references, id: draft_id)
+ }
}
+
+/// Holds and keeps track of the note post drafts throughout the app.
class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil
- @Published var replies: [NostrEvent: DraftArtifacts] = [:]
- @Published var quotes: [NostrEvent: DraftArtifacts] = [:]
- @Published var highlights: [HighlightSource: DraftArtifacts] = [:]
+ @Published var replies: [NoteId: DraftArtifacts] = [:]
+ @Published var quotes: [NoteId: DraftArtifacts] = [:]
+ /// The drafts we have for highlights
+ ///
+ /// ## Implementation notes
+ /// - Although in practice we also load drafts based on the highlight source for better UX (making it easier to find a draft), we need the keys to be of type `HighlightContentDraft` because we need the selected text information to be able to construct the NIP-37 draft, as well as to load that into post view.
+ @Published var highlights: [HighlightContentDraft: DraftArtifacts] = [:]
+
+ /// Loads drafts from storage (NostrDB + UserDefaults)
+ func load(from damus_state: DamusState) {
+ guard let note_ids = damus_state.settings.draft_event_ids?.compactMap({ NoteId(hex: $0) }) else { return }
+ for note_id in note_ids {
+ let txn = damus_state.ndb.lookup_note(note_id)
+ guard let note = txn?.unsafeUnownedValue else { continue }
+ // Implementation note: This currently fails silently, because:
+ // 1. Errors are unlikely and not expected
+ // 2. It is not mission critical to recover from this error
+ // 3. The changes that add a error view sheet with useful info is not yet merged in as of writing.
+ try? self.load(wrapped_draft_note: note, with: damus_state)
+ }
+ }
+
+ /// Loads a specific NIP-37 note into this class
+ func load(wrapped_draft_note: NdbNote, with damus_state: DamusState) throws {
+ // Extract draft info from the NIP-37 note
+ guard let full_keypair = damus_state.keypair.to_full() else { return }
+ guard let nip37_draft = try NIP37Draft(wrapped_note: wrapped_draft_note, keypair: full_keypair) else { return }
+ guard let known_kind = nip37_draft.unwrapped_note.known_kind else { return }
+ guard let draft_artifacts = DraftArtifacts.from(
+ nip37_draft: nip37_draft,
+ damus_state: damus_state
+ ) else { return }
+
+ // Find out where to place these drafts
+ let blocks = parse_note_content(content: .note(nip37_draft.unwrapped_note))
+ switch known_kind {
+ case .text:
+ if let replied_to_note_id = nip37_draft.unwrapped_note.direct_replies() {
+ self.replies[replied_to_note_id] = draft_artifacts
+ }
+ else {
+ for block in blocks.blocks {
+ if case .mention(let mention) = block {
+ if case .note(let note_id) = mention.ref {
+ self.quotes[note_id] = draft_artifacts
+ return
+ }
+ }
+ }
+ self.post = draft_artifacts
+ }
+ case .highlight:
+ guard let highlight = HighlightContentDraft(from: nip37_draft.unwrapped_note) else { return }
+ self.highlights[highlight] = draft_artifacts
+ default:
+ return
+ }
+ }
+
+ /// Saves the drafts tracked by this class persistently using NostrDB + UserDefaults
+ func save(damus_state: DamusState) {
+ var draft_events: [NdbNote] = []
+ post_artifact_block: if let post_artifacts = self.post {
+ let nip37_draft = try? post_artifacts.to_nip37_draft(action: .posting(.user(damus_state.pubkey)), damus_state: damus_state)
+ guard let wrapped_note = nip37_draft?.wrapped_note else { break post_artifact_block }
+ draft_events.append(wrapped_note)
+ }
+ for (replied_to_note_id, reply_artifacts) in self.replies {
+ guard let replied_to_note = damus_state.ndb.lookup_note(replied_to_note_id)?.unsafeUnownedValue?.to_owned() else { continue }
+ let nip37_draft = try? reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state)
+ guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
+ draft_events.append(wrapped_note)
+ }
+ for (quoted_note_id, quote_note_artifacts) in self.quotes {
+ guard let quoted_note = damus_state.ndb.lookup_note(quoted_note_id)?.unsafeUnownedValue?.to_owned() else { continue }
+ let nip37_draft = try? quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state)
+ guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
+ draft_events.append(wrapped_note)
+ }
+ for (highlight, highlight_note_artifacts) in self.highlights {
+ let nip37_draft = try? highlight_note_artifacts.to_nip37_draft(action: .highlighting(highlight), damus_state: damus_state)
+ guard let wrapped_note = nip37_draft?.wrapped_note else { continue }
+ draft_events.append(wrapped_note)
+ }
+
+ for draft_event in draft_events {
+ // Implementation note: We do not support draft synchronization with relays yet.
+ // TODO: Once it is time to implement draft syncing with relays, please consider the following:
+ // - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
+ // - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
+ damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
+ }
+
+ damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
+ }
+}
+
+// MARK: - Convenience extensions
+
+fileprivate extension Array {
+ mutating func appendIfNotNil(_ element: Element?) {
+ if let element = element {
+ self.append(element)
+ }
+ }
}
diff --git a/damus/Models/HighlightEvent.swift b/damus/Models/HighlightEvent.swift
@@ -188,17 +188,29 @@ extension HighlightEvent {
struct HighlightContentDraft: Hashable {
let selected_text: String
let source: HighlightSource
+
+
+ init(selected_text: String, source: HighlightSource) {
+ self.selected_text = selected_text
+ self.source = source
+ }
+
+ init?(from note: NdbNote) {
+ guard let source = HighlightSource.from(tags: note.tags.strings()) else { return nil }
+ self.source = source
+ self.selected_text = note.content
+ }
}
enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
- case event(NostrEvent)
+ case event(NoteId)
case external_url(URL)
func tags() -> [[String]] {
switch self {
- case .event(let event):
- return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
+ case .event(let event_id):
+ return [ ["e", "\(event_id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url):
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
}
@@ -206,12 +218,26 @@ enum HighlightSource: Hashable {
func ref() -> RefId {
switch self {
- case .event(let event):
- return .event(event.id)
+ case .event(let event_id):
+ return .event(event_id)
case .external_url(let url):
return .reference(url.absoluteString)
}
}
+
+ static func from(tags: [[String]]) -> HighlightSource? {
+ for tag in tags {
+ if tag.count == 3 && tag[0] == "e" && tag[2] == HighlightSource.TAG_SOURCE_ELEMENT {
+ guard let event_id = NoteId(hex: tag[1]) else { continue }
+ return .event(event_id)
+ }
+ if tag.count == 3 && tag[0] == "r" && tag[2] == HighlightSource.TAG_SOURCE_ELEMENT {
+ guard let url = URL(string: tag[1]) else { continue }
+ return .external_url(url)
+ }
+ }
+ return nil
+ }
}
struct ShareContent {
diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift
@@ -122,6 +122,7 @@ class HomeModel: ContactsDelegate {
/// This is called whenever DamusState gets set. This function is used to load or setup anything we need from the new DamusState
func load_our_stuff_from_damus_state() {
self.load_latest_contact_event_from_damus_state()
+ self.load_drafts_from_damus_state()
}
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
@@ -134,6 +135,10 @@ class HomeModel: ContactsDelegate {
process_contact_event(state: damus_state, ev: latest_contact_event)
}
+ func load_drafts_from_damus_state() {
+ damus_state.drafts.load(from: damus_state)
+ }
+
// MARK: - ContactsDelegate functions
func latest_contact_event_changed(new_event: NostrEvent) {
@@ -215,6 +220,10 @@ class HomeModel: ContactsDelegate {
break
case .status:
handle_status_event(ev)
+ case .draft:
+ // TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
+ // try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
+ break
}
}
diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift
@@ -122,6 +122,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
}
}
+protocol URLEncodable {
+ func url() -> URL?
+}
+
struct Mention<T: Equatable>: Equatable {
let index: Int?
let ref: T
diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift
@@ -324,9 +324,13 @@ class UserSettingsStore: ObservableObject {
// MARK: Internal, hidden settings
+ // TODO: Get rid of this once we have NostrDB query capabilities integrated
@Setting(key: "latest_contact_event_id", default_value: nil)
var latest_contact_event_id_hex: String?
+ // TODO: Get rid of this once we have NostrDB query capabilities integrated
+ @Setting(key: "draft_event_ids", default_value: nil)
+ var draft_event_ids: [String]?
// MARK: Helper types
diff --git a/damus/NIP37/NIP37Draft.swift b/damus/NIP37/NIP37Draft.swift
@@ -0,0 +1,146 @@
+//
+// NIP37Draft.swift
+// damus
+//
+// Created by Daniel D’Aquino 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())
+ }
+}
diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift
@@ -19,6 +19,7 @@ enum NostrKind: UInt32, Codable {
case chat = 42
case mute_list = 10000
case list_deprecated = 30000
+ case draft = 31234
case longform = 30023
case zap = 9735
case zap_request = 9734
diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift
@@ -51,21 +51,29 @@ enum PostAction {
}
struct PostView: View {
+
@State var post: NSMutableAttributedString = NSMutableAttributedString()
+ @State var uploadedMedias: [UploadedMedia] = []
+ @State var references: [RefId] = []
+ /// Pubkeys that should be filtered out from the references
+ ///
+ /// For example, when replying to an event, the user can select which pubkey mentions they want to keep, and which ones to remove.
+ @State var filtered_pubkeys: Set<Pubkey> = []
+
@FocusState var focus: Bool
@State var attach_media: Bool = false
@State var attach_camera: Bool = false
@State var error: String? = nil
- @State var uploadedMedias: [UploadedMedia] = []
@State var image_upload_confirm: Bool = false
@State var imagePastedFromPasteboard: PreUploadedMedia? = nil
@State var imageUploadConfirmPasteboard: Bool = false
- @State var references: [RefId] = []
@State var imageUploadConfirmDamusShare: Bool = false
- @State var filtered_pubkeys: Set<Pubkey> = []
@State var focusWordAttributes: (String?, NSRange?) = (nil, nil)
@State var newCursorIndex: Int?
@State var textHeight: CGFloat? = nil
+ @State var saved_state: SaveState = .needs_saving()
+ /// A timer that helps us add a delay between when changes occur and when they are saved persistently (to avoid too many disk writes and a jittery save indicator)
+ let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State var preUploadedMedia: [PreUploadedMedia] = []
@@ -81,6 +89,16 @@ struct PostView: View {
let placeholder_messages: [String]
let initial_text_suffix: String?
+ enum SaveState: Equatable {
+ /// The draft has been modified and needs saving.
+ /// Saving should occur in N seconds
+ case needs_saving(seconds_remaining: Int = 3)
+ /// A saving operation is in progress
+ case saving
+ /// The draft has been saved to disk.
+ case saved
+ }
+
init(
action: PostAction,
damus_state: DamusState,
@@ -109,24 +127,7 @@ struct PostView: View {
}
func send_post() {
- // don't add duplicate pubkeys but retain order
- var pkset = Set<Pubkey>()
-
- // we only want pubkeys really
- let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in
- guard case .pubkey(let pk) = ref else {
- return
- }
-
- if pkset.contains(pk) || filtered_pubkeys.contains(pk) {
- return
- }
-
- pkset.insert(pk)
- acc.append(pk)
- }
-
- let new_post = build_post(state: damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks)
+ let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys)
notify(.post(.post(new_post)))
@@ -182,10 +183,33 @@ struct PostView: View {
})
}
+ var save_state_indicator: some View {
+ HStack {
+ switch saved_state {
+ case .needs_saving:
+ EmptyView()
+ .accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
+ case .saving:
+ ProgressView()
+ .accessibilityHidden(true) // Probably no need to show this to visually impaired users, might be too noisy
+ case .saved:
+ Image(systemName: "checkmark")
+ .accessibilityHidden(true)
+ Text("Saved", comment: "Small label indicating that the user's draft has been saved to storage")
+ .accessibilityLabel(NSLocalizedString("Your draft has been saved to storage", comment: "Accessibility label indicating that a user's post draft has been saved, meant only for visually impaired users"))
+ .font(.caption)
+ }
+ }
+ .padding(6)
+ .foregroundStyle(.secondary)
+ }
+
var AttachmentBar: some View {
HStack(alignment: .center, spacing: 15) {
ImageButton
CameraButton
+ Spacer()
+ self.save_state_indicator
}
.disabled(uploading_disabled)
}
@@ -222,13 +246,13 @@ struct PostView: View {
func clear_draft() {
switch action {
case .replying_to(let replying_to):
- damus_state.drafts.replies.removeValue(forKey: replying_to)
+ damus_state.drafts.replies.removeValue(forKey: replying_to.id)
case .quoting(let quoting):
- damus_state.drafts.quotes.removeValue(forKey: quoting)
+ damus_state.drafts.quotes.removeValue(forKey: quoting.id)
case .posting:
damus_state.drafts.post = nil
case .highlighting(let draft):
- damus_state.drafts.highlights.removeValue(forKey: draft.source)
+ damus_state.drafts.highlights.removeValue(forKey: draft)
case .sharing(_):
damus_state.drafts.post = nil
}
@@ -239,23 +263,31 @@ struct PostView: View {
guard let draft = load_draft_for_post(drafts: self.damus_state.drafts, action: self.action) else {
self.post = NSMutableAttributedString("")
self.uploadedMedias = []
-
+ self.saved_state = .needs_saving()
return false
}
self.uploadedMedias = draft.media
self.post = draft.content
+ self.saved_state = .saved
return true
}
-
+
+ /// Use this to signal that the post contents have changed. This will do two things:
+ ///
+ /// 1. Save the new contents into our in-memory drafts
+ /// 2. Signal that we need to save drafts persistently, which will happen after a certain wait period
func post_changed(post: NSMutableAttributedString, media: [UploadedMedia]) {
if let draft = load_draft_for_post(drafts: damus_state.drafts, action: action) {
draft.content = post
- draft.media = media
+ draft.media = uploadedMedias
+ draft.references = references
+ draft.filtered_pubkeys = filtered_pubkeys
} else {
- let artifacts = DraftArtifacts(content: post, media: media)
+ let artifacts = DraftArtifacts(content: post, media: uploadedMedias, references: references, id: UUID().uuidString)
set_draft_for_post(drafts: damus_state.drafts, action: action, artifacts: artifacts)
}
+ self.saved_state = .needs_saving()
}
var TextEntry: some View {
@@ -356,7 +388,7 @@ struct PostView: View {
}
let blurhash = await blurhash
let meta = blurhash.map { bh in calculate_image_metadata(url: url, img: img, blurhash: bh) }
- let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, representingImage: img, metadata: meta)
+ let uploadedMedia = UploadedMedia(localURL: media.localURL, uploadedURL: url, metadata: meta)
uploadedMedias.append(uploadedMedia)
return true
@@ -570,6 +602,21 @@ struct PostView: View {
preUploadedMedia.removeAll()
}
}
+ .onReceive(timer) { time in
+ switch self.saved_state {
+ case .needs_saving(seconds_remaining: let seconds_remaining):
+ if seconds_remaining <= 0 {
+ self.saved_state = .saving
+ damus_state.drafts.save(damus_state: damus_state)
+ self.saved_state = .saved
+ }
+ else {
+ self.saved_state = .needs_saving(seconds_remaining: seconds_remaining - 1)
+ }
+ case .saving, .saved:
+ break
+ }
+ }
}
}
@@ -628,7 +675,7 @@ struct PVImageCarouselView: View {
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
- } else if is_animated_image(url: media[index].uploadedURL) {
+ } else {
KFAnimatedImage(media[index].uploadedURL)
.imageContext(.note, disable_animation: false)
.configure { view in
@@ -638,14 +685,6 @@ struct PVImageCarouselView: View {
.cornerRadius(10)
.padding()
.contextMenu { contextMenuContent(for: media[index]) }
- } else {
- Image(uiImage: media[index].representingImage)
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: media.count == 1 ? deviceWidth * 0.8 : 250, height: media.count == 1 ? 400 : 250)
- .cornerRadius(10)
- .padding()
- .contextMenu { contextMenuContent(for: media[index]) }
}
VStack { // Set spacing to 0 to remove the gap between items
@@ -732,7 +771,6 @@ fileprivate func getImage(media: MediaUpload) -> UIImage {
struct UploadedMedia: Equatable {
let localURL: URL
let uploadedURL: URL
- let representingImage: UIImage
let metadata: ImageMetadata?
}
@@ -740,13 +778,13 @@ struct UploadedMedia: Equatable {
func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArtifacts) {
switch action {
case .replying_to(let ev):
- drafts.replies[ev] = artifacts
+ drafts.replies[ev.id] = artifacts
case .quoting(let ev):
- drafts.quotes[ev] = artifacts
+ drafts.quotes[ev.id] = artifacts
case .posting:
drafts.post = artifacts
case .highlighting(let draft):
- drafts.highlights[draft.source] = artifacts
+ drafts.highlights[draft] = artifacts
case .sharing(_):
drafts.post = artifacts
}
@@ -755,13 +793,21 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts? {
switch action {
case .replying_to(let ev):
- return drafts.replies[ev]
+ return drafts.replies[ev.id]
case .quoting(let ev):
- return drafts.quotes[ev]
+ return drafts.quotes[ev.id]
case .posting:
return drafts.post
- case .highlighting(let draft):
- return drafts.highlights[draft.source]
+ case .highlighting(let highlight):
+ if let exact_match = drafts.highlights[highlight] {
+ return exact_match // Always prefer to return the draft for that exact same highlight
+ }
+ // If there are no exact matches to the highlight, try to load a draft for the same highlight source
+ // We do this to improve UX, because we don't want to leave the post view blank if they only selected a slightly different piece of text from before.
+ var other_matches = drafts.highlights
+ .filter { $0.key.source == highlight.source }
+ // It's not an exact match, so there is no way of telling which one is the preferred draft. So just load the first one we found.
+ return other_matches.first?.value
case .sharing(_):
return drafts.post
}
@@ -788,7 +834,53 @@ func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
return tags
}
-func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
+func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> NostrPost {
+ return build_post(
+ state: state,
+ post: draft.content,
+ action: action,
+ uploadedMedias: draft.media,
+ references: draft.references,
+ filtered_pubkeys: draft.filtered_pubkeys
+ )
+}
+
+func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set<Pubkey>) -> NostrPost {
+ // don't add duplicate pubkeys but retain order
+ var pkset = Set<Pubkey>()
+
+ // we only want pubkeys really
+ let pks = references.reduce(into: Array<Pubkey>()) { acc, ref in
+ guard case .pubkey(let pk) = ref else {
+ return
+ }
+
+ if pkset.contains(pk) || filtered_pubkeys.contains(pk) {
+ return
+ }
+
+ pkset.insert(pk)
+ acc.append(pk)
+ }
+
+ return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks)
+}
+
+/// This builds a Nostr post from draft data from `PostView` or other draft-related classes
+///
+/// ## Implementation notes
+///
+/// - This function _likely_ causes no side-effects, and _should not_ cause side-effects to any of the inputs.
+///
+/// - Parameters:
+/// - state: The damus state, needed to fetch more Nostr data to form this event
+/// - post: The text content from `PostView`.
+/// - action: The intended action of the post (highlighting? replying?)
+/// - uploadedMedias: The medias attached to this post
+/// - pubkeys: The referenced pubkeys
+/// - Returns: A NostrPost, which can then be signed into an event.
+func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
+ let post = NSMutableAttributedString(attributedString: post)
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
if let link = attributes[.link] as? String {
let nextCharIndex = range.upperBound
@@ -876,3 +968,11 @@ func isSupportedVideo(url: URL?) -> Bool {
return false
}
}
+
+func isSupportedImage(url: URL) -> Bool {
+ let fileExtension = url.pathExtension.lowercased()
+ // It would be better to pull this programmatically from Apple's APIs, but there seems to be no such call
+ let supportedTypes = ["jpg", "png", "gif"]
+ return supportedTypes.contains(fileExtension)
+}
+
diff --git a/damusTests/DraftTests.swift b/damusTests/DraftTests.swift
@@ -0,0 +1,21 @@
+//
+// DraftTests.swift
+// damusTests
+//
+// Created by Daniel D’Aquino on 2025-01-15
+
+import XCTest
+@testable import damus
+
+class DraftTests: XCTestCase {
+ func testRoundtripNIP37Draft() {
+ let test_note =
+ NostrEvent(
+ content: "Test",
+ keypair: test_keypair_full.to_keypair(),
+ createdAt: UInt32(Date().timeIntervalSince1970 - 100)
+ )!
+ let draft = try! NIP37Draft(unwrapped_note: test_note, draft_id: "test", keypair: test_keypair_full)!
+ XCTAssertEqual(draft.unwrapped_note, test_note)
+ }
+}