damus

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

commit a47645929e716e1909ce35d51e885b33af245826
parent 66eefa0ff20ad67bf578f940a9d608b4b681aad3
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 16 Oct 2022 16:11:27 -0700

Inline image loading

Changelog-Added: Added inline image loading
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Adamus/Components/ImageCarousel.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 2+-
Mdamus/Models/EventRef.swift | 2++
Mdamus/Models/Mentions.swift | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mdamus/Util/Parser.swift | 9+++++++++
Mdamus/Views/ChatView.swift | 2+-
Mdamus/Views/DMView.swift | 2+-
Mdamus/Views/EventActionBar.swift | 30++++++++++++++++--------------
Mdamus/Views/EventDetailView.swift | 6+++---
Mdamus/Views/EventView.swift | 10+++++++---
Mdamus/Views/NoteContentView.swift | 48+++++++++++++++++++++++++++++++++++++-----------
Mdamus/Views/ProfilePicView.swift | 6+++---
Mdamus/Views/ReplyQuoteView.swift | 2+-
MdamusTests/ReplyTests.swift | 12++++++------
MdamusTests/damusTests.swift | 39+++++++++++++++++++++++++++++++++------
16 files changed, 267 insertions(+), 60 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; }; 4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; }; + 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670528FCB08600038D2A /* ImageCarousel.swift */; }; 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F8B280F5FCA000448DE /* ChatroomView.swift */; }; 4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F8E280F640A000448DE /* ThreadModel.swift */; }; 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F90280F6528000448DE /* ChatView.swift */; }; @@ -128,6 +129,7 @@ /* Begin PBXFileReference section */ 4C06670028FC7C5900038D2A /* RelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayView.swift; sourceTree = "<group>"; }; + 4C06670528FCB08600038D2A /* ImageCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCarousel.swift; sourceTree = "<group>"; }; 4C0A3F8B280F5FCA000448DE /* ChatroomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatroomView.swift; sourceTree = "<group>"; }; 4C0A3F8E280F640A000448DE /* ThreadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadModel.swift; sourceTree = "<group>"; }; 4C0A3F90280F6528000448DE /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; @@ -378,6 +380,7 @@ children = ( 4CE4F9E0285287B800C00DD9 /* TextFieldAlert.swift */, 4CD7641A28A1641400B6928F /* EndBlock.swift */, + 4C06670528FCB08600038D2A /* ImageCarousel.swift */, ); path = Components; sourceTree = "<group>"; @@ -654,6 +657,7 @@ 4C216F32286E388800040376 /* DMChatView.swift in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, 4C363A8828236948006E126D /* BlocksView.swift in Sources */, + 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C363A9C282838B9006E126D /* EventRef.swift in Sources */, 4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */, diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift @@ -0,0 +1,69 @@ +// +// ImageCarousel.swift +// damus +// +// Created by William Casarin on 2022-10-16. +// + +import SwiftUI +import Kingfisher + +struct ImageViewer: View { + let urls: [URL] + + var body: some View { + TabView { + ForEach(urls, id: \.absoluteString) { url in + VStack{ + Text(url.lastPathComponent) + + KFImage(url) + .loadDiskFileSynchronously() + .scaleFactor(UIScreen.main.scale) + .fade(duration: 0.1) + .tabItem { + Text(url.absoluteString) + } + .id(url.absoluteString) + } + } + } + .tabViewStyle(PageTabViewStyle()) + } +} + +struct ImageCarousel: View { + var urls: [URL] + + @State var open_sheet: Bool = false + @State var current_url: URL? = nil + + var body: some View { + TabView { + ForEach(urls, id: \.absoluteString) { url in + KFImage(url) + .loadDiskFileSynchronously() + .scaleFactor(UIScreen.main.scale) + .fade(duration: 0.1) + .tabItem { + Text(url.absoluteString) + } + .id(url.absoluteString) + } + } + .sheet(isPresented: $open_sheet) { + ImageViewer(urls: urls) + } + .frame(height: 200) + .onTapGesture { + open_sheet = true + } + .tabViewStyle(PageTabViewStyle()) + } +} + +struct ImageCarousel_Previews: PreviewProvider { + static var previews: some View { + ImageCarousel(urls: [URL(string: "https://jb55.com/red-me.jpg")!]) + } +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -7,7 +7,7 @@ import SwiftUI import Starscream -//import Kingfisher +import Kingfisher let BOOTSTRAP_RELAYS = [ "wss://relay.damus.io", diff --git a/damus/Models/EventRef.swift b/damus/Models/EventRef.swift @@ -80,6 +80,8 @@ func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> { return case .hashtag: return + case .url: + return } } } diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -37,6 +37,7 @@ enum Block { case text(String) case mention(Mention) case hashtag(String) + case url(URL) var is_hashtag: String? { if case .hashtag(let htag) = self { @@ -45,6 +46,14 @@ enum Block { return nil } + var is_url: URL? { + if case .url(let url) = self { + return url + } + + return nil + } + var is_text: String? { if case .text(let txt) = self { return txt @@ -69,6 +78,8 @@ func render_blocks(blocks: [Block]) -> String { return str + txt case .hashtag(let htag): return str + "#" + htag + case .url(let url): + return str + url.absoluteString } } } @@ -83,21 +94,43 @@ func parse_mentions(content: String, tags: [[String]]) -> [Block] { var starting_from: Int = 0 while p.pos < content.count { - if !consume_until(p, match: { $0 == "#" }) { + if !consume_until(p, match: { !$0.isWhitespace}) { break } let pre_mention = p.pos - if let mention = parse_mention(p, tags: tags) { - blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) - blocks.append(.mention(mention)) - starting_from = p.pos - } else if let hashtag = parse_hashtag(p) { - blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) - blocks.append(.hashtag(hashtag)) - starting_from = p.pos + + let c = peek_char(p, 0) + let pr = peek_char(p, -1) + + if c == "#" { + if let mention = parse_mention(p, tags: tags) { + blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) + blocks.append(.mention(mention)) + starting_from = p.pos + } else if let hashtag = parse_hashtag(p) { + blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) + blocks.append(.hashtag(hashtag)) + starting_from = p.pos + } else { + if !consume_until(p, match: { $0.isWhitespace }) { + break + } + } + } else if c == "h" && (pr == nil || pr!.isWhitespace) { + if let url = parse_url(p) { + blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) + blocks.append(.url(url)) + starting_from = p.pos + } else { + if !consume_until(p, match: { $0.isWhitespace }) { + break + } + } } else { - p.pos += 1 + if !consume_until(p, match: { $0.isWhitespace }) { + break + } } } @@ -145,6 +178,37 @@ func is_punctuation(_ c: Character) -> Bool { return c.isWhitespace || c.isPunctuation } +func parse_url(_ p: Parser) -> URL? { + let start = p.pos + + if !parse_str(p, "http") { + return nil + } + + if parse_char(p, "s") { + if !parse_str(p, "://") { + return nil + } + } else { + if !parse_str(p, "://") { + return nil + } + } + + if !consume_until(p, match: { c in c.isWhitespace }, end_ok: true) { + p.pos = start + return nil + } + + let url_str = String(substring(p.str, start: start, end: p.pos)) + guard let url = URL(string: url_str) else { + p.pos = start + return nil + } + + return url +} + func parse_hashtag(_ p: Parser) -> String? { let start = p.pos diff --git a/damus/Util/Parser.swift b/damus/Util/Parser.swift @@ -55,6 +55,15 @@ func parse_str(_ p: Parser, _ s: String) -> Bool { return false } +func peek_char(_ p: Parser, _ i: Int) -> Character? { + let offset = p.pos + i + if offset < 0 || offset > p.str.count { + return nil + } + let ind = p.str.index(p.str.startIndex, offsetBy: offset) + return p.str[ind] +} + func parse_char(_ p: Parser, _ c: Character) -> Bool { if p.pos >= p.str.count { return false diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift @@ -106,7 +106,7 @@ struct ChatView: View { } } - NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, content: event.content) + NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, show_images: true, content: event.content) if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { let bar = make_actionbar_model(ev: event, damus: damus_state) diff --git a/damus/Views/DMView.swift b/damus/Views/DMView.swift @@ -21,7 +21,7 @@ struct DMView: View { Spacer() } - NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, content: event.get_content(damus_state.keypair.privkey)) + NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, show_images: true, content: event.get_content(damus_state.keypair.privkey)) .foregroundColor(is_ours ? Color.white : Color.primary) .padding(10) .background(is_ours ? Color.accentColor : Color.secondary.opacity(0.15)) diff --git a/damus/Views/EventActionBar.swift b/damus/Views/EventActionBar.swift @@ -39,6 +39,20 @@ struct EventActionBar: View { notify(.reply, event) } } + + HStack(alignment: .bottom) { + Text("\(bar.boosts > 0 ? "\(bar.boosts)" : "")") + .font(.footnote) + .foregroundColor(bar.boosted ? Color.green : Color.gray) + + EventActionButton(img: "arrow.2.squarepath", col: bar.boosted ? Color.green : nil) { + if bar.boosted { + notify(.delete, bar.our_boost) + } else { + self.confirm_boost = true + } + } + } HStack(alignment: .bottom) { Text("\(bar.likes > 0 ? "\(bar.likes)" : "")") @@ -53,21 +67,8 @@ struct EventActionBar: View { } } } - - HStack(alignment: .bottom) { - Text("\(bar.boosts > 0 ? "\(bar.boosts)" : "")") - .font(.footnote) - .foregroundColor(bar.boosted ? Color.green : Color.gray) - - EventActionButton(img: "arrow.2.squarepath", col: bar.boosted ? Color.green : nil) { - if bar.boosted { - notify(.delete, bar.our_boost) - } else { - self.confirm_boost = true - } - } - } + /* HStack(alignment: .bottom) { Text("\(bar.tips > 0 ? "\(bar.tips)" : "")") .font(.footnote) @@ -81,6 +82,7 @@ struct EventActionBar: View { } } } + */ } .padding(.top, 1) .alert("Boost", isPresented: $confirm_boost) { diff --git a/damus/Views/EventDetailView.swift b/damus/Views/EventDetailView.swift @@ -127,13 +127,13 @@ func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) { } -/* struct EventDetailView_Previews: PreviewProvider { static var previews: some View { - EventDetailView(event: NostrEvent(content: "Hello", pubkey: "Guy"), profile: nil) + let state = test_damus_state() + let tm = ThreadModel(evid: "4da698ceac09a16cdb439276fa3d13ef8f6620ffb45d11b76b3f103483c2d0b0", damus_state: state) + EventDetailView(damus: state, thread: tm) } } - */ /// Find the entire reply path for the active event func make_reply_map(active: NostrEvent, events: [NostrEvent], privkey: String?) -> [String: ()] diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -129,9 +129,8 @@ struct EventView: View { .frame(maxWidth: .infinity, alignment: .leading) } - NoteContentView(privkey: damus.keypair.privkey, event: event, profiles: damus.profiles, content: content) + NoteContentView(privkey: damus.keypair.privkey, event: event, profiles: damus.profiles, show_images: true, content: content) .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) if has_action_bar { let bar = make_actionbar_model(ev: event, damus: damus) @@ -146,7 +145,7 @@ struct EventView: View { .contentShape(Rectangle()) .background(event_validity_color(event.validity)) .id(event.id) - .frame(minHeight: PFP_SIZE) + .frame(maxWidth: .infinity, minHeight: PFP_SIZE) .padding([.bottom], 4) .event_context_menu(event, privkey: damus.keypair.privkey) } @@ -269,3 +268,8 @@ func make_actionbar_model(ev: NostrEvent, damus: DamusState) -> ActionBarModel { } +struct EventView_Previews: PreviewProvider { + static var previews: some View { + EventView(damus: test_damus_state(), event: NostrEvent(content: "hello there https://jb55.com/s/Oct12-150217.png https://jb55.com/red-me.jb55 cool", pubkey: "pk"), show_friend_icon: true) + } +} diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -8,9 +8,10 @@ import SwiftUI -func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> String { +func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> (String, [URL]) { let blocks = ev.blocks(privkey) - return blocks.reduce("") { str, block in + var img_urls: [URL] = [] + let txt = blocks.reduce("") { str, block in switch block { case .mention(let m): return str + mention_str(m, profiles: profiles) @@ -18,8 +19,20 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) - return str + txt case .hashtag(let htag): return str + hashtag_str(htag) + case .url(let url): + if is_image_url(url) { + img_urls.append(url) + } + return str + url.absoluteString } } + + return (txt, img_urls) +} + +func is_image_url(_ url: URL) -> Bool { + let str = url.lastPathComponent + return str.hasSuffix("png") || str.hasSuffix("jpg") || str.hasSuffix("jpeg") } struct NoteContentView: View { @@ -27,23 +40,33 @@ struct NoteContentView: View { let event: NostrEvent let profiles: Profiles + let show_images: Bool + @State var content: String + @State var images: [URL] = [] 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(content) + return VStack(alignment: .leading) { + if let txt = try? AttributedString(markdown: content, options: md_opts) { + Text(txt) + } else { + Text(content) + } + if show_images && images.count > 0 { + ImageCarousel(urls: images) + } } - - return Text(txt) } var body: some View { MainContent() .onAppear() { - self.content = render_note_content(ev: event, profiles: profiles, privkey: privkey) + let (txt, images) = render_note_content(ev: event, profiles: profiles, privkey: privkey) + self.content = txt + self.images = images } .onReceive(handle_notify(.profile_updated)) { notif in let profile = notif.object as! ProfileUpdate @@ -52,10 +75,13 @@ struct NoteContentView: View { switch block { case .mention(let m): if m.type == .pubkey && m.ref.ref_id == profile.pubkey { - content = render_note_content(ev: event, profiles: profiles, privkey: privkey) + let (txt, images) = render_note_content(ev: event, profiles: profiles, privkey: privkey) + self.content = txt + self.images = images } case .text: return case .hashtag: return + case .url: return } } } @@ -80,10 +106,10 @@ func mention_str(_ m: Mention, profiles: Profiles) -> String { } -/* struct NoteContentView_Previews: PreviewProvider { static var previews: some View { - NoteContentView() + let state = test_damus_state() + let content = "hi there https://jb55.com/s/Oct12-150217.png 5739a762ef6124dd.jpg" + NoteContentView(privkey: "", event: NostrEvent(content: content, pubkey: "pk"), profiles: state.profiles, show_images: true, content: content) } } - */ diff --git a/damus/Views/ProfilePicView.swift b/damus/Views/ProfilePicView.swift @@ -56,9 +56,8 @@ struct ProfilePicView: View { Group { let pic = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey) let url = URL(string: pic) - let processor = /*DownsamplingImageProcessor(size: CGSize(width: size, height: size)) - |>*/ ResizingImageProcessor(referenceSize: CGSize(width: size, height: size)) - |> RoundCornerImageProcessor(cornerRadius: 20) + let processor = ResizingImageProcessor(referenceSize: CGSize(width: size, height: size)) + KFImage.url(url) .placeholder { _ in Placeholder @@ -67,6 +66,7 @@ struct ProfilePicView: View { .scaleFactor(UIScreen.main.scale) .loadDiskFileSynchronously() .fade(duration: 0.1) + .clipShape(Circle()) .overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight))) } } diff --git a/damus/Views/ReplyQuoteView.swift b/damus/Views/ReplyQuoteView.swift @@ -31,7 +31,7 @@ struct ReplyQuoteView: View { .foregroundColor(.gray) } - NoteContentView(privkey: privkey, event: event, profiles: profiles, content: event.content) + NoteContentView(privkey: privkey, event: event, profiles: profiles, show_images: false, content: event.content) .font(.callout) .foregroundColor(.accentColor) diff --git a/damusTests/ReplyTests.swift b/damusTests/ReplyTests.swift @@ -31,8 +31,8 @@ class ReplyTests: XCTestCase { 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") + XCTAssertEqual(ref.is_mention?.type, .event) + XCTAssertEqual(ref.is_mention?.ref.ref_id, "event_id") } func testUrlAnchorsAreNotHashtags() { @@ -96,11 +96,11 @@ class ReplyTests: XCTestCase { 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") + 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") + XCTAssertEqual(event_refs[1].is_mention?.type, .event) + XCTAssertEqual(event_refs[1].is_mention?.ref.ref_id, "mentioned_id") } func testEmptyMention() throws { diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -64,6 +64,33 @@ class damusTests: XCTestCase { XCTAssertNotNil(parsed[0].is_text) } + func testParseUrl() { + let parsed = parse_mentions(content: "a https://jb55.com b", tags: []) + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com") + } + + func testParseUrlEnd() { + let parsed = parse_mentions(content: "a https://jb55.com", tags: []) + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 2) + XCTAssertEqual(parsed[0].is_text, "a ") + XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com") + } + + func testParseUrlStart() { + let parsed = parse_mentions(content: "https://jb55.com br", tags: []) + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertEqual(parsed[0].is_text, "") + XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com") + XCTAssertEqual(parsed[2].is_text, " br") + } + func testParseMentionBlank() { let parsed = parse_mentions(content: "", tags: [["e", "event_id"]]) @@ -91,9 +118,9 @@ class damusTests: XCTestCase { XCTAssertNotNil(parsed) XCTAssertEqual(parsed.count, 3) - XCTAssertEqual(parsed[0].is_text!, "some hashtag ") - XCTAssertEqual(parsed[1].is_hashtag!, "bitcoin") - XCTAssertEqual(parsed[2].is_text!, " derp") + XCTAssertEqual(parsed[0].is_text, "some hashtag ") + XCTAssertEqual(parsed[1].is_hashtag, "bitcoin") + XCTAssertEqual(parsed[2].is_text, " derp") } func testParseHashtagEnd() { @@ -101,8 +128,8 @@ class damusTests: XCTestCase { XCTAssertNotNil(parsed) XCTAssertEqual(parsed.count, 2) - XCTAssertEqual(parsed[0].is_text!, "some hashtag ") - XCTAssertEqual(parsed[1].is_hashtag!, "bitcoin") + XCTAssertEqual(parsed[0].is_text, "some hashtag ") + XCTAssertEqual(parsed[1].is_hashtag, "bitcoin") } func testParseMentionOnlyText() { @@ -110,7 +137,7 @@ class damusTests: XCTestCase { XCTAssertNotNil(parsed) XCTAssertEqual(parsed.count, 1) - XCTAssertEqual(parsed[0].is_text!, "there is no mention here") + XCTAssertEqual(parsed[0].is_text, "there is no mention here") guard case .text(let txt) = parsed[0] else { XCTAssertTrue(false)