diff --git a/damus/Util/Markdown.swift b/damus/Util/Markdown.swift new file mode 100644 index 0000000..9d6fa71 --- /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 6e80453..34b550d 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 0000000..f5fe847 --- /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) + } +}