diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 517f0cc..2e30f8c 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 4C363AA228296A7E006E126D /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA128296A7E006E126D /* SearchView.swift */; }; 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA328296DEE006E126D /* SearchModel.swift */; }; 4C363AA828297703006E126D /* InsertSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA728297703006E126D /* InsertSort.swift */; }; + 4C3A1D332960DB0500558C0F /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3A1D322960DB0500558C0F /* Markdown.swift */; }; 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3AC79A28306D7B00E1F516 /* Contacts.swift */; }; 4C3AC79D2833036D00E1F516 /* FollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3AC79C2833036D00E1F516 /* FollowingView.swift */; }; 4C3AC79F2833115300E1F516 /* FollowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3AC79E2833115300E1F516 /* FollowButtonView.swift */; }; @@ -203,6 +204,7 @@ 4C363AA128296A7E006E126D /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 4C363AA328296DEE006E126D /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = ""; }; 4C363AA728297703006E126D /* InsertSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertSort.swift; sourceTree = ""; }; + 4C3A1D322960DB0500558C0F /* Markdown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = ""; }; 4C3AC79A28306D7B00E1F516 /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.swift; sourceTree = ""; }; 4C3AC79C2833036D00E1F516 /* FollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingView.swift; sourceTree = ""; }; 4C3AC79E2833115300E1F516 /* FollowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowButtonView.swift; sourceTree = ""; }; @@ -527,6 +529,7 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + 4C3A1D322960DB0500558C0F /* Markdown.swift */, 4CEE2AF4280B29E600AB5EEF /* TimeAgo.swift */, 4CE4F8CC281352B30009DFBB /* Notifications.swift */, 4C363A8328233689006E126D /* Parser.swift */, @@ -884,6 +887,7 @@ 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */, E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */, 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */, + 4C3A1D332960DB0500558C0F /* Markdown.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, 4C06670B28FDE64700038D2A /* damus.c in Sources */, 4FE60CDD295E1C5E00105A1F /* Wallet.swift in Sources */, diff --git a/damus/Util/Markdown.swift b/damus/Util/Markdown.swift new file mode 100644 index 0000000..82f3d6b --- /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 + } + + public static func parse(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.parse(content: output) + } +} diff --git a/damus/Views/FollowingView.swift b/damus/Views/FollowingView.swift index 52e4f10..7e66f9c 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 { 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)) .lineLimit(3) .font(.footnote) } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift index faa625f..6705c56 100644 --- a/damus/Views/NoteContentView.swift +++ b/damus/Views/NoteContentView.swift @@ -70,17 +70,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) } else if !show_images && artifacts.images.count > 0 { diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift index 81f1b7b..66fcd05 100644 --- a/damus/Views/ProfileView.swift +++ b/damus/Views/ProfileView.swift @@ -152,7 +152,9 @@ struct ProfileView: View { .environmentObject(user_settings) } } - + + static let markdown = 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) @@ -188,7 +190,6 @@ struct ProfileView: View { DMButton - if profile.pubkey != damus_state.pubkey { FollowButtonView( target: profile.get_follow_target(), @@ -205,7 +206,7 @@ struct ProfileView: View { ProfileNameView(pubkey: profile.pubkey, profile: data, contacts: damus_state.contacts) .padding(.bottom) - Text(data?.about ?? "") + Text(ProfileView.markdown.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) + } +}