diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 5cac8ad6..e27f4bd1 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -440,7 +440,7 @@ struct ContentView: View { } Task { - if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) { + if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post, clientTag: state.clientTagComponents) { self.active_sheet = nil } } @@ -1081,13 +1081,27 @@ func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool return await handle_follow(state: state, follow: target.follow_ref) } -func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) async -> Bool { +/// Handles a post notification by converting the post to a signed nostr event and broadcasting it. +/// +/// - Parameters: +/// - keypair: The user's full keypair used to sign the event. +/// - postbox: The postbox used to broadcast the event to relays. +/// - events: The event cache used to look up referenced events for rebroadcasting. +/// - post: The post result, either a post to publish or a cancellation. +/// - clientTag: Optional client tag array (e.g., `["client", "Damus"]`) to include in the event, +/// identifying which application created the post. Pass `nil` to omit the tag. +/// - Returns: `true` if the post was successfully converted and sent, `false` if the post was +/// cancelled or if event conversion failed. +/// +/// When successful, this function also rebroadcasts up to 3 referenced events and 3 quoted events +/// to help ensure they are available on relays. +func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult, clientTag: [String]? = nil) async -> Bool { switch post { case .post(let post): //let post = tup.0 //let to_relays = tup.1 print("post \(post.content)") - guard let new_ev = post.to_event(keypair: keypair) else { + guard let new_ev = post.to_event(keypair: keypair, clientTag: clientTag) else { return false } await postbox.send(new_ev) diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index 68eb253b..7e2b839d 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -32,6 +32,50 @@ enum ValidationResult: Decodable { case bad_sig } +/// Represents metadata from a NIP-89 client tag (`["client", name, address?, relay?]`). +/// Used to identify which application published a nostr event. +struct ClientTagMetadata: Equatable { + /// The client application name (e.g., "Damus"). + let name: String + /// Optional NIP-89 handler address for the client. + let handlerAddress: String? + /// Optional relay hint where the handler can be found. + let relayHint: String? + + init(name: String, handlerAddress: String? = nil, relayHint: String? = nil) { + self.name = name + self.handlerAddress = handlerAddress + self.relayHint = relayHint + } + + /// Parses client tag metadata from tag components array. + /// - Parameter tagComponents: Array where index 0 is "client", index 1 is name, etc. + /// - Returns: nil if the tag is not a valid client tag. + init?(tagComponents: [String]) { + guard tagComponents.first == "client", let clientName = tagComponents[safe: 1], !clientName.isEmpty else { + return nil + } + self.name = clientName + self.handlerAddress = tagComponents[safe: 2] + self.relayHint = tagComponents[safe: 3] + } + + /// Converts this metadata back into a tag array suitable for inclusion in an event. + var tagValues: [String] { + var components = ["client", name] + if let handlerAddress, !handlerAddress.isEmpty { + components.append(handlerAddress) + if let relayHint, !relayHint.isEmpty { + components.append(relayHint) + } + } + return components + } + + /// The default Damus client tag. + static let damus = ClientTagMetadata(name: "Damus") +} + /* class NostrEventOld: Codable, Identifiable, CustomStringConvertible, Equatable, Hashable, Comparable { // TODO: memory mapped db events diff --git a/damus/Core/Storage/DamusState.swift b/damus/Core/Storage/DamusState.swift index e62f3bba..e4968263 100644 --- a/damus/Core/Storage/DamusState.swift +++ b/damus/Core/Storage/DamusState.swift @@ -165,6 +165,14 @@ class DamusState: HeadlessDamusState, ObservableObject { keypair.privkey != nil } + /// Returns the Damus client tag array if the user has enabled client tag publishing, nil otherwise. + var clientTagComponents: [String]? { + guard settings.publish_client_tag else { + return nil + } + return ClientTagMetadata.damus.tagValues + } + func close() { print("txn: damus close") Task { diff --git a/damus/Features/Posting/Models/DraftsModel.swift b/damus/Features/Posting/Models/DraftsModel.swift index 3e10932c..333a6ab5 100644 --- a/damus/Features/Posting/Models/DraftsModel.swift +++ b/damus/Features/Posting/Models/DraftsModel.swift @@ -67,7 +67,7 @@ class DraftArtifacts: Equatable { func to_nip37_draft(action: PostAction, damus_state: DamusState) async throws -> NIP37Draft? { guard let keypair = damus_state.keypair.to_full() else { return nil } let post = await build_post(state: damus_state, action: action, draft: self) - guard let note = post.to_event(keypair: keypair) else { return nil } + guard let note = post.to_event(keypair: keypair, clientTag: damus_state.clientTagComponents) else { return nil } return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair) } diff --git a/damus/Features/Posting/Models/Post.swift b/damus/Features/Posting/Models/Post.swift index a3c4d7e0..30c7b8de 100644 --- a/damus/Features/Posting/Models/Post.swift +++ b/damus/Features/Posting/Models/Post.swift @@ -18,7 +18,7 @@ struct NostrPost { self.tags = tags } - func to_event(keypair: FullKeypair) -> NostrEvent? { + func to_event(keypair: FullKeypair, clientTag: [String]? = nil) -> NostrEvent? { let post_blocks = self.parse_blocks() let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags) let content = post_tags.blocks @@ -30,10 +30,13 @@ struct NostrPost { if content.count > 0 { new_tags.append(["comment", content]) } + addClientTagIfNeeded(clientTag, to: &new_tags) return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags) } - return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags) + var final_tags = post_tags.tags + addClientTagIfNeeded(clientTag, to: &final_tags) + return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: final_tags) } func parse_blocks() -> [Block] { @@ -80,6 +83,17 @@ struct NostrPost { } } +extension NostrPost { + /// Appends a client tag to the tags array if one is provided and not already present. + fileprivate func addClientTagIfNeeded(_ clientTag: [String]?, to tags: inout [[String]]) { + guard let clientTag else { return } + guard tags.first(where: { $0.first == "client" }) == nil else { + return + } + tags.append(clientTag) + } +} + // MARK: - Helper structures and functions extension NostrPost { diff --git a/damus/Features/Settings/Models/UserSettingsStore.swift b/damus/Features/Settings/Models/UserSettingsStore.swift index ce98af90..0f94ad27 100644 --- a/damus/Features/Settings/Models/UserSettingsStore.swift +++ b/damus/Features/Settings/Models/UserSettingsStore.swift @@ -219,6 +219,10 @@ class UserSettingsStore: ObservableObject { @Setting(key: "auto_translate", default_value: true) var auto_translate: Bool + /// Whether to include a client tag identifying Damus when publishing events. + @Setting(key: "publish_client_tag", default_value: true) + var publish_client_tag: Bool + @Setting(key: "show_general_statuses", default_value: true) var show_general_statuses: Bool diff --git a/damusTests/NostrEventTests.swift b/damusTests/NostrEventTests.swift index 827e8909..28738066 100644 --- a/damusTests/NostrEventTests.swift +++ b/damusTests/NostrEventTests.swift @@ -40,4 +40,18 @@ final class NostrEventTests: XCTestCase { let urlInContent2 = "https://cdn.nostr.build/i/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg" XCTAssert(testEvent2.content.contains(urlInContent2), "Issue parsing event. Expected to see '\(urlInContent2)' inside \(testEvent2.content)") } + + func testClientTagParsing() { + let tags = [["client", "Custom Client", "addr", "wss://relay.example"], ["p", test_pubkey.hex()]] + let event = NostrEvent(content: "hi", keypair: test_keypair, kind: 1, tags: tags)! + let metadata = event.clientTag + XCTAssertEqual(metadata?.name, "Custom Client") + XCTAssertEqual(metadata?.handlerAddress, "addr") + XCTAssertEqual(metadata?.relayHint, "wss://relay.example") + } + + func testClientTagNilWhenMissing() { + let event = NostrEvent(content: "hi", keypair: test_keypair, kind: 1, tags: [])! + XCTAssertNil(event.clientTag) + } } diff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift index 5a3ebb8f..79415230 100644 --- a/damusTests/PostViewTests.swift +++ b/damusTests/PostViewTests.swift @@ -220,7 +220,7 @@ final class PostViewTests: XCTestCase { XCTAssertEqual(post.content, "nostr:\(test_pubkey.npub)") } - /// Tests that pasting an npub converts it to a mention link (issue #2289) +/// Tests that pasting an npub converts it to a mention link (issue #2289) func testPastedNpubConvertsToMention() { let content = NSMutableAttributedString(string: "Hello ") var resultContent: NSMutableAttributedString? @@ -318,6 +318,25 @@ final class PostViewTests: XCTestCase { XCTAssertTrue(shouldChange, "shouldChangeTextIn should return true for regular text") } + + /// Tests that client tags are added to events when provided. + func testToEventAddsClientTagWhenProvided() { + let post = NostrPost(content: "gm") + let clientTag = ["client", "Damus"] + let event = post.to_event(keypair: test_keypair_full, clientTag: clientTag) + XCTAssertTrue(event?.tags.contains(where: { $0 == clientTag }) ?? false) + } + + /// Tests that existing client tags are not duplicated. + func testToEventDoesNotDuplicateExistingClientTag() { + let existingTags = [["client", "Custom"]] + let post = NostrPost(content: "gm", tags: existingTags) + let clientTag = ["client", "Damus"] + let event = post.to_event(keypair: test_keypair_full, clientTag: clientTag) + let clientTagCount = event?.tags.filter { $0.first == "client" }.count + XCTAssertEqual(clientTagCount, 1) + XCTAssertEqual(event?.tags.first(where: { $0.first == "client" }), existingTags.first) + } } func checkMentionLinkEditorHandling( @@ -354,4 +373,3 @@ func testAddingStringAfterLink(str: String) { }) } - diff --git a/highlighter action extension/ActionViewController.swift b/highlighter action extension/ActionViewController.swift index 1ff09a01..092c255f 100644 --- a/highlighter action extension/ActionViewController.swift +++ b/highlighter action extension/ActionViewController.swift @@ -235,7 +235,7 @@ struct ShareExtensionView: View { self.highlighter_state = .not_logged_in return } - guard let posted_event = post.to_event(keypair: full_keypair) else { + guard let posted_event = post.to_event(keypair: full_keypair, clientTag: state.clientTagComponents) else { self.highlighter_state = .failed(error: "Cannot convert post data into a nostr event") return } diff --git a/share extension/ShareViewController.swift b/share extension/ShareViewController.swift index 2b4004c2..ee362a07 100644 --- a/share extension/ShareViewController.swift +++ b/share extension/ShareViewController.swift @@ -226,7 +226,7 @@ struct ShareExtensionView: View { self.share_state = .not_logged_in return } - guard let posted_event = post.to_event(keypair: full_keypair) else { + guard let posted_event = post.to_event(keypair: full_keypair, clientTag: state.clientTagComponents) else { self.share_state = .failed(error: "Cannot convert post data into a nostr event") return } @@ -375,4 +375,3 @@ struct ShareExtensionView: View { case posted(event: NostrEvent) } } -