Add Damus client tag emission

- Add ClientTagMetadata struct with parsing helpers and documentation
- Append Damus client tags when posting across app, share, and drafts flows
- Gate the behavior behind a new publish_client_tag setting (default on)

Changelog-Added: Add client tag to published events to identify Damus
Ref: https://github.com/damus-io/damus/issues/3323
Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
This commit is contained in:
alltheseas
2026-02-02 12:15:11 -06:00
committed by Daniel D’Aquino
parent 795fce1b65
commit ec28822451
11 changed files with 126 additions and 11 deletions

0
.beads/issues.jsonl Normal file
View File

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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) {
})
}

View File

@@ -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
}

View File

@@ -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)
}
}