damus

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

commit 0eb1372937d217479ccb50fe2d28c0e8d3d964ae
parent 73652513d91c0c780dc6bfb3d74f336dfa90711a
Author: William Casarin <jb55@jb55.com>
Date:   Sat,  7 May 2022 13:50:19 -0700

more mention progress

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

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 16++++++++++++++++
Mdamus/ContentView.swift | 39++++++++++++++++++++++++++++++++++++++-
Mdamus/Info.plist | 13+++++++++++++
Mdamus/Models/Mentions.swift | 63++++++++++++++++++++++++++++++++-------------------------------
Adamus/Models/Post.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/PostBlock.swift | 31+++++++++++++++++++++++++++++++
Adamus/Models/ProfileUpdate.swift | 14++++++++++++++
Mdamus/Nostr/Nostr.swift | 2+-
Mdamus/Nostr/NostrEvent.swift | 4++++
Adamus/Nostr/NostrLink.swift | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/Profiles.swift | 56--------------------------------------------------------
Mdamus/Notifications.swift | 10++++++++--
Mdamus/Util/ImageCache.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/Parser.swift | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Mdamus/Views/ChatView.swift | 2+-
Mdamus/Views/EventView.swift | 2+-
Mdamus/Views/NoteContentView.swift | 73++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mdamus/Views/PostView.swift | 20--------------------
Mdamus/Views/ProfileView.swift | 4++++
Mdamus/Views/PubkeyView.swift | 4++--
MdamusTests/damusTests.swift | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 608 insertions(+), 141 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -19,6 +19,10 @@ 4C363A8A28236B57006E126D /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8928236B57006E126D /* MentionView.swift */; }; 4C363A8C28236B92006E126D /* PubkeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8B28236B92006E126D /* PubkeyView.swift */; }; 4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8D28236FE4006E126D /* NoteContentView.swift */; }; + 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8F28247A1D006E126D /* NostrLink.swift */; }; + 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 */; }; 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 */; }; @@ -91,6 +95,10 @@ 4C363A8928236B57006E126D /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; 4C363A8B28236B92006E126D /* PubkeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PubkeyView.swift; sourceTree = "<group>"; }; 4C363A8D28236FE4006E126D /* NoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentView.swift; sourceTree = "<group>"; }; + 4C363A8F28247A1D006E126D /* NostrLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrLink.swift; sourceTree = "<group>"; }; + 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>"; }; 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>"; }; @@ -175,6 +183,9 @@ 4C3BEFDD281DD59C00B3DE84 /* ParsedRefs.swift */, 4C3BEFDF281DE1ED00B3DE84 /* DamusState.swift */, 4C7FF7D42823313F009601DB /* Mentions.swift */, + 4C363A912825FCF2006E126D /* ProfileUpdate.swift */, + 4C363A93282704FA006E126D /* Post.swift */, + 4C363A952827096D006E126D /* PostBlock.swift */, ); path = Models; sourceTree = "<group>"; @@ -219,6 +230,7 @@ 4CEE2AEC2805B22500AB5EEF /* NostrRequest.swift */, 4CACA9DB280C38C000D9BBE8 /* Profiles.swift */, 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */, + 4C363A8F28247A1D006E126D /* NostrLink.swift */, ); path = Nostr; sourceTree = "<group>"; @@ -452,6 +464,7 @@ 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, + 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, @@ -468,6 +481,7 @@ 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */, 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */, 4CACA9DC280C38C000D9BBE8 /* Profiles.swift in Sources */, + 4C363A94282704FA006E126D /* Post.swift in Sources */, 4C363A8828236948006E126D /* BlocksView.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C8682872814DE470026224F /* ProfileView.swift in Sources */, @@ -477,7 +491,9 @@ 4CEE2AF9280B2EAC00AB5EEF /* PowView.swift in Sources */, 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */, 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, + 4C363A962827096D006E126D /* PostBlock.swift in Sources */, 4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */, + 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */, 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, 4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -58,6 +58,8 @@ struct ContentView: View { @State var events: [NostrEvent] = [] @State var friend_events: [NostrEvent] = [] @State var notifications: [NostrEvent] = [] + @State var active_profile: String? = nil + @State var profile_open: Bool = false // connect retry timer let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() @@ -146,6 +148,9 @@ struct ContentView: View { func MainContent(damus: DamusState) -> some View { NavigationView { VStack { + NavigationLink(destination: MaybeProfileView, isActive: $profile_open) { + EmptyView() + } switch selected_timeline { case .home: PostingTimelineView @@ -168,10 +173,23 @@ struct ContentView: View { } } .navigationBarTitle("Damus", displayMode: .inline) + } .navigationViewStyle(.stack) } + var MaybeProfileView: some View { + Group { + if let pk = self.active_profile { + let profile_model = ProfileModel(pubkey: pk, damus: damus!) + ProfileView(damus: damus!, profile: profile_model) + .environmentObject(profiles) + } else { + EmptyView() + } + } + } + var body: some View { VStack { if let damus = self.damus { @@ -197,6 +215,25 @@ struct ContentView: View { .environmentObject(profiles) } } + .onOpenURL { url in + guard let link = decode_nostr_uri(url.absoluteString) else { + return + } + + switch link { + case .ref(let ref): + if ref.key == "p" { + active_profile = ref.ref_id + profile_open = true + } else if ref.key == "e" { + // TODO open event view + } + case .filter: + break + // TODO: handle filter searches? + } + + } .onReceive(handle_notify(.boost)) { notif in let ev = notif.object as! NostrEvent let boost = make_boost_event(ev, privkey: privkey, pubkey: pubkey) @@ -229,7 +266,7 @@ struct ContentView: View { switch post_res { case .post(let post): print("post \(post.content)") - let new_ev = post.to_event(privkey: privkey, pubkey: pubkey) + let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey) self.damus?.pool.send(.event(new_ev)) case .cancel: active_sheet = nil diff --git a/damus/Info.plist b/damus/Info.plist @@ -2,6 +2,19 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>CFBundleURLTypes</key> + <array> + <dict> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>CFBundleURLName</key> + <string>io.damus.nostr</string> + <key>CFBundleURLSchemes</key> + <array> + <string>nostr</string> + </array> + </dict> + </array> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -7,9 +7,19 @@ import Foundation + enum MentionType { case pubkey case event + + var ref: String { + switch self { + case .pubkey: + return "p" + case .event: + return "e" + } + } } struct Mention { @@ -42,38 +52,15 @@ enum Block { } } -struct ParsedMentions { - let blocks: [Block] -} - -class Parser { - var pos: Int - var str: String - - init(pos: Int, str: String) { - self.pos = pos - self.str = str - } -} - -func consume_until(_ p: Parser, match: Character) -> Bool { - var i: Int = 0 - let sub = substring(p.str, start: p.pos, end: p.str.count) - for c in sub { - if c == match { - p.pos += i - return true +func render_blocks(blocks: [Block]) -> String { + return blocks.reduce("") { str, block in + switch block { + case .mention(let m): + return str + "#[\(m.index)]" + case .text(let txt): + return str + txt } - i += 1 } - - return false -} - -func substring(_ s: String, start: Int, end: Int) -> Substring { - let ind = s.index(s.startIndex, offsetBy: start) - let end = s.index(s.startIndex, offsetBy: end) - return s[ind..<end] } func parse_textblock(str: String, from: Int, to: Int) -> Block { @@ -86,7 +73,7 @@ func parse_mentions(content: String, tags: [[String]]) -> [Block] { var starting_from: Int = 0 while p.pos < content.count { - if (!consume_until(p, match: "#")) { + if (!consume_until(p, match: { $0 == "#" })) { blocks.append(parse_textblock(str: p.str, from: starting_from, to: p.str.count)) return blocks } @@ -142,3 +129,17 @@ func parse_mention(_ p: Parser, tags: [[String]]) -> Mention? { return Mention(index: digit, type: kind, ref: ref) } +func post_to_event(post: NostrPost, privkey: String, pubkey: String) -> NostrEvent { + let new_ev = NostrEvent(content: post.content, pubkey: pubkey) + for id in post.references { + var tag = [id.key, id.ref_id] + if let relay_id = id.relay_id { + tag.append(relay_id) + } + new_ev.tags.append(tag) + } + new_ev.calculate_id() + new_ev.sign(privkey: privkey) + return new_ev +} + diff --git a/damus/Models/Post.swift b/damus/Models/Post.swift @@ -0,0 +1,69 @@ +// +// Post.swift +// damus +// +// Created by William Casarin on 2022-05-07. +// + +import Foundation + +struct NostrPost { + let content: String + let references: [ReferencedId] +} + +// TODO: parse nostr:{e,p}:pubkey uris as well +func parse_post_mention_type(_ p: Parser) -> MentionType? { + if parse_char(p, "@") { + return .pubkey + } + + if parse_char(p, "&") { + return .event + } + + return nil +} + +func parse_post_reference(_ p: Parser) -> ReferencedId? { + let start = p.pos + + guard let typ = parse_post_mention_type(p) else { + return parse_nostr_ref_uri(p) + } + + guard let id = parse_hexstr(p, len: 64) else { + p.pos = start + return nil + } + + return ReferencedId(ref_id: id, relay_id: nil, key: typ.ref) +} + + +/// Return a list of tags +func parse_post_blocks(content: String) -> [PostBlock] { + let p = Parser(pos: 0, str: content) + var blocks: [PostBlock] = [] + var starting_from: Int = 0 + + if content.count == 0 { + return [] + } + + while p.pos < content.count { + let pre_mention = p.pos + if let reference = parse_post_reference(p) { + blocks.append(parse_post_textblock(str: p.str, from: starting_from, to: pre_mention)) + blocks.append(.ref(reference)) + starting_from = p.pos + } else { + p.pos += 1 + } + } + + blocks.append(parse_post_textblock(str: content, from: starting_from, to: content.count)) + + return blocks +} + diff --git a/damus/Models/PostBlock.swift b/damus/Models/PostBlock.swift @@ -0,0 +1,31 @@ +// +// PostBlock.swift +// damus +// +// Created by William Casarin on 2022-05-07. +// + +import Foundation + +enum PostBlock { + case text(String) + case ref(ReferencedId) + + var is_text: Bool { + if case .text = self { + return true + } + return false + } + + var is_ref: Bool { + if case .ref = self { + return true + } + return false + } +} + +func parse_post_textblock(str: String, from: Int, to: Int) -> PostBlock { + return .text(String(substring(str, start: from, end: to))) +} diff --git a/damus/Models/ProfileUpdate.swift b/damus/Models/ProfileUpdate.swift @@ -0,0 +1,14 @@ +// +// ProfileUpdate.swift +// damus +// +// Created by William Casarin on 2022-05-06. +// + +import Foundation + + +struct ProfileUpdate { + let pubkey: String + let profile: Profile +} diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift @@ -14,7 +14,7 @@ struct Profile: Decodable { let picture: String? static func displayName(profile: Profile?, pubkey: String) -> String { - return profile?.name ?? String(pubkey.prefix(16)) + return profile?.name ?? abbrev_pubkey(pubkey) } } diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -49,6 +49,10 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { let kind: Int let content: String + lazy var blocks: [Block] = { + return parse_mentions(content: self.content, 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)' }" diff --git a/damus/Nostr/NostrLink.swift b/damus/Nostr/NostrLink.swift @@ -0,0 +1,96 @@ +// +// NostrLink.swift +// damus +// +// Created by William Casarin on 2022-05-05. +// + +import Foundation + + +enum NostrLink { + case ref(ReferencedId) + case filter(NostrFilter) +} + +func encode_pubkey_uri(_ ref: ReferencedId) -> String { + return "p:" + ref.ref_id +} + +// TODO: bech32 and relay hints +func encode_event_id_uri(_ ref: ReferencedId) -> String { + return "e:" + ref.ref_id +} + +func parse_nostr_ref_uri_type(_ p: Parser) -> String? { + if parse_char(p, "p") { + return "p" + } + + if parse_char(p, "e") { + return "e" + } + + return nil +} + +func parse_hexstr(_ p: Parser, len: Int) -> String? { + var i: Int = 0 + + if len % 2 != 0 { + return nil + } + + let start = p.pos + + while i < len { + guard parse_hex_char(p) != nil else { + p.pos = start + return nil + } + i += 1 + } + + return String(substring(p.str, start: start, end: p.pos)) +} + +func parse_nostr_ref_uri(_ p: Parser) -> ReferencedId? { + let start = p.pos + + if !parse_str(p, "nostr:") { + return nil + } + + guard let typ = parse_nostr_ref_uri_type(p) else { + p.pos = start + return nil + } + + if !parse_char(p, ":") { + p.pos = start + return nil + } + + guard let pk = parse_hexstr(p, len: 64) else { + p.pos = start + return nil + } + + // TODO: parse relays from nostr uris + return ReferencedId(ref_id: pk, relay_id: nil, key: typ) +} + +func decode_nostr_uri(_ s: String) -> NostrLink? { + let uri = s.replacingOccurrences(of: "nostr:", with: "") + + let parts = uri.split(separator: ":") + .reduce(into: Array<String>()) { acc, str in + guard let decoded = str.removingPercentEncoding else { + return + } + acc.append(decoded) + return + } + + return tag_to_refid(parts).map { .ref($0) } +} diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift @@ -9,62 +9,6 @@ import Foundation import UIKit import Combine -class ImageCache { - private let lock = NSLock() - - lazy var cache: NSCache<AnyObject, AnyObject> = { - let cache = NSCache<AnyObject, AnyObject>() - cache.totalCostLimit = 1024 * 1024 * 100 // 100MB - return cache - }() - - func lookup(for url: URL) -> UIImage? { - lock.lock(); defer { lock.unlock() } - - if let decoded = cache.object(forKey: url as AnyObject) as? UIImage { - return decoded - } - - return nil - } - - func remove(for url: URL) { - lock.lock(); defer { lock.unlock() } - cache.removeObject(forKey: url as AnyObject) - } - - func insert(_ image: UIImage?, for url: URL) { - guard let image = image else { return remove(for: url) } - let decodedImage = image.decodedImage(Int(PFP_SIZE!)) - lock.lock(); defer { lock.unlock() } - cache.setObject(decodedImage, forKey: url as AnyObject) - } - - subscript(_ key: URL) -> UIImage? { - get { - return lookup(for: key) - } - set { - return insert(newValue, for: key) - } - } -} - -func load_image(cache: ImageCache, from url: URL) -> AnyPublisher<UIImage?, Never> { - if let image = cache[url] { - return Just(image).eraseToAnyPublisher() - } - return URLSession.shared.dataTaskPublisher(for: url) - .map { (data, response) -> UIImage? in return UIImage(data: data) } - .catch { error in return Just(nil) } - .handleEvents(receiveOutput: { image in - guard let image = image else { return } - cache[url] = image - }) - .subscribe(on: DispatchQueue.global(qos: .background)) - .receive(on: RunLoop.main) - .eraseToAnyPublisher() -} class Profiles: ObservableObject { @Published var profiles: [String: TimestampedProfile] = [:] diff --git a/damus/Notifications.swift b/damus/Notifications.swift @@ -32,6 +32,12 @@ extension Notification.Name { } extension Notification.Name { + static var profile_update: Notification.Name { + return Notification.Name("profile_update") + } +} + +extension Notification.Name { static var switched_timeline: Notification.Name { return Notification.Name("switched_timeline") } @@ -44,8 +50,8 @@ extension Notification.Name { } extension Notification.Name { - static var click_profile_pic: Notification.Name { - return Notification.Name("click_profile_pic") + static var open_profile: Notification.Name { + return Notification.Name("open_profile") } } diff --git a/damus/Util/ImageCache.swift b/damus/Util/ImageCache.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import Combine extension UIImage { func decodedImage(_ size: Int) -> UIImage { @@ -26,3 +27,60 @@ extension UIImage { return UIImage(cgImage: decodedImage, scale: scale, orientation: .up) } } + +class ImageCache { + private let lock = NSLock() + + lazy var cache: NSCache<AnyObject, AnyObject> = { + let cache = NSCache<AnyObject, AnyObject>() + cache.totalCostLimit = 1024 * 1024 * 100 // 100MB + return cache + }() + + func lookup(for url: URL) -> UIImage? { + lock.lock(); defer { lock.unlock() } + + if let decoded = cache.object(forKey: url as AnyObject) as? UIImage { + return decoded + } + + return nil + } + + func remove(for url: URL) { + lock.lock(); defer { lock.unlock() } + cache.removeObject(forKey: url as AnyObject) + } + + func insert(_ image: UIImage?, for url: URL) { + guard let image = image else { return remove(for: url) } + let decodedImage = image.decodedImage(Int(PFP_SIZE!)) + lock.lock(); defer { lock.unlock() } + cache.setObject(decodedImage, forKey: url as AnyObject) + } + + subscript(_ key: URL) -> UIImage? { + get { + return lookup(for: key) + } + set { + return insert(newValue, for: key) + } + } +} + +func load_image(cache: ImageCache, from url: URL) -> AnyPublisher<UIImage?, Never> { + if let image = cache[url] { + return Just(image).eraseToAnyPublisher() + } + return URLSession.shared.dataTaskPublisher(for: url) + .map { (data, response) -> UIImage? in return UIImage(data: data) } + .catch { error in return Just(nil) } + .handleEvents(receiveOutput: { image in + guard let image = image else { return } + cache[url] = image + }) + .subscribe(on: DispatchQueue.global(qos: .background)) + .receive(on: RunLoop.main) + .eraseToAnyPublisher() +} diff --git a/damus/Util/Parser.swift b/damus/Util/Parser.swift @@ -7,7 +7,41 @@ import Foundation +class Parser { + var pos: Int + var str: String + + init(pos: Int, str: String) { + self.pos = pos + self.str = str + } +} + +func consume_until(_ p: Parser, match: (Character) -> Bool) -> Bool { + var i: Int = 0 + let sub = substring(p.str, start: p.pos, end: p.str.count) + for c in sub { + if match(c) { + p.pos += i + return true + } + i += 1 + } + + return false +} + +func substring(_ s: String, start: Int, end: Int) -> Substring { + let ind = s.index(s.startIndex, offsetBy: start) + let end = s.index(s.startIndex, offsetBy: end) + return s[ind..<end] +} + + func parse_str(_ p: Parser, _ s: String) -> Bool { + if p.pos + s.count > p.str.count { + return false + } let sub = substring(p.str, start: p.pos, end: p.pos + s.count) if sub == s { p.pos += s.count @@ -16,7 +50,7 @@ func parse_str(_ p: Parser, _ s: String) -> Bool { return false } -func parse_char(_ p: Parser, _ c: Character) -> Bool{ +func parse_char(_ p: Parser, _ c: Character) -> Bool { let ind = p.str.index(p.str.startIndex, offsetBy: p.pos) if p.str[ind] == c { @@ -40,3 +74,19 @@ func parse_digit(_ p: Parser) -> Int? { return 0 } + + +func parse_hex_char(_ p: Parser) -> Character? { + let ind = p.str.index(p.str.startIndex, offsetBy: p.pos) + + if let c = p.str[ind].unicodeScalars.first { + // hex chars + let d = c.value + if (d >= 48 && d <= 57) || (d >= 97 && d <= 102) || (d >= 65 && d <= 70) { + p.pos += 1 + return p.str[ind] + } + } + + return nil +} diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift @@ -128,7 +128,7 @@ struct ChatView: View { } } - NoteContentView(event) + NoteContentView(event: event, profiles: profiles) if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { EventActionBar(event: event, diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -74,7 +74,7 @@ struct EventView: View { .frame(maxWidth: .infinity, alignment: .leading) } - NoteContentView(event) + NoteContentView(event: event, profiles: profiles) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -7,46 +7,69 @@ import SwiftUI -func NoteContentView(_ ev: NostrEvent) -> some View { - let txt = parse_mentions(content: ev.content, tags: ev.tags) - .reduce("") { str, block in - switch block { - case .mention(let m): - return str + mention_str(m) - case .text(let txt): - return str + txt - } + +func render_note_content(ev: NostrEvent, profiles: Profiles) -> String { + return ev.blocks.reduce("") { str, block in + switch block { + case .mention(let m): + return str + mention_str(m, profiles: profiles) + case .text(let txt): + return str + txt } + } +} + +struct NoteContentView: View { + let event: NostrEvent + let profiles: Profiles - let md_opts: AttributedString.MarkdownParsingOptions = - .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + @State var content: String = "" - guard let txt = try? AttributedString(markdown: txt, options: md_opts) else { - return Text(ev.content) + func MainContent() -> some View { + let md_opts: AttributedString.MarkdownParsingOptions = + .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + + guard let txt = try? AttributedString(markdown: content, options: md_opts) else { + return Text(event.content) + } + + return Text(txt) } - return Text(txt) + var body: some View { + MainContent() + .onAppear() { + self.content = render_note_content(ev: event, profiles: profiles) + } + .onReceive(handle_notify(.profile_update)) { notif in + let profile = notif.object as! ProfileUpdate + for block in event.blocks { + switch block { + case .mention(let m): + if m.type == .pubkey && m.ref.ref_id == profile.pubkey { + content = render_note_content(ev: event, profiles: profiles) + } + case .text: + return + } + } + } + } } -func mention_str(_ m: Mention) -> String { +func mention_str(_ m: Mention, profiles: Profiles) -> String { switch m.type { case .pubkey: let pk = m.ref.ref_id - return "[@\(abbrev_pubkey(pk))](nostr:\(encode_pubkey(m.ref)))" + let profile = profiles.lookup(id: pk) + let disp = Profile.displayName(profile: profile, pubkey: pk) + return "[@\(disp)](nostr:\(encode_pubkey_uri(m.ref)))" case .event: let evid = m.ref.ref_id - return "[*\(abbrev_pubkey(evid))](nostr:\(encode_event_id(m.ref)))" + return "[&\(abbrev_pubkey(evid))](nostr:\(encode_event_id_uri(m.ref)))" } } -// TODO: bech32 and relay hints -func encode_event_id(_ ref: ReferencedId) -> String { - return "e_" + ref.ref_id -} - -func encode_pubkey(_ ref: ReferencedId) -> String { - return "p_" + ref.ref_id -} /* struct NoteContentView_Previews: PreviewProvider { diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -12,26 +12,6 @@ enum NostrPostResult { case cancel } -struct NostrPost { - let content: String - let references: [ReferencedId] - - public func to_event(privkey: String, pubkey: String) -> NostrEvent { - let new_ev = NostrEvent(content: content, pubkey: pubkey) - for id in references { - var tag = [id.key, id.ref_id] - if let relay_id = id.relay_id { - tag.append(relay_id) - } - new_ev.tags.append(tag) - } - new_ev.calculate_id() - new_ev.sign(privkey: privkey) - return new_ev - } -} - - struct PostView: View { @State var post: String = "" @FocusState var focus: Bool diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -31,6 +31,10 @@ struct ProfileView: View { ProfileName(pubkey: pubkey, profile: data) .font(.title) //.border(Color.green) + Text("\(pubkey)") + .textSelection(.enabled) + .font(.footnote) + .foregroundColor(id_to_color(pubkey)) } Text(data?.about ?? "") //.border(Color.red) diff --git a/damus/Views/PubkeyView.swift b/damus/Views/PubkeyView.swift @@ -20,8 +20,8 @@ struct PubkeyView: View { } } -func abbrev_pubkey(_ pubkey: String) -> String { - return pubkey.prefix(4) + ":" + pubkey.suffix(4) +func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String { + return pubkey.prefix(amount) + ":" + pubkey.suffix(amount) } /* diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -43,6 +43,127 @@ class damusTests: XCTestCase { XCTAssertTrue(parsed[2].is_text) } + func testEmptyPostReference() throws { + let parsed = parse_post_blocks(content: "") + XCTAssertEqual(parsed.count, 0) + } + + func testInvalidPostReference() throws { + let pk = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e24" + let content = "this is a @\(pk) mention" + let parsed = parse_post_blocks(content: content) + XCTAssertEqual(parsed.count, 1) + guard case .text(let txt) = parsed[0] else { + XCTAssert(false) + return + } + XCTAssertEqual(txt, content) + } + + func testInvalidPostReferenceEmptyAt() throws { + let content = "this is a @ mention" + let parsed = parse_post_blocks(content: content) + XCTAssertEqual(parsed.count, 1) + guard case .text(let txt) = parsed[0] else { + XCTAssert(false) + return + } + XCTAssertEqual(txt, content) + } + + func testParsePostUriReference() throws { + let id = "6fec2ee6cfff779fe8560976b3d9df782b74577f0caefa7a77c0ed4c3749b5de" + let parsed = parse_post_blocks(content: "this is a nostr:e:\(id) event mention") + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertTrue(parsed[0].is_text) + XCTAssertTrue(parsed[1].is_ref) + XCTAssertTrue(parsed[2].is_text) + + guard case .ref(let ref) = parsed[1] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(ref.ref_id, id) + XCTAssertEqual(ref.key, "e") + XCTAssertNil(ref.relay_id) + + guard case .text(let t1) = parsed[0] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t1, "this is a ") + + guard case .text(let t2) = parsed[2] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t2, " event mention") + } + + func testParsePostEventReference() throws { + let pk = "6fec2ee6cfff779fe8560976b3d9df782b74577f0caefa7a77c0ed4c3749b5de" + let parsed = parse_post_blocks(content: "this is a &\(pk) event mention") + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertTrue(parsed[0].is_text) + XCTAssertTrue(parsed[1].is_ref) + XCTAssertTrue(parsed[2].is_text) + + guard case .ref(let ref) = parsed[1] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(ref.ref_id, pk) + XCTAssertEqual(ref.key, "e") + XCTAssertNil(ref.relay_id) + + guard case .text(let t1) = parsed[0] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t1, "this is a ") + + guard case .text(let t2) = parsed[2] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t2, " event mention") + } + + func testParsePostPubkeyReference() throws { + let pk = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + let parsed = parse_post_blocks(content: "this is a @\(pk) mention") + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertTrue(parsed[0].is_text) + XCTAssertTrue(parsed[1].is_ref) + XCTAssertTrue(parsed[2].is_text) + + guard case .ref(let ref) = parsed[1] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(ref.ref_id, pk) + XCTAssertEqual(ref.key, "p") + XCTAssertNil(ref.relay_id) + + guard case .text(let t1) = parsed[0] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t1, "this is a ") + + guard case .text(let t2) = parsed[2] else { + XCTAssertTrue(false) + return + } + XCTAssertEqual(t2, " mention") + } + func testParseInvalidMention() throws { let parsed = parse_mentions(content: "this is #[0] a mention", tags: [])