From 75c67bc1e99a73b7dd5c95c7946c4f1dc7c66682 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Wed, 28 Dec 2022 13:29:18 -0800 Subject: [PATCH 1/2] Make links clickable in profile view --- damus/Util/Markdown.swift | 43 ++++++++++++++++++++++++++++++++++ damus/Views/ProfileView.swift | 7 +++--- damusTests/MarkdownTests.swift | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 damus/Util/Markdown.swift create mode 100644 damusTests/MarkdownTests.swift diff --git a/damus/Util/Markdown.swift b/damus/Util/Markdown.swift new file mode 100644 index 00000000..9d6fa711 --- /dev/null +++ b/damus/Util/Markdown.swift @@ -0,0 +1,43 @@ +// +// Markdown.swift +// damus +// +// Created by Lionello Lunesu on 2022-12-28. +// + +import Foundation + +public struct Markdown { + private let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + /// Ensure the specified URL has a scheme by prepending "https://" if it's absent. + static func withScheme(_ url: any StringProtocol) -> any StringProtocol { + return url.contains("://") ? url : "https://" + url + } + + static func parseMarkdown(content: String) -> AttributedString { + // Similar to the parsing in NoteContentView + let md_opts: AttributedString.MarkdownParsingOptions = + .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + + if let txt = try? AttributedString(markdown: content, options: md_opts) { + return txt + } else { + return AttributedString(stringLiteral: content) + } + } + + /// Process the input text and add markdown for any embedded URLs. + public func process(_ input: String) -> AttributedString { + let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.utf16.count)) + var output = input + // Start with the last match, because replacing the first would invalidate all subsequent indices + for match in matches.reversed() { + guard let range = Range(match.range, in: input) else { continue } + let url = input[range] + output.replaceSubrange(range, with: "[\(url)](\(Markdown.withScheme(url)))") + } + // TODO: escape unintentional markdown + return Markdown.parseMarkdown(content: output) + } +} diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift index 6e804539..34b550d5 100644 --- a/damus/Views/ProfileView.swift +++ b/damus/Views/ProfileView.swift @@ -143,7 +143,9 @@ struct ProfileView: View { } } } - + + static let markdownHelper = Markdown() + var DMButton: some View { let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) let dmview = DMChatView(damus_state: damus_state, pubkey: profile.pubkey) @@ -179,7 +181,6 @@ struct ProfileView: View { DMButton - if profile.pubkey != damus_state.pubkey { FollowButtonView( target: profile.get_follow_target(), @@ -196,7 +197,7 @@ struct ProfileView: View { ProfileNameView(pubkey: profile.pubkey, profile: data, contacts: damus_state.contacts) .padding(.bottom) - Text(data?.about ?? "") + Text(ProfileView.markdownHelper.process(data?.about ?? "")) .font(.subheadline) Divider() diff --git a/damusTests/MarkdownTests.swift b/damusTests/MarkdownTests.swift new file mode 100644 index 00000000..f5fe8474 --- /dev/null +++ b/damusTests/MarkdownTests.swift @@ -0,0 +1,43 @@ +// +// MarkdownTests.swift +// damusTests +// +// Created by Lionello Lunesu on 2022-12-28. +// + +import XCTest +@testable import damus + +class MarkdownTests: XCTestCase { + let md_opts: AttributedString.MarkdownParsingOptions = + .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func test_convert_link() throws { + let helper = Markdown() + let md = helper.process("prologue https://nostr.build epilogue") + let expected = try AttributedString(markdown: "prologue [https://nostr.build](https://nostr.build) epilogue", options: md_opts) + XCTAssertEqual(md, expected) + } + + func test_convert_link_no_scheme() throws { + let helper = Markdown() + let md = helper.process("prologue damus.io epilogue") + let expected = try AttributedString(markdown: "prologue [damus.io](https://damus.io) epilogue", options: md_opts) + XCTAssertEqual(md, expected) + } + + func test_convert_links() throws { + let helper = Markdown() + let md = helper.process("prologue damus.io https://nostr.build epilogue") + let expected = try AttributedString(markdown: "prologue [damus.io](https://damus.io) [https://nostr.build](https://nostr.build) epilogue", options: md_opts) + XCTAssertEqual(md, expected) + } +} From a4eaa50ba7034a18f7dece472f48bb127b38686f Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Wed, 28 Dec 2022 23:01:40 -0800 Subject: [PATCH 2/2] Make links clickable in follow user view --- damus/Util/Markdown.swift | 4 ++-- damus/Views/FollowingView.swift | 8 +++++--- damus/Views/NoteContentView.swift | 13 +++---------- damus/Views/ProfileView.swift | 4 ++-- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/damus/Util/Markdown.swift b/damus/Util/Markdown.swift index 9d6fa711..82f3d6b3 100644 --- a/damus/Util/Markdown.swift +++ b/damus/Util/Markdown.swift @@ -15,7 +15,7 @@ public struct Markdown { return url.contains("://") ? url : "https://" + url } - static func parseMarkdown(content: String) -> AttributedString { + public static func parse(content: String) -> AttributedString { // Similar to the parsing in NoteContentView let md_opts: AttributedString.MarkdownParsingOptions = .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) @@ -38,6 +38,6 @@ public struct Markdown { output.replaceSubrange(range, with: "[\(url)](\(Markdown.withScheme(url)))") } // TODO: escape unintentional markdown - return Markdown.parseMarkdown(content: output) + return Markdown.parse(content: output) } } diff --git a/damus/Views/FollowingView.swift b/damus/Views/FollowingView.swift index 3caf22a3..f4656cc0 100644 --- a/damus/Views/FollowingView.swift +++ b/damus/Views/FollowingView.swift @@ -10,7 +10,9 @@ import SwiftUI struct FollowUserView: View { let target: FollowTarget let damus_state: DamusState - + + static let markdown = Markdown() + var body: some View { HStack(alignment: .top) { let pmodel = ProfileModel(pubkey: target.pubkey, damus: damus_state) @@ -23,8 +25,8 @@ struct FollowUserView: View { VStack(alignment: .leading) { let profile = damus_state.profiles.lookup(id: target.pubkey) ProfileName(pubkey: target.pubkey, profile: profile, contacts: damus_state.contacts, show_friend_confirmed: false) - if let about = profile.flatMap { $0.about } { - Text(about) + if let about = profile?.about { + Text(FollowUserView.markdown.process(about)) } } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index 92d80a06..ecb1c335 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -60,17 +60,10 @@ struct NoteContentView: View { let size: EventViewKind func MainContent() -> some View { - let md_opts: AttributedString.MarkdownParsingOptions = - .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - return VStack(alignment: .leading) { - if let txt = try? AttributedString(markdown: artifacts.content, options: md_opts) { - Text(txt) - .font(eventviewsize_to_font(size)) - } else { - Text(artifacts.content) - .font(eventviewsize_to_font(size)) - } + Text(Markdown.parse(content: artifacts.content)) + .font(eventviewsize_to_font(size)) + if show_images && artifacts.images.count > 0 { ImageCarousel(urls: artifacts.images) } diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift index 34b550d5..899bd9b4 100644 --- a/damus/Views/ProfileView.swift +++ b/damus/Views/ProfileView.swift @@ -144,7 +144,7 @@ struct ProfileView: View { } } - static let markdownHelper = Markdown() + static let markdown = Markdown() var DMButton: some View { let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) @@ -197,7 +197,7 @@ struct ProfileView: View { ProfileNameView(pubkey: profile.pubkey, profile: data, contacts: damus_state.contacts) .padding(.bottom) - Text(ProfileView.markdownHelper.process(data?.about ?? "")) + Text(ProfileView.markdown.process(data?.about ?? "")) .font(.subheadline) Divider()