damus

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

commit 8896d19f403d9cca1dcfad8a3d3a24b38078ad13
parent 4db06b015c37ff0abe2b6d71b1757d739097d389
Author: William Casarin <jb55@jb55.com>
Date:   Sun,  8 May 2022 10:25:50 -0700

initial reply revamp

It now understands mentions

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
M.gitignore | 1+
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/ContentView.swift | 3++-
Mdamus/Models/ThreadModel.swift | 4++--
Mdamus/Nostr/NostrEvent.swift | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mdamus/Views/EventDetailView.swift | 51+++++++++++++++++++--------------------------------
MdamusTests/damusTests.swift | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 254 insertions(+), 85 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,3 @@ xcuserdata Preview\ Content +damus/TestingPrivate.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A912825FCF2006E126D /* ProfileUpdate.swift */; }; 4C363A94282704FA006E126D /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A93282704FA006E126D /* Post.swift */; }; 4C363A962827096D006E126D /* PostBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A952827096D006E126D /* PostBlock.swift */; }; + 4C363A9828283441006E126D /* TestingPrivate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A9728283441006E126D /* TestingPrivate.swift */; }; 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */; }; 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */; }; @@ -99,6 +100,7 @@ 4C363A912825FCF2006E126D /* ProfileUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileUpdate.swift; sourceTree = "<group>"; }; 4C363A93282704FA006E126D /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = "<group>"; }; 4C363A952827096D006E126D /* PostBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostBlock.swift; sourceTree = "<group>"; }; + 4C363A9728283441006E126D /* TestingPrivate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingPrivate.swift; sourceTree = "<group>"; }; 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModel.swift; sourceTree = "<group>"; }; 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrKind.swift; sourceTree = "<group>"; }; 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarModel.swift; sourceTree = "<group>"; }; @@ -279,6 +281,7 @@ 4CE6DEEC27F7A08200C66700 /* Preview Content */, 4CEE2AF4280B29E600AB5EEF /* TimeAgo.swift */, 4CE4F8CC281352B30009DFBB /* Notifications.swift */, + 4C363A9728283441006E126D /* TestingPrivate.swift */, ); path = damus; sourceTree = "<group>"; @@ -464,6 +467,7 @@ 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, + 4C363A9828283441006E126D /* TestingPrivate.swift in Sources */, 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -67,7 +67,8 @@ struct ContentView: View { let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() let sub_id = UUID().description - let pubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + let pubkey = MY_PUBKEY + let privkey = MY_PRIVKEY var NotificationTab: some View { ZStack(alignment: .center) { diff --git a/damus/Models/ThreadModel.swift b/damus/Models/ThreadModel.swift @@ -130,8 +130,8 @@ class ThreadModel: ObservableObject { return } - if let reply_id = ev.find_direct_reply() { - self.replies.add(id: ev.id, reply_id: reply_id) + for reply in ev.direct_replies() { + self.replies.add(id: ev.id, reply_id: reply.ref_id) } self.events.append(ev) diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -53,6 +53,10 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { return parse_mentions(content: self.content, tags: self.tags) }() + lazy var event_refs: [EventRef] = { + return interpret_event_refs(blocks: self.blocks, tags: self.tags) + }() + var description: String { let p = pow.map { String($0) } ?? "?" return "NostrEvent { id: \(id) pubkey \(pubkey) kind \(kind) tags \(tags) pow \(p) content '\(content)' }" @@ -79,28 +83,12 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { return true } - /// find a non-root reply - public func find_direct_reply() -> String? { - var i = tags.count - 1 - var first: String? = nil - var matches: Int = 0 - - while i >= 0 { - let tag = tags[i] - if tag.count >= 2 && tag[0] == "e" { - if first == nil { - first = tag[1] - } - matches += 1 + public func direct_replies() -> [ReferencedId] { + return event_refs.reduce(into: []) { acc, evref in + if let direct_reply = evref.is_direct_reply { + acc.append(direct_reply) } - i -= 1 - } - - if matches <= 1 { - return nil } - - return first } public func last_refid() -> ReferencedId? { @@ -120,29 +108,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { return tag_to_refid(tags[last]) } - public func directly_references(_ id: String) -> Bool { - // conditions: if it only has 1 e ref - // OR it has more than 1 e ref, ignoring the first - - var nrefs = 0 - var i = 0 - var first_matched = false - var matched = true - - for tag in tags { - if tag.count >= 2 && tag[0] == "e" { - nrefs += 1 - if tag[1] == id { - matched = true - first_matched = nrefs == 1 - } - } - i += 1 - } - - return (nrefs == 1 && matched) || (nrefs > 1 && matched && !first_matched) - } - public func references(id: String, key: String) -> Bool { for tag in tags { if tag.count >= 2 && tag[0] == key { @@ -156,13 +121,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { } public var is_reply: Bool { - for tag in tags { - if tag[0] == "e" { - return true - } - } - - return false + return event_is_reply(self) } public var referenced_ids: [ReferencedId] { @@ -438,3 +397,146 @@ func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] { return ids } +enum EventRef { + case mention(Mention) + case thread_id(ReferencedId) + case reply(ReferencedId) + case reply_to_root(ReferencedId) + + var is_mention: Mention? { + if case .mention(let m) = self { + return m + } + return nil + } + + var is_direct_reply: ReferencedId? { + switch self { + case .mention: + return nil + case .thread_id: + return nil + case .reply(let refid): + return refid + case .reply_to_root(let refid): + return refid + } + } + + var is_thread_id: ReferencedId? { + switch self { + case .mention(let mention): + return nil + case .thread_id(let referencedId): + return referencedId + case .reply(let referencedId): + return nil + case .reply_to_root(let referencedId): + return referencedId + } + } + + var is_reply: ReferencedId? { + switch self { + case .mention: + return nil + case .thread_id: + return nil + case .reply(let refid): + return refid + case .reply_to_root(let refid): + return refid + } + } +} + +func has_any_e_refs(_ tags: [[String]]) -> Bool { + for tag in tags { + if tag.count >= 2 && tag[0] == "e" { + return true + } + } + return false +} + +func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> { + return blocks.reduce(into: []) { acc, block in + switch block { + case .mention(let m): + if m.type == type { + acc.insert(m.index) + } + case .text: + return + } + } +} + +func interp_event_refs_without_mentions(_ refs: [ReferencedId]) -> [EventRef] { + if refs.count == 0 { + return [] + } + + if refs.count == 1 { + return [.reply_to_root(refs[0])] + } + + var evrefs: [EventRef] = [] + var first: Bool = true + for ref in refs { + if first { + evrefs.append(.thread_id(ref)) + first = false + } else { + evrefs.append(.reply(ref)) + } + } + return evrefs +} + +func interp_event_refs_with_mentions(tags: [[String]], mention_indices: Set<Int>) -> [EventRef] { + var mentions: [EventRef] = [] + var ev_refs: [ReferencedId] = [] + var i: Int = 0 + + for tag in tags { + if tag.count >= 2 && tag[0] == "e" { + let ref = tag_to_refid(tag)! + if mention_indices.contains(i) { + let mention = Mention(index: i, type: .event, ref: ref) + mentions.append(.mention(mention)) + } else { + ev_refs.append(ref) + } + } + i += 1 + } + + var replies = interp_event_refs_without_mentions(ev_refs) + replies.append(contentsOf: mentions) + return replies +} + +func interpret_event_refs(blocks: [Block], tags: [[String]]) -> [EventRef] { + if tags.count == 0 { + return [] + } + + /// build a set of indices for each event mention + let mention_indices = build_mention_indices(blocks, type: .event) + + /// simpler case with no mentions + if mention_indices.count == 0 { + let ev_refs = get_referenced_ids(tags: tags, key: "e") + return interp_event_refs_without_mentions(ev_refs) + } + + return interp_event_refs_with_mentions(tags: tags, mention_indices: mention_indices) +} + + +func event_is_reply(_ ev: NostrEvent) -> Bool { + return ev.event_refs.contains { evref in + return evref.is_reply != nil + } +} diff --git a/damus/Views/EventDetailView.swift b/damus/Views/EventDetailView.swift @@ -129,19 +129,27 @@ func make_reply_map(active: NostrEvent, events: [NostrEvent]) -> [String: ()] return is_reply } - let is_root = active.is_root_event() - for ev in events { - if is_root && ev.directly_references(active.id) { - is_reply[ev.id] = () - start = i - } else if !is_root && ev.references(id: active.id, key: "e") { - is_reply[ev.id] = () - start = i - } else if active.references(id: ev.id, key: "e") { - is_reply[ev.id] = () - start = i + /// does this event reply to the active event? + for ev_ref in ev.event_refs { + if let reply = ev_ref.is_reply { + if reply.ref_id == active.id { + is_reply[ev.id] = () + start = i + } + } + } + + /// does the active event reply to this event? + for active_ref in active.event_refs { + if let reply = active_ref.is_reply { + if reply.ref_id == ev.id { + is_reply[ev.id] = () + start = i + } + } } + i += 1 } @@ -186,27 +194,6 @@ func determine_highlight(reply_map: [String: ()], current: NostrEvent, active: N } else { return .none } - - /* - if current.id == active.id { - return .main - } - if active.is_root_event() { - if active.directly_references(current.id) { - return .reply - } else if current.directly_references(active.id) { - return .reply - } - } else { - if active.references(id: current.id, key: "e") { - return .reply - } else if current.references(id: active.id, key: "e") { - return .reply - } - } - - return .none - */ } func calculated_collapsed_events(collapsed: Bool, active: NostrEvent?, events: [NostrEvent]) -> [CollapsedEvent] { diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -33,6 +33,80 @@ class damusTests: XCTestCase { } } + func testMentionIsntReply() throws { + let content = "this is #[0] a mention" + let tags = [["e", "event_id"]] + let blocks = parse_mentions(content: content, tags: tags) + let event_refs = interpret_event_refs(blocks: blocks, tags: tags) + + XCTAssertEqual(event_refs.count, 1) + + let ref = event_refs[0] + + XCTAssertNil(ref.is_reply) + XCTAssertNil(ref.is_thread_id) + XCTAssertNil(ref.is_direct_reply) + XCTAssertEqual(ref.is_mention!.type, .event) + XCTAssertEqual(ref.is_mention!.ref.ref_id, "event_id") + } + + func testRootReplyWithMention() throws { + let content = "this is #[1] a mention" + let tags = [["e", "thread_id"], ["e", "mentioned_id"]] + let blocks = parse_mentions(content: content, tags: tags) + let event_refs = interpret_event_refs(blocks: blocks, tags: tags) + + XCTAssertEqual(event_refs.count, 2) + XCTAssertNotNil(event_refs[0].is_reply) + XCTAssertNotNil(event_refs[0].is_thread_id) + XCTAssertNotNil(event_refs[0].is_reply) + XCTAssertNotNil(event_refs[0].is_direct_reply) + XCTAssertEqual(event_refs[0].is_reply!.ref_id, "thread_id") + XCTAssertEqual(event_refs[0].is_thread_id!.ref_id, "thread_id") + XCTAssertNotNil(event_refs[1].is_mention) + XCTAssertEqual(event_refs[1].is_mention!.type, .event) + XCTAssertEqual(event_refs[1].is_mention!.ref.ref_id, "mentioned_id") + } + + func testThreadedReply() throws { + let content = "this is some content" + let tags = [["e", "thread_id"], ["e", "reply_id"]] + let blocks = parse_mentions(content: content, tags: tags) + let event_refs = interpret_event_refs(blocks: blocks, tags: tags) + + XCTAssertEqual(event_refs.count, 2) + let r1 = event_refs[0] + let r2 = event_refs[1] + + XCTAssertEqual(r1.is_thread_id!.ref_id, "thread_id") + XCTAssertEqual(r2.is_reply!.ref_id, "reply_id") + XCTAssertEqual(r2.is_direct_reply!.ref_id, "reply_id") + XCTAssertNil(r1.is_direct_reply) + } + + func testRootReply() throws { + let content = "this is a reply" + let tags = [["e", "thread_id"]] + let blocks = parse_mentions(content: content, tags: tags) + let event_refs = interpret_event_refs(blocks: blocks, tags: tags) + + XCTAssertEqual(event_refs.count, 1) + let r = event_refs[0] + + XCTAssertEqual(r.is_direct_reply!.ref_id, "thread_id") + XCTAssertEqual(r.is_reply!.ref_id, "thread_id") + XCTAssertEqual(r.is_thread_id!.ref_id, "thread_id") + XCTAssertNil(r.is_mention) + } + + func testNoReply() throws { + let content = "this is a #[0] reply" + let blocks = parse_mentions(content: content, tags: []) + let event_refs = interpret_event_refs(blocks: blocks, tags: []) + + XCTAssertEqual(event_refs.count, 0) + } + func testParseMention() throws { let parsed = parse_mentions(content: "this is #[0] a mention", tags: [["e", "event_id"]])