Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

display quoted notes #944 #1415

Merged
merged 10 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where the sheet asking users to set up a NIP-05 username would appear after reinstalling Nos, even if the profile already had a NIP-05 username.
- Fixed a bug where urls with periods after them would include the period.
- Replaced hard-coded color values.
- Show quoted notes in note cards.

## [0.1.24] - 2024-08-09Z

Expand Down
4 changes: 4 additions & 0 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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 */; };
Expand Down Expand Up @@ -592,6 +593,7 @@
509532FF2C62535400E0BACA /* zap_request.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_request.json; sourceTree = "<group>"; };
509533092C625B5D00E0BACA /* zap_request_one_sat.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_one_sat.json; sourceTree = "<group>"; };
5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_no_amount.json; sourceTree = "<group>"; };
50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+StyledBorder.swift"; sourceTree = "<group>"; };
50F695062C6392C4000E4C74 /* zap_receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_receipt.json; sourceTree = "<group>"; };
5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+NIP08.swift"; sourceTree = "<group>"; };
5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Links.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1326,6 +1328,7 @@
C95D68A8299E709800429F86 /* LinearGradient+Planetary.swift */,
C9A0DAE629C69FA000466635 /* Text+Gradient.swift */,
C93EC2FC29C3785C0012EE2A /* View+RoundedCorner.swift */,
50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */,
C9DC6CB92C1739AD00E1CFB3 /* View+HandleURLsInRouter.swift */,
);
path = Modifiers;
Expand Down Expand Up @@ -2157,6 +2160,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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
86 changes: 59 additions & 27 deletions Nos/Models/CoreData/Event+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -818,6 +817,7 @@ public class Event: NosManagedObject, VerifiableEvent {
@MainActor var loadingViewData = false
@MainActor var attributedContent = LoadingContent<AttributedString>.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
Expand All @@ -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
bryanmontz marked this conversation as resolved.
Show resolved Hide resolved
}

/// Tries to download this event from relays.
Expand Down Expand Up @@ -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
Expand All @@ -882,20 +892,42 @@ 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

let quotedNote = 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
Expand Down Expand Up @@ -1027,11 +1059,11 @@ 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
}
Expand Down
65 changes: 38 additions & 27 deletions Nos/Models/NoteParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import RegexBuilder
/// This struct encapsulates the algorithms that parse notes and the mentions inside the note.
struct NoteParser {

struct NoteDisplayComponents {
bryanmontz marked this conversation as resolved.
Show resolved Hide resolved
let attributedContent: AttributedString
let contentLinks: [URL]
let quotedNoteID: RawEventID?
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved
}

/// Parses attributed text generated when composing a note and returns
/// the content and tags.
func parse(attributedText: AttributedString) -> (String, [[String]]) {
Expand All @@ -15,34 +21,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 parse(content: String, tags: [[String]], context: NSManagedObjectContext) -> NoteDisplayComponents {
bryanmontz marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -111,11 +115,12 @@ 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 {
private func replaceNostrEntities(in content: String, capturesFirstNote: Bool = false) -> (String, RawEventID?) {
joshuatbrown marked this conversation as resolved.
Show resolved Hide resolved
let unformattedRegex =
/@?(?:nostr:)?(?<entity>((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 = ""
Expand All @@ -131,7 +136,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))"
Expand All @@ -142,6 +152,7 @@ struct NoteParser {
return String(substring)
}
}
return (result, firstNoteID)
}

/// Parse links in `attributedString` and replace them with plain text,
Expand Down
2 changes: 1 addition & 1 deletion Nos/Models/URLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
bryanmontz marked this conversation as resolved.
Show resolved Hide resolved
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@:%_\\+.~#?&//=]*)"
Expand Down
2 changes: 1 addition & 1 deletion Nos/Views/BioView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct BioView: View {
guard let bio else {
return AttributedString()
}
let (content, _) = noteParser.parse(
let content: AttributedString = noteParser.parse(
content: bio,
tags: [[]],
context: viewContext
Expand Down
Loading
Loading