diff --git a/CHANGELOG.md b/CHANGELOG.md index 92362b1b5..00672ba51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Included the npub in the properties list sent to analytics. - Removed the like and repost counts from the Main and Profile feeds. - Replaced hard-coded color values. +- Show quoted notes in note cards. ## [0.1.25] - 2024-08-21Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index ebca76c3e..9dd7ff5fc 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 509533002C62535400E0BACA /* zap_request.json in Resources */ = {isa = PBXBuildFile; fileRef = 509532FF2C62535400E0BACA /* zap_request.json */; }; 5095330B2C625B5D00E0BACA /* zap_request_one_sat.json in Resources */ = {isa = PBXBuildFile; fileRef = 509533092C625B5D00E0BACA /* zap_request_one_sat.json */; }; 5095330C2C625B5D00E0BACA /* zap_request_no_amount.json in Resources */ = {isa = PBXBuildFile; fileRef = 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */; }; + 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */; }; 50F695072C6392C4000E4C74 /* zap_receipt.json in Resources */ = {isa = PBXBuildFile; fileRef = 50F695062C6392C4000E4C74 /* zap_receipt.json */; }; 5B098DBC2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */; }; 5B098DC62BDAF73500500A1B /* AttributedString+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */; }; @@ -608,6 +609,7 @@ 509532FF2C62535400E0BACA /* zap_request.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_request.json; sourceTree = ""; }; 509533092C625B5D00E0BACA /* zap_request_one_sat.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_one_sat.json; sourceTree = ""; }; 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_no_amount.json; sourceTree = ""; }; + 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+StyledBorder.swift"; sourceTree = ""; }; 50F695062C6392C4000E4C74 /* zap_receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_receipt.json; sourceTree = ""; }; 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+NIP08.swift"; sourceTree = ""; }; 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Links.swift"; sourceTree = ""; }; @@ -1357,6 +1359,7 @@ C95D68A8299E709800429F86 /* LinearGradient+Planetary.swift */, C9A0DAE629C69FA000466635 /* Text+Gradient.swift */, C93EC2FC29C3785C0012EE2A /* View+RoundedCorner.swift */, + 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */, C9DC6CB92C1739AD00E1CFB3 /* View+HandleURLsInRouter.swift */, ); path = Modifiers; @@ -2190,6 +2193,7 @@ 3FFB1D9329A6BBCE002A755D /* EventReference+CoreDataClass.swift in Sources */, C973AB5B2A323167002AED16 /* Follow+CoreDataProperties.swift in Sources */, C92F01552AC4D6CF00972489 /* BeveledSeparator.swift in Sources */, + 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */, C93EC2FD29C3785C0012EE2A /* View+RoundedCorner.swift in Sources */, 5B503F622A291A1A0098805A /* JSONRelayMetadata.swift in Sources */, C98298332ADD7F9A0096C5B5 /* DeepLinkService.swift in Sources */, diff --git a/Nos/Assets/Colors.xcassets/styled-border-gradient-bottom.colorset/Contents.json b/Nos/Assets/Colors.xcassets/styled-border-gradient-bottom.colorset/Contents.json new file mode 100644 index 000000000..56885d422 --- /dev/null +++ b/Nos/Assets/Colors.xcassets/styled-border-gradient-bottom.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x75", + "green" : "0x3F", + "red" : "0xF4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x9C", + "green" : "0x51", + "red" : "0x6A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Assets/Colors.xcassets/styled-border-gradient-top.colorset/Contents.json b/Nos/Assets/Colors.xcassets/styled-border-gradient-top.colorset/Contents.json new file mode 100644 index 000000000..385897eb6 --- /dev/null +++ b/Nos/Assets/Colors.xcassets/styled-border-gradient-top.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x08", + "green" : "0x85", + "red" : "0xF0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA3", + "green" : "0x75", + "red" : "0x85" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Assets/Colors.xcassets/styled-border-shadow.colorset/Contents.json b/Nos/Assets/Colors.xcassets/styled-border-shadow.colorset/Contents.json new file mode 100644 index 000000000..1e597197f --- /dev/null +++ b/Nos/Assets/Colors.xcassets/styled-border-shadow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.150", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Models/CoreData/Event+CoreDataClass.swift b/Nos/Models/CoreData/Event+CoreDataClass.swift index 95e292b67..9526b03f6 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -40,6 +40,7 @@ public class Event: NosManagedObject, VerifiableEvent { @Dependency(\.currentUser) @ObservationIgnored private var currentUser var pubKey: String { author?.hexadecimalPublicKey ?? "" } + static var replyNoteReferences = "kind = 1 AND ANY eventReferences.referencedEvent.identifier == %@ " + "AND author.muted = false" @@ -592,10 +593,8 @@ public class Event: NosManagedObject, VerifiableEvent { /// Populates an event stub (with only its ID set) using the data in the given JSON. func hydrate(from jsonEvent: JSONEvent, relay: Relay?, in context: NSManagedObjectContext) throws { - guard isStub else { - fatalError("Tried to hydrate an event that isn't a stub. This is a programming error") - } - + assert(isStub, "Tried to hydrate an event that isn't a stub. This is a programming error") + // if this stub was created with a replaceableIdentifier and author, it won't have an identifier yet identifier = jsonEvent.id @@ -726,7 +725,7 @@ public class Event: NosManagedObject, VerifiableEvent { print("error parsing e tag: \(error.localizedDescription)") } } else if jsonTag.first == "p" { - // TODO: validdate that the tag looks like a pubkey + // TODO: validate that the tag looks like a pubkey let authorReference = AuthorReference(context: context) authorReference.pubkey = jsonTag[safe: 1] authorReference.recommendedRelayUrl = jsonTag[safe: 2] @@ -818,6 +817,7 @@ public class Event: NosManagedObject, VerifiableEvent { @MainActor var loadingViewData = false @MainActor var attributedContent = LoadingContent.loading @MainActor var contentLinks = [URL]() + @MainActor private(set) var quotedNoteID: RawEventID? @MainActor var relaySubscriptions = SubscriptionCancellables() /// Instructs this event to load supplementary data like author name and photo, reference events, and produce @@ -826,18 +826,31 @@ public class Event: NosManagedObject, VerifiableEvent { guard !loadingViewData else { return } - loadingViewData = true + + loadingViewData = true Log.debug("\(identifier ?? "null") loading view data") - if isStub { - await loadContent() - loadingViewData = false - // TODO: how do we load details for the event again after we hydrate the stub? - } else { - Task { await loadReferencedNote() } - Task { await loadAuthorMetadata() } - Task { await loadAttributedContent() } + await withTaskGroup(of: Void.self) { group in + if isStub { + group.addTask { + await self.loadContent() + } + // TODO: how do we load details for the event again after we hydrate the stub? + } else { + group.addTask { + await self.loadReferencedNote() + } + group.addTask { + await self.loadAuthorMetadata() + } + group.addTask { + await self.loadAttributedContent() + } + } + + await group.waitForAll() } + loadingViewData = false } /// Tries to download this event from relays. @@ -866,11 +879,8 @@ public class Event: NosManagedObject, VerifiableEvent { /// Tries to load the note this note is reposting or replying to from relays. @MainActor private func loadReferencedNote() async { - if let referencedNote = referencedNote() { - await referencedNote.loadViewData() - } else { - await rootNote()?.loadViewData() - } + let referencedNote = referencedNote() ?? rootNote() + await referencedNote?.loadViewData() } @MainActor private var loadingAttributedContent = false @@ -882,20 +892,39 @@ public class Event: NosManagedObject, VerifiableEvent { return } loadingAttributedContent = true - defer { loadingAttributedContent = false } @Dependency(\.persistenceController) var persistenceController let backgroundContext = persistenceController.backgroundViewContext - if let parsedAttributedContent = await Event.attributedContentAndURLs( + if let components = await Event.parsedComponents( note: self, context: backgroundContext ) { - let (attributedString, contentLinks) = parsedAttributedContent - self.attributedContent = .loaded(attributedString) - self.contentLinks = contentLinks + self.attributedContent = .loaded(components.attributedContent) + self.contentLinks = components.contentLinks + self.quotedNoteID = components.quotedNoteID + Task { await loadFirstQuotedNote() } } else { - self.attributedContent = .loaded(AttributedString(content ?? "")) + self.attributedContent = .loaded(AttributedString(content ?? "")) } + loadingAttributedContent = false + } + + @MainActor func loadFirstQuotedNote() async { + guard let quotedNoteID else { + return + } + + @Dependency(\.persistenceController) var persistenceController + let context = persistenceController.backgroundViewContext + + _ = try? Event.findOrCreateStubBy(id: quotedNoteID, context: context) + + await context.perform { + try? context.save() + } + + @Dependency(\.relayService) var relayService + relaySubscriptions.append(await relayService.requestEvent(with: quotedNoteID)) } // MARK: - Helpers @@ -1027,18 +1056,18 @@ public class Event: NosManagedObject, VerifiableEvent { /// `note` is in. /// - Returns: A tuple where the first object is the note content formatted for display, and the second is a list /// of HTTP links found in the note's context. - @MainActor class func attributedContentAndURLs( + @MainActor class func parsedComponents( note: Event, noteParser: NoteParser = NoteParser(), context: NSManagedObjectContext - ) async -> (AttributedString, [URL])? { + ) async -> NoteParser.NoteDisplayComponents? { guard let content = note.content else { return nil } let tags = note.allTags as? [[String]] ?? [] return await context.perform { - noteParser.parse(content: content, tags: tags, context: context) + noteParser.components(from: content, tags: tags, context: context) } } diff --git a/Nos/Models/NoteParser.swift b/Nos/Models/NoteParser.swift index f46deadaf..857c74e39 100644 --- a/Nos/Models/NoteParser.swift +++ b/Nos/Models/NoteParser.swift @@ -6,6 +6,16 @@ import RegexBuilder /// This struct encapsulates the algorithms that parse notes and the mentions inside the note. struct NoteParser { + /// Components of a note that can be used to display the note in the UI. + struct NoteDisplayComponents { + /// The note content as attributed text with tagged entities replaced with readable names. + let attributedContent: AttributedString + /// Content links parsed from the note. + let contentLinks: [URL] + /// The id of the first quoted note in the content, if one exists. + let quotedNoteID: RawEventID? + } + /// Parses attributed text generated when composing a note and returns /// the content and tags. func parse(attributedText: AttributedString) -> (String, [[String]]) { @@ -15,34 +25,32 @@ struct NoteParser { /// Parses the content and tags stored in a note and returns an attributed text with tagged entities replaced /// with readable names. func parse(content: String, tags: [[String]], context: NSManagedObjectContext) -> AttributedString { - var result = replaceTaggedNostrEntities(in: content, tags: tags, context: context) - result = replaceNostrEntities(in: result) - do { - return try AttributedString( - markdown: result, - options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } catch { - return AttributedString(stringLiteral: content) - } + let replaced = replaceTaggedNostrEntities(in: content, tags: tags, context: context) + let (result, _) = replaceNostrEntities(in: replaced) + return (try? AttributedString( + markdown: result, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(content) } - - /// Parses the content and tags stored in a note and returns an attributed string and list of URLs that can be used + + /// Parses the content and tags stored in a note and returns components that can be used /// to display the note in the UI. - func parse(content: String, tags: [[String]], context: NSManagedObjectContext) -> (AttributedString, [URL]) { + func components(from content: String, tags: [[String]], context: NSManagedObjectContext) -> NoteDisplayComponents { let (cleanedString, urls) = URLParser().replaceUnformattedURLs( in: content ) - var result = replaceTaggedNostrEntities(in: cleanedString, tags: tags, context: context) - result = replaceNostrEntities(in: result) - do { - return (try AttributedString( - markdown: result, - options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ), urls) - } catch { - return (AttributedString(stringLiteral: content), urls) - } + let replaced = replaceTaggedNostrEntities(in: cleanedString, tags: tags, context: context) + let (result, quotedNoteID) = replaceNostrEntities(in: replaced, capturesFirstNote: true) + + let attributedContent = (try? AttributedString( + markdown: result.trimmingCharacters(in: .whitespacesAndNewlines), + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(content) + return NoteDisplayComponents( + attributedContent: attributedContent, + contentLinks: urls, + quotedNoteID: quotedNoteID + ) } // swiftlint:disable function_body_length @@ -110,12 +118,20 @@ struct NoteParser { } // swiftlint:enable function_body_length - /// Replaces Nostr entities embedded in the note (without a proper tag) with markdown links - private func replaceNostrEntities(in content: String) -> String { + /// Replaces Nostr entities embedded in the note (without a proper tag) with markdown links and + /// optionally extracts the first quoted note id. + /// + /// - Parameters: + /// - content: The note content in which to replace entities. + /// - capturesFirstNote: If true, this function will extract the first quoted note id, if it exists. + /// Defaults to `false`. + /// - Returns: A tuple of the edited content and the first quoted note id, if it was requested and it exists. + private func replaceNostrEntities(in content: String, capturesFirstNote: Bool = false) -> (String, RawEventID?) { let unformattedRegex = /@?(?:nostr:)?(?((npub1|note1|nprofile1|nevent1|naddr1)[a-zA-Z0-9]{58,}))/ - - return content.replacing(unformattedRegex) { match in + + var firstNoteID: RawEventID? + let result = content.replacing(unformattedRegex) { match in let substring = match.0 let entity = match.1 var prefix = "" @@ -131,7 +147,12 @@ struct NoteParser { case .npub(let rawAuthorID), .nprofile(let rawAuthorID, _): return "\(prefix)[\(string)](@\(rawAuthorID))" case .note(let rawEventID), .nevent(let rawEventID, _, _, _): - return "\(prefix)[\(String(localized: .localizable.linkToNote))](%\(rawEventID))" + if capturesFirstNote && firstNoteID == nil { + firstNoteID = rawEventID + return "" + } else { + return "\(prefix)[\(String(localized: .localizable.linkToNote))](%\(rawEventID))" + } case .naddr(let replaceableID, _, let authorID, let kind): return "\(prefix)[\(String(localized: .localizable.linkToNote))]" + "($\(replaceableID);\(authorID);\(kind))" @@ -142,6 +163,7 @@ struct NoteParser { return String(substring) } } + return (result, firstNoteID) } /// Parse links in `attributedString` and replace them with plain text, diff --git a/Nos/Models/URLParser.swift b/Nos/Models/URLParser.swift index 0ec1279b3..d1587e403 100644 --- a/Nos/Models/URLParser.swift +++ b/Nos/Models/URLParser.swift @@ -3,7 +3,7 @@ import Logger /// Parses unformatted urls in a string and replace them with markdown links struct URLParser { - /// Returns an array with all unformated urls + /// Returns an array with all unformatted urls func findUnformattedURLs(in content: String) throws -> [URL] { // swiftlint:disable line_length let regex = "((http|https)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)" diff --git a/Nos/Views/BioView.swift b/Nos/Views/BioView.swift index 7367bf27f..a494523a3 100644 --- a/Nos/Views/BioView.swift +++ b/Nos/Views/BioView.swift @@ -34,7 +34,7 @@ struct BioView: View { guard let bio else { return AttributedString() } - let (content, _) = noteParser.parse( + let content = noteParser.parse( content: bio, tags: [[]], context: viewContext diff --git a/Nos/Views/CompactNoteView.swift b/Nos/Views/CompactNoteView.swift index 6d17fd3f0..a9130c3b8 100644 --- a/Nos/Views/CompactNoteView.swift +++ b/Nos/Views/CompactNoteView.swift @@ -83,9 +83,13 @@ struct CompactNoteView: View { .font(.clarity(.regular)) .redacted(reason: .placeholder) case .loaded(let attributedString): - Text(attributedString) + // prevents blank space above quoted note if there is no other content + if !attributedString.characters.isEmpty { + Text(attributedString) + } } } + .multilineTextAlignment(.leading) } var body: some View { @@ -158,9 +162,6 @@ struct CompactNoteView: View { .task { await note.loadViewData() } - .task { - await note.loadAttributedContent() - } } } diff --git a/Nos/Views/Modifiers/View+StyledBorder.swift b/Nos/Views/Modifiers/View+StyledBorder.swift new file mode 100644 index 000000000..e1cff51c1 --- /dev/null +++ b/Nos/Views/Modifiers/View+StyledBorder.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftUI + +extension View { + /// Applies a rounded border with a subtle styled gradient to this view. + func withStyledBorder() -> some View { + modifier(StyledBorder()) + } +} + +/// A rounded border with a subtle styled gradient. +struct StyledBorder: ViewModifier { + func body(content: Content) -> some View { + content + .overlay( + RoundedRectangle(cornerRadius: 16) + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + .styledBorderGradientTop, + .styledBorderGradientBottom + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 3 + ) + ) + .shadow( + color: .styledBorderShadow, + radius: 2, + x: 0, + y: 2 + ) + } +} diff --git a/Nos/Views/NoteCard.swift b/Nos/Views/NoteCard.swift index 0d9967fda..4e3baadf4 100644 --- a/Nos/Views/NoteCard.swift +++ b/Nos/Views/NoteCard.swift @@ -8,15 +8,16 @@ import Dependencies /// Use this view inside MessageButton to have nice borders. struct NoteCard: View { - var note: Event + @ObservedObject var note: Event + @State private var quotedNote: Event? - var style = CardStyle.compact + let style: CardStyle @State private var warningController = NoteWarningController() @EnvironmentObject private var router: Router - private var shouldTruncate: Bool + private let shouldTruncate: Bool private let repliesDisplayType: RepliesDisplayType /// Indicates whether the number of likes is displayed. @@ -24,9 +25,11 @@ struct NoteCard: View { /// Indicates whether the number of reposts is displayed. private let showsRepostCount: Bool - - private var hideOutOfNetwork: Bool - private var replyAction: ((Event) -> Void)? + + private let hideOutOfNetwork: Bool + private let rendersQuotedNotes: Bool + private let showsActions: Bool + private let replyAction: ((Event) -> Void)? /// Initializes a NoteCard object. /// @@ -46,6 +49,8 @@ struct NoteCard: View { style: CardStyle = .compact, shouldTruncate: Bool = true, hideOutOfNetwork: Bool = true, + rendersQuotedNotes: Bool = true, + showsActions: Bool = true, repliesDisplayType: RepliesDisplayType = .displayNothing, showsLikeCount: Bool = true, showsRepostCount: Bool = true, @@ -55,6 +60,8 @@ struct NoteCard: View { self.style = style self.shouldTruncate = shouldTruncate self.hideOutOfNetwork = hideOutOfNetwork + self.rendersQuotedNotes = rendersQuotedNotes + self.showsActions = showsActions self.repliesDisplayType = repliesDisplayType self.showsLikeCount = showsLikeCount self.showsRepostCount = showsRepostCount @@ -98,6 +105,17 @@ struct NoteCard: View { ) .blur(radius: warningController.showWarning ? 6 : 0) .frame(maxWidth: .infinity) + + if rendersQuotedNotes, let quotedNote { + Button { + router.push(quotedNote) + } label: { + NoteCard(note: quotedNote, rendersQuotedNotes: false, showsActions: false) + .withStyledBorder() + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } } BeveledSeparator() HStack(spacing: 0) { @@ -112,9 +130,11 @@ struct NoteCard: View { } } Spacer() - RepostButton(note: note, showsCount: showsRepostCount) - LikeButton(note: note, showsCount: showsLikeCount) - ReplyButton(note: note, replyAction: replyAction) + if showsActions { + RepostButton(note: note, showsCount: showsRepostCount) + LikeButton(note: note, showsCount: showsLikeCount) + ReplyButton(note: note, replyAction: replyAction) + } } .padding(.leading, 13) } @@ -137,14 +157,30 @@ struct NoteCard: View { } .task { await note.loadViewData() + loadQuotedNote() } .onChange(of: note.content) { _, _ in Task { await note.loadAttributedContent() } } + .onChange(of: note.quotedNoteID) { + loadQuotedNote() + } .background(LinearGradient.cardBackground) .listRowInsets(EdgeInsets()) .cornerRadius(cornerRadius) } + + private func loadQuotedNote() { + guard rendersQuotedNotes, let quotedNoteID = note.quotedNoteID else { + return + } + + @Dependency(\.persistenceController) var persistenceController + quotedNote = try? Event.findOrCreateStubBy( + id: quotedNoteID, + context: persistenceController.viewContext + ) + } var cornerRadius: CGFloat { switch style { diff --git a/Nos/Views/Profile/BioSheet.swift b/Nos/Views/Profile/BioSheet.swift index eb218981c..de9b31400 100644 --- a/Nos/Views/Profile/BioSheet.swift +++ b/Nos/Views/Profile/BioSheet.swift @@ -12,7 +12,7 @@ struct BioSheet: View { guard let about = author.about, !about.isEmpty else { return nil } - let (bio, _) = noteParser.parse( + let bio = noteParser.parse( content: about, tags: [[]], context: viewContext diff --git a/Nos/Views/RepostButton.swift b/Nos/Views/RepostButton.swift index c195d6c28..29e8dee5b 100644 --- a/Nos/Views/RepostButton.swift +++ b/Nos/Views/RepostButton.swift @@ -4,10 +4,10 @@ import Logger struct RepostButton: View { - var note: Event + let note: Event /// Indicates whether the number of reposts is displayed. - var showsCount: Bool + let showsCount: Bool @FetchRequest private var reposts: FetchedResults @EnvironmentObject private var relayService: RelayService diff --git a/NosTests/Models/NoteParserTests.swift b/NosTests/Models/NoteParserTests.swift index e36723f73..91fe462b9 100644 --- a/NosTests/Models/NoteParserTests.swift +++ b/NosTests/Models/NoteParserTests.swift @@ -31,11 +31,12 @@ final class NoteParserTests: CoreDataTestCase { // Act let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent // Assert XCTAssertEqual(String(attributedContent.characters), expected) @@ -54,11 +55,12 @@ final class NoteParserTests: CoreDataTestCase { // Act let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent // Assert XCTAssertEqual(String(attributedContent.characters), expected) @@ -77,11 +79,12 @@ final class NoteParserTests: CoreDataTestCase { // Act let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent // Assert XCTAssertEqual(String(attributedContent.characters), expected) @@ -115,11 +118,12 @@ final class NoteParserTests: CoreDataTestCase { let displayName2 = "npub180cvv..." let hex2 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" let tags = [["p", hex1], ["p", hex2]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 2) XCTAssertEqual(links[safe: 0]?.key, "@\(displayName1)") @@ -133,11 +137,12 @@ final class NoteParserTests: CoreDataTestCase { let npub = "npub1937vv2nf06360qn9y8el6d8sevnndy7tuh5nzre4gj05xc32tnwqauhaj6" let hex = "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc" let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 1) XCTAssertEqual(links[safe: 0]?.key, npub) @@ -148,30 +153,28 @@ final class NoteParserTests: CoreDataTestCase { let content = "Check this note1h2mmqfjqle48j8ytmdar22v42g5y9n942aumyxatgtxpqj29pjjsjecraw" let hex = "bab7b02640fe6a791c8bdb7a352995522842ccb55779b21bab42cc1049450ca5" let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) - let links = attributedContent.links - XCTAssertEqual(links.count, 1) - XCTAssertEqual(links[safe: 0]?.key, "šŸ”— Link to note") - XCTAssertEqual(links[safe: 0]?.value, URL(string: "%\(hex)")) + let attributedContent = components.attributedContent + XCTAssertTrue(attributedContent.links.isEmpty) + XCTAssertEqual(components.quotedNoteID, hex) } @MainActor func testContentWithUntaggedNIP27Note() throws { let content = "Check this nostr:note1h2mmqfjqle48j8ytmdar22v42g5y9n942aumyxatgtxpqj29pjjsjecraw" let hex = "bab7b02640fe6a791c8bdb7a352995522842ccb55779b21bab42cc1049450ca5" let tags: [[String]] = [[]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) - let links = attributedContent.links - XCTAssertEqual(links.count, 1) - XCTAssertEqual(links[safe: 0]?.key, "šŸ”— Link to note") - XCTAssertEqual(links[safe: 0]?.value, URL(string: "%\(hex)")) + let attributedContent = components.attributedContent + XCTAssertTrue(attributedContent.links.isEmpty) + XCTAssertEqual(components.quotedNoteID, hex) } @MainActor func testContentWithUntaggedProfile() throws { @@ -182,11 +185,12 @@ final class NoteParserTests: CoreDataTestCase { let tags: [[String]] = [[]] let expectedContent = content - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) @@ -207,20 +211,19 @@ final class NoteParserTests: CoreDataTestCase { let content = "check this \(event)" let tags: [[String]] = [[]] - let expectedContent = "check this šŸ”— Link to note" - let (attributedContent, _) = sut.parse( - content: content, + let expectedContent = "check this" + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) - - let links = attributedContent.links - XCTAssertEqual(links.count, 1) - XCTAssertEqual(links[safe: 0]?.key, "šŸ”— Link to note") - XCTAssertEqual(links[safe: 0]?.value, URL(string: "%\(hex)")) + + XCTAssertTrue(attributedContent.links.isEmpty) + XCTAssertEqual(components.quotedNoteID, hex) } @MainActor func testContentWithUntaggedEventWithADot() throws { @@ -233,20 +236,18 @@ final class NoteParserTests: CoreDataTestCase { let content = "check this \(event). Bye!" let tags: [[String]] = [[]] - let expectedContent = "check this šŸ”— Link to note. Bye!" - let (attributedContent, _) = sut.parse( - content: content, + let expectedContent = "check this . Bye!" + let components = sut.components( + from: content, tags: tags, context: testContext ) - + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) - - let links = attributedContent.links - XCTAssertEqual(links.count, 1) - XCTAssertEqual(links[safe: 0]?.key, "šŸ”— Link to note") - XCTAssertEqual(links[safe: 0]?.value, URL(string: "%\(hex)")) + + XCTAssertTrue(attributedContent.links.isEmpty) + XCTAssertEqual(components.quotedNoteID, hex) } @MainActor func testContentWithMalformedEvent() throws { @@ -258,17 +259,17 @@ final class NoteParserTests: CoreDataTestCase { let tags: [[String]] = [[]] let expectedContent = content - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) - - let links = attributedContent.links - XCTAssertEqual(links.count, 0) + + XCTAssertTrue(attributedContent.links.isEmpty) } @MainActor func testContentWithNAddr() throws { @@ -283,11 +284,12 @@ final class NoteParserTests: CoreDataTestCase { let expectedContent = "People are using Coracle's custom feeds! Here are some interesting ones:\n\nšŸ”— Link to note\nšŸ”— Link to note\nšŸ”— Link to note\nšŸ”— Link to note\n\nI encourage you to try it out ā€” create your own and paste its address into a reply to this note to share it." // swiftlint:enable line_length - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) diff --git a/NosTests/NoteParserTests+NIP08.swift b/NosTests/NoteParserTests+NIP08.swift index 66aa69610..5282504c7 100644 --- a/NosTests/NoteParserTests+NIP08.swift +++ b/NosTests/NoteParserTests+NIP08.swift @@ -10,11 +10,12 @@ extension NoteParserTests { let author = try Author.findOrCreate(by: hex, context: testContext) author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) let links = attributedContent.links @@ -30,11 +31,12 @@ extension NoteParserTests { let expectedContent = "hello @\(displayName)" let tags = [["p", hex]] let context = persistenceController.viewContext - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: context ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) let links = attributedContent.links @@ -49,11 +51,12 @@ extension NoteParserTests { let hex = "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc" let expectedContent = "@\(displayName)" let tags = [["p", hex]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) let links = attributedContent.links @@ -68,11 +71,12 @@ extension NoteParserTests { let hex = "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc" let expectedContent = "Hello\n@\(displayName)" let tags = [["p", hex]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) let links = attributedContent.links @@ -86,11 +90,12 @@ extension NoteParserTests { let hex = "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc" let expectedContent = "hello#[0]" let tags = [["p", hex]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let parsedContent = String(attributedContent.characters) XCTAssertEqual(parsedContent, expectedContent) let links = attributedContent.links diff --git a/NosTests/NoteParserTests+NIP27.swift b/NosTests/NoteParserTests+NIP27.swift index a44f1be78..0740cf98d 100644 --- a/NosTests/NoteParserTests+NIP27.swift +++ b/NosTests/NoteParserTests+NIP27.swift @@ -10,11 +10,12 @@ extension NoteParserTests { let author = try Author.findOrCreate(by: hex, context: testContext) author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 1) XCTAssertEqual(links.first?.key, "@\(name)") @@ -27,11 +28,12 @@ extension NoteParserTests { let content = "hello nostr:npub1937vv2nf06360qn9y8el6d8sevnndy7tuh5nzre4gj05xc32tnwqauhaj6" let hex = "2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc" let tags = [["p", hex]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 1) XCTAssertEqual(links.first?.key, "@\(displayName)") @@ -47,11 +49,12 @@ extension NoteParserTests { let author = try Author.findOrCreate(by: hex, context: testContext) author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 1) XCTAssertEqual(links.first?.key, "@\(name)") @@ -66,11 +69,12 @@ extension NoteParserTests { let author = try Author.findOrCreate(by: hex, context: testContext) author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links XCTAssertEqual(links.count, 1) XCTAssertEqual(links.first?.key, "@\(name)") @@ -85,17 +89,17 @@ extension NoteParserTests { let profileHex = "0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6" let noteHex = "bab7b02640fe6a791c8bdb7a352995522842ccb55779b21bab42cc1049450ca5" let tags: [[String]] = [["p", profileHex]] - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent let links = attributedContent.links - XCTAssertEqual(links.count, 2) - XCTAssertEqual(links[safe: 0]?.key, "šŸ”— Link to note") - XCTAssertEqual(links[safe: 0]?.value, URL(string: "%\(noteHex)")) - XCTAssertEqual(links[safe: 1]?.key, "\(profileDisplayName)") - XCTAssertEqual(links[safe: 1]?.value, URL(string: "@\(profileHex)")) + XCTAssertEqual(links.count, 1) + XCTAssertEqual(links[safe: 0]?.key, "\(profileDisplayName)") + XCTAssertEqual(links[safe: 0]?.value, URL(string: "@\(profileHex)")) + XCTAssertEqual(components.quotedNoteID, noteHex) } @MainActor func testNIP27MentionPrecededByAt() throws { @@ -120,11 +124,12 @@ extension NoteParserTests { author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent // Assert let parsedContent = String(attributedContent.characters) @@ -157,11 +162,12 @@ extension NoteParserTests { let author = try Author.findOrCreate(by: hex, context: testContext) author.displayName = name try testContext.save() - let (attributedContent, _) = sut.parse( - content: content, + let components = sut.components( + from: content, tags: tags, context: testContext ) + let attributedContent = components.attributedContent // Assert let parsedContent = String(attributedContent.characters)