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:
committed by
Daniel D’Aquino
parent
795fce1b65
commit
ec28822451
0
.beads/issues.jsonl
Normal file
0
.beads/issues.jsonl
Normal 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user