Fix highlight tag ambiguity with specifiers

This commit fixes the ambiguity in tags used in highlights with comments, by adding specifiers to help clients understand:
- If a URL reference is the source of the highlight or just a URL mentioned in the comment
- If a pubkey reference is the author of the highlighted content, or just a generic mention in the comment

This tries to be backwards compatible with previous versions of NIP-84.

Testing
--------

PASS

Device: iPhone 15 simulator
iOS: 17.5
Damus: This commit
Steps:
1. Create a new highlight from a webpage using the extension. Tag a user and attach an image
2. Check the newly-created highlight:
  1. Highlight description line should just say "Highlighted", not "Highlighted <username>"
  2. Highlight source link preview should present the URL of the highlighted page, NOT the image URL
3. Inspect the JSON for the newly-created highlight:
  1. "r" tags should include specifiers in the 3rd slot, such as "source" or "mention"
  2. "p" tags should include specifiers in the 3rd slot, such as "mention"
4. Go to an older, generic highlight (without comment) to another nostr event and check the view.
  1. Highlight description line should say "Highlighted <author_name_of_other_event>"
  2. Clicking on the highlight should lead to the highlighted event itself.

Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2024-08-22 14:34:10 -07:00
parent 55090bc102
commit b43dcd2bc7
6 changed files with 212 additions and 72 deletions

View File

@@ -13,24 +13,176 @@ struct HighlightEvent {
var event_ref: String? = nil var event_ref: String? = nil
var url_ref: URL? = nil var url_ref: URL? = nil
var context: String? = nil var context: String? = nil
// MARK: - Initializers and parsers
static func parse(from ev: NostrEvent) -> HighlightEvent { static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev) var highlight = HighlightEvent(event: ev)
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
for tag in ev.tags { for tag in ev.tags {
guard tag.count >= 2 else { continue } guard tag.count >= 2 else { continue }
switch tag[0].string() { switch tag[0].string() {
case "e": highlight.event_ref = tag[1].string() case "e": highlight.event_ref = tag[1].string()
case "a": highlight.event_ref = tag[1].string() case "a": highlight.event_ref = tag[1].string()
case "r": highlight.url_ref = URL(string: tag[1].string()) case "r":
if tag.count >= 3,
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
let url = URL(string: tag[1].string()) {
// URL marked as source. Very good candidate
best_url_source = (url: url, tagged_as_source: true)
}
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
// URL marked as something else (not source). Not the source we are after
}
else if let url = URL(string: tag[1].string()), tag.count == 2 {
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
if (best_url_source?.tagged_as_source ?? false) == false {
// No URL candidates marked as the source. Mark this as the best option we have
best_url_source = (url: url, tagged_as_source: false)
}
}
case "context": highlight.context = tag[1].string() case "context": highlight.context = tag[1].string()
default: default:
break break
} }
} }
if let best_url_source {
highlight.url_ref = best_url_source.url
}
return highlight return highlight
} }
// MARK: - Getting information about source
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
var others_count = 0
var highlighted_authors: [Pubkey] = []
var i = event.tags.count
if let highlighted_event {
highlighted_authors.append(highlighted_event.pubkey)
}
for tag in event.tags {
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
others_count += 1
if highlighted_authors.count < 2 {
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
continue
} else {
switch pubkey_with_role.role {
case .author:
highlighted_authors.append(pubkey_with_role.pubkey)
default:
break
}
}
}
}
i -= 1
}
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
}
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let description_info = self.source_description_info(highlighted_event: highlighted_event)
let pubkeys = description_info.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
}
// MARK: - Helper structures
extension HighlightEvent {
struct PubkeyWithRole: TagKey, TagConvertible {
let pubkey: Pubkey
let role: Role
var tag: [String] {
if let role_text = self.role.rawValue {
return [keychar.description, self.pubkey.hex(), role_text]
}
else {
return [keychar.description, self.pubkey.hex()]
}
}
var keychar: AsciiCharacter { "p" }
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "p",
let t1 = i.next(),
let pubkey = t1.id().map(Pubkey.init)
else { return nil }
let t3: String? = i.next()?.string()
let role = Role(rawValue: t3)
return PubkeyWithRole(pubkey: pubkey, role: role)
}
enum Role: RawRepresentable {
case author
case editor
case mention
case other(String)
case no_role
typealias RawValue = String?
var rawValue: String? {
switch self {
case .author: "author"
case .editor: "editor"
case .mention: "mention"
case .other(let role): role
case .no_role: nil
}
}
init(rawValue: String?) {
switch rawValue {
case "author": self = .author
case "editor": self = .editor
case "mention": self = .mention
default:
if let rawValue {
self = .other(rawValue)
}
else {
self = .no_role
}
}
}
}
}
} }
struct HighlightContentDraft: Hashable { struct HighlightContentDraft: Hashable {
@@ -39,15 +191,16 @@ struct HighlightContentDraft: Hashable {
} }
enum HighlightSource: Hashable { enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
case event(NostrEvent) case event(NostrEvent)
case external_url(URL) case external_url(URL)
func tags() -> [[String]] { func tags() -> [[String]] {
switch self { switch self {
case .event(let event): case .event(let event):
return [ ["e", "\(event.id)"] ] return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url): case .external_url(let url):
return [ ["r", "\(url)"] ] return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
} }
} }

View File

@@ -256,37 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
return nil return nil
} }
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Convert
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
new_tags.append(mention.ref.tag)
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}

View File

@@ -20,7 +20,7 @@ struct NostrPost {
func to_event(keypair: FullKeypair) -> NostrEvent? { func to_event(keypair: FullKeypair) -> NostrEvent? {
let post_blocks = self.parse_blocks() let post_blocks = self.parse_blocks()
let post_tags = make_post_tags(post_blocks: post_blocks, tags: self.tags) let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
let content = post_tags.blocks let content = post_tags.blocks
.map(\.asString) .map(\.asString)
.joined(separator: "") .joined(separator: "")
@@ -49,6 +49,50 @@ struct NostrPost {
return self.content return self.content
} }
} }
/// Parse the post's contents to find more tags to apply to the final nostr event
private func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
if self.kind == .highlight, case .pubkey(_) = mention.ref {
var new_tag = mention.ref.tag
new_tag.append("mention")
new_tags.append(new_tag)
}
else {
new_tags.append(mention.ref.tag)
}
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
}
// MARK: - Helper structures and functions
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
fileprivate struct PostTags {
let blocks: [Block]
let tags: [[String]]
} }
func parse_post_blocks(content: String) -> [Block] { func parse_post_blocks(content: String) -> [Block] {

View File

@@ -17,7 +17,8 @@ struct ReplyPart: View {
Group { Group {
if event.known_kind == .highlight { if event.known_kind == .highlight {
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) } let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
HighlightDescription(event: event, highlighted_event: highlighted_note, ndb: ndb) let highlight_note = HighlightEvent.parse(from: event)
HighlightDescription(highlight_event: highlight_note, highlighted_event: highlighted_note, ndb: ndb)
} else if let reply_ref = event.thread_reply()?.reply { } else if let reply_ref = event.thread_reply()?.reply {
let replying_to = events.lookup(reply_ref.note_id) let replying_to = events.lookup(reply_ref.note_id)
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb) ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)

View File

@@ -9,12 +9,12 @@ import SwiftUI
// Modified from Reply Description // Modified from Reply Description
struct HighlightDescription: View { struct HighlightDescription: View {
let event: NostrEvent let highlight_event: HighlightEvent
let highlighted_event: NostrEvent? let highlighted_event: NostrEvent?
let ndb: Ndb let ndb: Ndb
var body: some View { var body: some View {
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_desc(ndb: ndb, event: event, highlighted_event: highlighted_event))")) (Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_event.source_description_text(ndb: ndb, highlighted_event: highlighted_event))"))
.font(.footnote) .font(.footnote)
.foregroundColor(.gray) .foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -24,30 +24,6 @@ struct HighlightDescription: View {
struct HighlightDescription_Previews: PreviewProvider { struct HighlightDescription_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
HighlightDescription(event: test_note, highlighted_event: test_note, ndb: test_damus_state.ndb) HighlightDescription(highlight_event: HighlightEvent.parse(from: test_note), highlighted_event: nil, ndb: test_damus_state.ndb)
} }
} }
func highlight_desc(ndb: Ndb, event: NostrEvent, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let desc = make_reply_description(event, replying_to: highlighted_event)
let pubkeys = desc.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}

View File

@@ -702,11 +702,6 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
case .highlighting(let draft): case .highlighting(let draft):
break break
} }
// include pubkeys
tags += pubkeys.map { pk in
["p", pk.hex()]
}
// append additional tags // append additional tags
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() } tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
@@ -717,9 +712,14 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) { if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) {
tags.append(["comment", content]) tags.append(["comment", content])
} }
tags += pubkeys.map { pk in
["p", pk.hex(), "mention"]
}
return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags) return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags)
default: default:
break tags += pubkeys.map { pk in
["p", pk.hex()]
}
} }
return NostrPost(content: content, kind: .text, tags: tags) return NostrPost(content: content, kind: .text, tags: tags)