damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit d07ad67778825811eed62da5f2750e92205c2840
parent af75eed83a2a1dd0eb33a0a27ded71c9f44dacbd
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu, 18 Jan 2024 14:59:27 -0500

nip19: add bech32 TLV url parsing

Create shortened URLs for bech32 with TLV data strings. Additionally,
upon clicking on an nevent URL the user is directed to the note.

Lightning-url: LNURL1DP68GURN8GHJ7EM9W3SKCCNE9E3K7MF0D3H82UNVWQHKWUN9V4HXGCTHDC6RZVGR8SW3G
Signed-off-by: kernelkind <kernelkind@gmail.com>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/ContentView.swift | 42+++++++++++++++++++++++++++---------------
Mdamus/Models/Contacts+.swift | 2+-
Mdamus/Models/Mentions.swift | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mdamus/Models/NoteContent.swift | 43++++++++++++++++++++++++-------------------
Mdamus/Nostr/ReferencedId.swift | 9+++++++--
Mdamus/Types/Block.swift | 46+++++++---------------------------------------
Mdamus/Util/Bech32Object.swift | 29++++++++++++++++++++++++++---
MdamusTests/NoteContentViewTests.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MdamusTests/damusTests.swift | 42++++++++++++++++++++++++++++++++++++++++++
9 files changed, 285 insertions(+), 88 deletions(-)

diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -492,21 +492,15 @@ struct ContentView: View { open_profile(pubkey: pubkey) case .note(let noteId): - guard let target = damus_state.events.lookup(noteId) else { - return - } - - switch local.type { - case .dm: - selected_timeline = .dms - damus_state.dms.set_active_dm(target.pubkey) - navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model)) - case .like, .zap, .mention, .repost: - open_event(ev: target) - case .profile_zap: - // Handled separately above. - break - } + openEvent(noteId: noteId, notificationType: local.type) + case .nevent(let nevent): + openEvent(noteId: nevent.noteid, notificationType: local.type) + case .nprofile(let nprofile): + open_profile(pubkey: nprofile.author) + case .nrelay(_): + break + case .naddr(let naddr): + break } @@ -725,6 +719,22 @@ struct ContentView: View { } } + private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) { + guard let target = damus_state.events.lookup(noteId) else { + return + } + + switch notificationType { + case .dm: + selected_timeline = .dms + damus_state.dms.set_active_dm(target.pubkey) + navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model)) + case .like, .zap, .mention, .repost: + open_event(ev: target) + case .profile_zap: + break + } + } } struct ContentView_Previews: PreviewProvider { @@ -1082,6 +1092,8 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> case .param, .quote: // doesn't really make sense here break + case .naddr(let naddr): + break // TODO: fix } case .filter(let filt): result(.filter(filt)) diff --git a/damus/Models/Contacts+.swift b/damus/Models/Contacts+.swift @@ -113,7 +113,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool { case let (.pubkey(pk), .pubkey(follow_pk)): return pk == follow_pk case (.hashtag, .pubkey), (.pubkey, .hashtag), - (.event, _), (.quote, _), (.param, _): + (.event, _), (.quote, _), (.param, _), (.naddr, _): return false } } diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -10,6 +10,8 @@ import Foundation enum MentionType: AsciiCharacter, TagKey { case p case e + case a + case r var keychar: AsciiCharacter { self.rawValue @@ -17,21 +19,26 @@ enum MentionType: AsciiCharacter, TagKey { } enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { - case pubkey(Pubkey) // TODO: handle nprofile + case pubkey(Pubkey) case note(NoteId) + case nevent(NEvent) + case nprofile(NProfile) + case nrelay(String) + case naddr(NAddr) var key: MentionType { switch self { case .pubkey: return .p case .note: return .e + case .nevent: return .e + case .nprofile: return .p + case .nrelay: return .r + case .naddr: return .a } } var bech32: String { - switch self { - case .pubkey(let pubkey): return bech32_pubkey(pubkey) - case .note(let noteId): return bech32_note_id(noteId) - } + return Bech32Object.encode(toBech32Object()) } static func from_bech32(str: String) -> MentionRef? { @@ -46,6 +53,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { switch self { case .pubkey(let pubkey): return pubkey case .note: return nil + case .nevent(let nevent): return nevent.author + case .nprofile(let nprofile): return nprofile.author + case .nrelay: return nil + case .naddr: return nil } } @@ -53,6 +64,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { switch self { case .pubkey(let pubkey): return ["p", pubkey.hex()] case .note(let noteId): return ["e", noteId.hex()] + case .nevent(let nevent): return ["e", nevent.noteid.hex()] + case .nprofile(let nprofile): return ["p", nprofile.author.hex()] + case .nrelay(let url): return ["r", url] + case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()] } } @@ -64,14 +79,45 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { guard let t0 = i.next(), let chr = t0.single_char, let mention_type = MentionType(rawValue: chr), - let id = i.next()?.id() + let element = i.next() else { return nil } switch mention_type { - case .p: return .pubkey(Pubkey(id)) - case .e: return .note(NoteId(id)) + case .p: + guard let data = element.id() else { return nil } + return .pubkey(Pubkey(data)) + case .e: + guard let data = element.id() else { return nil } + return .note(NoteId(data)) + case .a: + let str = element.string() + let data = str.split(separator: ":") + if(data.count != 3) { return nil } + + guard let pubkey = Pubkey(hex: String(data[1])) else { return nil } + guard let kind = UInt32(data[0]) else { return nil } + + return .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)) + case .r: return .nrelay(element.string()) + } + } + + func toBech32Object() -> Bech32Object { + switch self { + case .pubkey(let pk): + return .npub(pk) + case .note(let noteid): + return .note(noteid) + case .naddr(let naddr): + return .naddr(naddr) + case .nevent(let nevent): + return .nevent(nevent) + case .nprofile(let nprofile): + return .nprofile(nprofile) + case .nrelay(let url): + return .nrelay(url) } } } @@ -251,4 +297,3 @@ func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? { .joined(separator: "") return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags) } - diff --git a/damus/Models/NoteContent.swift b/damus/Models/NoteContent.swift @@ -182,26 +182,31 @@ func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) astr.append(wrapped) } -func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText { - switch m.ref { - case .pubkey(let pk): - let npub = bech32_pubkey(pk) - let profile_txn = profiles.lookup(id: pk) - let profile = profile_txn?.unsafeUnownedValue - let disp = Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50) - var attributedString = AttributedString(stringLiteral: "@\(disp)") - attributedString.link = URL(string: "damus:nostr:\(npub)") - attributedString.foregroundColor = DamusColors.purple - - return CompatibleText(attributed: attributedString) - case .note(let note_id): - let bevid = bech32_note_id(note_id) - var attributedString = AttributedString(stringLiteral: "@\(abbrev_pubkey(bevid))") - attributedString.link = URL(string: "damus:nostr:\(bevid)") - attributedString.foregroundColor = DamusColors.purple +func getDisplayName(pk: Pubkey, profiles: Profiles) -> String { + let profile_txn = profiles.lookup(id: pk) + let profile = profile_txn?.unsafeUnownedValue + return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50) +} - return CompatibleText(attributed: attributedString) - } +func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText { + let bech32String = Bech32Object.encode(m.ref.toBech32Object()) + + let attributedStringLiteral: String = { + switch m.ref { + case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles) + case .note: return "@\(abbrev_pubkey(bech32String))" + case .nevent: return "@\(abbrev_pubkey(bech32String))" + case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles) + case .nrelay(let url): return url + case .naddr: return "@\(abbrev_pubkey(bech32String))" + } + }() + + var attributedString = AttributedString(stringLiteral: attributedStringLiteral) + attributedString.link = URL(string: "damus:nostr:\(bech32String)") + attributedString.foregroundColor = DamusColors.purple + + return CompatibleText(attributed: attributedString) } // trim suffix whitespace and newlines diff --git a/damus/Nostr/ReferencedId.swift b/damus/Nostr/ReferencedId.swift @@ -121,7 +121,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case quote(QuoteId) case hashtag(TagElem) case param(TagElem) - + case naddr(NAddr) + var key: RefKey { switch self { case .event: return .e @@ -129,11 +130,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case .quote: return .q case .hashtag: return .t case .param: return .d + case .naddr: return .a } } enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible { - case e, p, t, d, q + case e, p, t, d, q, a var keychar: AsciiCharacter { self.rawValue @@ -155,6 +157,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case .quote(let quote): return quote.hex() case .hashtag(let string): return string.string() case .param(let string): return string.string() + case .naddr(let naddr): + return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier } } @@ -174,6 +178,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case .q: return t1.id().map({ .quote(QuoteId($0)) }) case .t: return .hashtag(t1) case .d: return .param(t1) + case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0)) } } } diff --git a/damus/Types/Block.swift b/damus/Types/Block.swift @@ -150,47 +150,18 @@ fileprivate extension Block { self = .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at)) } } + fileprivate extension Block { /// Failable initializer for the C-backed type `mention_bech32_block_t`. This initializer will inspect the /// bech32 type code and build the appropriate enum type. init?(bech32 b: mention_bech32_block_t) { - switch b.bech32.type { - case NOSTR_BECH32_NOTE: - let note = b.bech32.data.note; - let note_id = NoteId(Data(bytes: note.event_id, count: 32)) - self = .mention(.any(.note(note_id))) - case NOSTR_BECH32_NEVENT: - let nevent = b.bech32.data.nevent; - let note_id = NoteId(Data(bytes: nevent.event_id, count: 32)) - self = .mention(.any(.note(note_id))) - case NOSTR_BECH32_NPUB: - let npub = b.bech32.data.npub - let pubkey = Pubkey(Data(bytes: npub.pubkey, count: 32)) - self = .mention(.any(.pubkey(pubkey))) - case NOSTR_BECH32_NSEC: - let nsec = b.bech32.data.nsec - let privkey = Privkey(Data(bytes: nsec.nsec, count: 32)) - guard let pubkey = privkey_to_pubkey(privkey: privkey) else { return nil } - self = .mention(.any(.pubkey(pubkey))) - case NOSTR_BECH32_NPROFILE: - let nprofile = b.bech32.data.nprofile - let pubkey = Pubkey(Data(bytes: nprofile.pubkey, count: 32)) - self = .mention(.any(.pubkey(pubkey))) - case NOSTR_BECH32_NRELAY: - let nrelay = b.bech32.data.nrelay - guard let relay_str = String(nrelay.relay) else { - return nil - } - self = .relay(relay_str) - case NOSTR_BECH32_NADDR: - // TODO: wtf do I do with this - guard let naddr = String(b.str) else { - return nil - } - self = .text("nostr:" + naddr) - default: + guard let decoded = decodeCBech32(b.bech32) else { + return nil + } + guard let ref = decoded.toMentionRef() else { return nil } + self = .mention(.any(ref)) } } extension Block { @@ -201,10 +172,7 @@ extension Block { return "#[\(idx)]" } - switch m.ref { - case .pubkey(let pk): return "nostr:\(pk.npub)" - case .note(let note_id): return "nostr:\(note_id.bech32)" - } + return "nostr:" + Bech32Object.encode(m.ref.toBech32Object()) case .relay(let relay): return relay case .text(let txt): diff --git a/damus/Util/Bech32Object.swift b/damus/Util/Bech32Object.swift @@ -16,7 +16,7 @@ fileprivate extension String { } } -struct NEvent : Equatable { +struct NEvent : Equatable, Hashable { let noteid: NoteId let relays: [String] let author: Pubkey? @@ -49,12 +49,12 @@ struct NEvent : Equatable { } } -struct NProfile : Equatable { +struct NProfile : Equatable, Hashable { let author: Pubkey let relays: [String] } -struct NAddr : Equatable { +struct NAddr : Equatable, Hashable { let identifier: String let author: Pubkey let relays: [String] @@ -107,6 +107,29 @@ enum Bech32Object : Equatable { return bech32_encode(hrp: "nscript", data) } } + + func toMentionRef() -> MentionRef? { + switch self { + case .nsec(let privkey): + guard let pubkey = privkey_to_pubkey(privkey: privkey) else { return nil } + return .pubkey(pubkey) + case .npub(let pubkey): + return .pubkey(pubkey) + case .note(let noteid): + return .note(noteid) + case .nscript(_): + return nil + case .nevent(let nevent): + return .nevent(nevent) + case .nprofile(let nprofile): + return .nprofile(nprofile) + case .nrelay(let relayURL): + return .nrelay(relayURL) + case .naddr(let naddr): + return .naddr(naddr) + } + } + } func decodeCBech32(_ b: nostr_bech32_t) -> Bech32Object? { diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift @@ -6,6 +6,7 @@ // import XCTest +import SwiftUI @testable import damus class NoteContentViewTests: XCTestCase { @@ -35,5 +36,101 @@ class NoteContentViewTests: XCTestCase { XCTAssertTrue((parsed.blocks[0].asURL != nil), "NoteContentView does not correctly parse an image block when url in JSON content contains optional escaped slashes.") } + + func testMentionStr_Pubkey_ContainsAbbreviated() throws { + let compatibleText = createCompatibleText(test_pubkey.npub) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "17ldvg64:nq5mhr77") + } + + func testMentionStr_Pubkey_ContainsFullBech32() { + let compatableText = createCompatibleText(test_pubkey.npub) + + assertCompatibleTextHasExpectedString(compatibleText: compatableText, expected: test_pubkey.npub) + } + + func testMentionStr_Nprofile_ContainsAbbreviated() throws { + let compatibleText = createCompatibleText("nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p") + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "180cvv07:wsyjh6w6") + } + + func testMentionStr_Nprofile_ContainsFullBech32() throws { + let bech = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: bech) + } + + func testMentionStr_Note_ContainsAbbreviated() { + let compatibleText = createCompatibleText(test_note.id.bech32) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "note1qqq:qqn2l0z3") + } + + func testMentionStr_Note_ContainsFullBech32() { + let compatableText = createCompatibleText(test_note.id.bech32) + + assertCompatibleTextHasExpectedString(compatibleText: compatableText, expected: test_note.id.bech32) + } + + func testMentionStr_Nevent_ContainsAbbreviated() { + let bech = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "nevent1q:t5nxnepm") + } + + func testMentionStr_Nevent_ContainsFullBech32() throws { + let bech = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: bech) + } + + func testMentionStr_Nrelay_ContainsAbbreviated() { + let bech = "nrelay1qqt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueq4r295t" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "wss://relay.nostr.band") + } + + func testMentionStr_Nrelay_ContainsFullBech32() { + let bech = "nrelay1qqt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueq4r295t" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: bech) + } + + func testMentionStr_Naddr_ContainsAbbreviated() { + let bech = "naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: "naddr1qq:3cnmhuld") + } + + func testMentionStr_Naddr_ContainsFullBech32() { + let bech = "naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld" + let compatibleText = createCompatibleText(bech) + + assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: bech) + } + +} +private func assertCompatibleTextHasExpectedString(compatibleText: CompatibleText, expected: String) { + guard let hasExpected = compatibleText.items.first?.attributed_string()?.description.contains(expected) else { + XCTFail() + return + } + + XCTAssertTrue(hasExpected) +} + +private func createCompatibleText(_ bechString: String) -> CompatibleText { + guard let mentionRef = Bech32Object.parse(bechString)?.toMentionRef() else { + XCTFail("Failed to create MentionRef from Bech32 string") + return CompatibleText() + } + return mention_str(.any(mentionRef), profiles: test_damus_state.profiles) } diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -228,5 +228,47 @@ class damusTests: XCTestCase { XCTAssertEqual(txt, "there is no mention here") } + + func testTagGeneration_Nevent_ContainsETag() { + let ev = createEventFromContentString("nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm") + + XCTAssertEqual(ev.tags.count, 1) + XCTAssertEqual(ev.tags[0][0].string(), "e") + XCTAssertEqual(ev.tags[0][1].string(), "b9f5441e45ca39179320e0031cfb18e34078673dcc3d3e3a3b3a981760aa5696") + } + + func testTagGeneration_Nprofile_ContainsPTag() { + let ev = createEventFromContentString("nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p") + + XCTAssertEqual(ev.tags.count, 1) + XCTAssertEqual(ev.tags[0][0].string(), "p") + XCTAssertEqual(ev.tags[0][1].string(), "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d") + } + + func testTagGeneration_Nrelay_ContainsRTag() { + let ev = createEventFromContentString("nrelay1qqt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueq4r295t") + + XCTAssertEqual(ev.tags.count, 1) + XCTAssertEqual(ev.tags[0][0].string(), "r") + XCTAssertEqual(ev.tags[0][1].string(), "wss://relay.nostr.band") + } + + func testTagGeneration_Naddr_ContainsATag(){ + let ev = createEventFromContentString("naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld") + + XCTAssertEqual(ev.tags.count, 1) + XCTAssertEqual(ev.tags[0][0].string(), "a") + XCTAssertEqual(ev.tags[0][1].string(), "30023:599f67f7df7694c603a6d0636e15ebc610db77dcfd47d6e5d05386d821fb3ea9:1700730909108") + } } + +private func createEventFromContentString(_ content: String) -> NostrEvent { + let post = NostrPost(content: content, references: []) + guard let ev = post_to_event(post: post, keypair: test_keypair_full) else { + XCTFail("Could not create event") + return test_note + } + + return ev +}