Highlights

This patch adds highlights (NIP-84) to Damus.

Kind 9802 are handled by all the necessary models.
We show highlighted events, longform events, and url references.
Url references also leverage text fragments to take the user to the highlighted text.

Testing
——
iPhone 15 Pro Max (17.0) Dark Mode:
https://v.nostr.build/oM6DW.mp4

iPhone 15 Pro Max (17.0) Light Mode:
https://v.nostr.build/BRrmP.mp4

iPhone SE (3rd generation) (16.4) Light Mode:
https://v.nostr.build/6GzKa.mp4
——

Closes: https://github.com/damus-io/damus/issues/2172
Closes: https://github.com/damus-io/damus/issues/1772
Closes: https://github.com/damus-io/damus/issues/1773
Closes: https://github.com/damus-io/damus/issues/2173
Closes: https://github.com/damus-io/damus/issues/2175
Changelog-Added: Highlights (NIP-84)

PATCH CHANGELOG:
V1 -> V2: addressed review comments highlights are now truncated and highlight label shown in Thread view
V2 -> V3: handle case where highlight context is smaller than the highlight content

Signed-off-by: ericholguin <ericholguin@apache.org>
This commit is contained in:
ericholguin
2024-05-27 13:05:59 -06:00
committed by Daniel D’Aquino
parent 23c3130a82
commit 6376c61bad
18 changed files with 573 additions and 10 deletions

View File

@@ -406,6 +406,11 @@
5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; }; 5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; };
5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; }; 5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.swift */; };
5C7389B92B9E69ED00781E0A /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; }; 5C7389B92B9E69ED00781E0A /* MutinyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */; };
5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; };
5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; };
5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; };
5CC852A42BDF3CA10039FFC5 /* HighlightLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A32BDF3CA10039FFC5 /* HighlightLink.swift */; };
5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A52BE00F180039FFC5 /* HighlightEventRef.swift */; };
5CC868DD2AA29B3200FB22BA /* NeutralButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868DC2AA29B3200FB22BA /* NeutralButtonStyle.swift */; }; 5CC868DD2AA29B3200FB22BA /* NeutralButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868DC2AA29B3200FB22BA /* NeutralButtonStyle.swift */; };
5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; }; 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; };
5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; }; 5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; };
@@ -1341,6 +1346,11 @@
5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; }; 5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; };
5C7389B62B9E692E00781E0A /* MutinyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyButton.swift; sourceTree = "<group>"; }; 5C7389B62B9E692E00781E0A /* MutinyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyButton.swift; sourceTree = "<group>"; };
5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyGradient.swift; sourceTree = "<group>"; }; 5C7389B82B9E69ED00781E0A /* MutinyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutinyGradient.swift; sourceTree = "<group>"; };
5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = "<group>"; };
5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = "<group>"; };
5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = "<group>"; };
5CC852A32BDF3CA10039FFC5 /* HighlightLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightLink.swift; sourceTree = "<group>"; };
5CC852A52BE00F180039FFC5 /* HighlightEventRef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEventRef.swift; sourceTree = "<group>"; };
5CC868DC2AA29B3200FB22BA /* NeutralButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeutralButtonStyle.swift; sourceTree = "<group>"; }; 5CC868DC2AA29B3200FB22BA /* NeutralButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeutralButtonStyle.swift; sourceTree = "<group>"; };
5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = "<group>"; }; 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicView.swift; sourceTree = "<group>"; };
5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = "<group>"; }; 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLightGradient.swift; sourceTree = "<group>"; };
@@ -1668,6 +1678,8 @@
B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */, B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */,
B533694D2B66D791008A805E /* MutelistManager.swift */, B533694D2B66D791008A805E /* MutelistManager.swift */,
D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */, D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */,
D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */,
5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -2402,6 +2414,7 @@
4CC7AAEE297F11B300430951 /* Events */ = { 4CC7AAEE297F11B300430951 /* Events */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5CC852A02BDED9970039FFC5 /* Highlight */,
4CA927682A290F8F0098A105 /* Components */, 4CA927682A290F8F0098A105 /* Components */,
4CC7AAEF297F11C700430951 /* SelectedEventView.swift */, 4CC7AAEF297F11C700430951 /* SelectedEventView.swift */,
4CC7AAF5297F1A6A00430951 /* EventBody.swift */, 4CC7AAF5297F1A6A00430951 /* EventBody.swift */,
@@ -2699,6 +2712,17 @@
path = Images; path = Images;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
5CC852A02BDED9970039FFC5 /* Highlight */ = {
isa = PBXGroup;
children = (
5CC8529E2BD744F60039FFC5 /* HighlightView.swift */,
5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */,
5CC852A32BDF3CA10039FFC5 /* HighlightLink.swift */,
5CC852A52BE00F180039FFC5 /* HighlightEventRef.swift */,
);
path = Highlight;
sourceTree = "<group>";
};
7C0F392D29B57C8F0039859C /* Extensions */ = { 7C0F392D29B57C8F0039859C /* Extensions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -3183,6 +3207,7 @@
4C32B94D2A9AD44700DC3548 /* Offset.swift in Sources */, 4C32B94D2A9AD44700DC3548 /* Offset.swift in Sources */,
4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */,
4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */,
5CC852A42BDF3CA10039FFC5 /* HighlightLink.swift in Sources */,
4C32B9552A9AD44700DC3548 /* ByteBuffer.swift in Sources */, 4C32B9552A9AD44700DC3548 /* ByteBuffer.swift in Sources */,
4C32B95B2A9AD44700DC3548 /* NativeObject.swift in Sources */, 4C32B95B2A9AD44700DC3548 /* NativeObject.swift in Sources */,
3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */, 3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */,
@@ -3322,6 +3347,7 @@
4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */, 4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */,
4C06670128FC7C5900038D2A /* RelayView.swift in Sources */, 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */,
4C285C8C28398BC7008A31F1 /* Keys.swift in Sources */, 4C285C8C28398BC7008A31F1 /* Keys.swift in Sources */,
5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */,
4C94D6432BA5AEFE00C26EFF /* QuoteRepostsView.swift in Sources */, 4C94D6432BA5AEFE00C26EFF /* QuoteRepostsView.swift in Sources */,
D7EDED332B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */, D7EDED332B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */,
4CA352AE2A76C1AC003BB08B /* FollowedNotify.swift in Sources */, 4CA352AE2A76C1AC003BB08B /* FollowedNotify.swift in Sources */,
@@ -3365,7 +3391,9 @@
4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */,
4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */, 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */,
4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */, 4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */,
5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */,
BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */, BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */,
5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */,
4C9146FD2A2A87C200DDEA40 /* wasm.c in Sources */, 4C9146FD2A2A87C200DDEA40 /* wasm.c in Sources */,
4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */,
4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */,
@@ -3477,6 +3505,7 @@
B51C1CEB2B55A60A00E312A9 /* MuteDurationMenu.swift in Sources */, B51C1CEB2B55A60A00E312A9 /* MuteDurationMenu.swift in Sources */,
4CB88389296AF99A00DC99E7 /* EventDetailBar.swift in Sources */, 4CB88389296AF99A00DC99E7 /* EventDetailBar.swift in Sources */,
4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */, 4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */,
5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */,
4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */, 4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */,
4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */, 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */,
4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */, 4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */,

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF2",
"green" : "0xD8",
"red" : "0xF4"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x45",
"green" : "0x17",
"red" : "0x47"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -28,6 +28,7 @@ class DamusColors {
static let green = Color("DamusGreen") static let green = Color("DamusGreen")
static let purple = Color("DamusPurple") static let purple = Color("DamusPurple")
static let deepPurple = Color("DamusDeepPurple") static let deepPurple = Color("DamusDeepPurple")
static let highlight = Color("DamusHighlight")
static let blue = Color("DamusBlue") static let blue = Color("DamusBlue")
static let bitcoin = Color("Bitcoin") static let bitcoin = Color("Bitcoin")
static let success = Color("DamusSuccessPrimary") static let success = Color("DamusSuccessPrimary")

View File

@@ -0,0 +1,34 @@
//
// HighlightEvent.swift
// damus
//
// Created by eric on 4/22/24.
//
import Foundation
struct HighlightEvent {
let event: NostrEvent
var event_ref: String? = nil
var url_ref: URL? = nil
var context: String? = nil
static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "e": 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 "context": highlight.context = tag[1].string()
default:
break
}
}
return highlight
}
}

View File

@@ -184,7 +184,7 @@ class HomeModel: ContactsDelegate {
} }
switch kind { switch kind {
case .chat, .longform, .text: case .chat, .longform, .text, .highlight:
handle_text_event(sub_id: sub_id, ev) handle_text_event(sub_id: sub_id, ev)
case .contacts: case .contacts:
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev) handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
@@ -586,7 +586,7 @@ class HomeModel: ContactsDelegate {
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) { func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) {
// TODO: separate likes? // TODO: separate likes?
var home_filter_kinds: [NostrKind] = [ var home_filter_kinds: [NostrKind] = [
.text, .longform, .boost .text, .longform, .boost, .highlight
] ]
if !damus_state.settings.onlyzaps_mode { if !damus_state.settings.onlyzaps_mode {
home_filter_kinds.append(.like) home_filter_kinds.append(.like)

View File

@@ -60,11 +60,11 @@ class ProfileModel: ObservableObject, Equatable {
damus.pool.unsubscribe(sub_id: sub_id) damus.pool.unsubscribe(sub_id: sub_id)
damus.pool.unsubscribe(sub_id: prof_subid) damus.pool.unsubscribe(sub_id: prof_subid)
} }
func subscribe() { func subscribe() {
var text_filter = NostrFilter(kinds: [.text, .longform]) var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost]) var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
profile_filter.authors = [pubkey] profile_filter.authors = [pubkey]
text_filter.authors = [pubkey] text_filter.authors = [pubkey]

View File

@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
func subscribe() { func subscribe() {
// since 1 month // since 1 month
search.limit = self.limit search.limit = self.limit
search.kinds = [.text, .like, .longform] search.kinds = [.text, .like, .longform, .highlight]
//likes_filter.ids = ref_events.referenced_ids! //likes_filter.ids = ref_events.referenced_ids!

View File

@@ -22,6 +22,7 @@ enum NostrKind: UInt32, Codable {
case longform = 30023 case longform = 30023
case zap = 9735 case zap = 9735
case zap_request = 9734 case zap_request = 9734
case highlight = 9802
case nwc_request = 23194 case nwc_request = 23194
case nwc_response = 23195 case nwc_response = 23195
case http_auth = 27235 case http_auth = 27235

View File

@@ -45,6 +45,8 @@ struct EventView: View {
} }
} else if event.known_kind == .longform { } else if event.known_kind == .longform {
LongformPreview(state: damus, ev: event, options: options) LongformPreview(state: damus, ev: event, options: options)
} else if event.known_kind == .highlight {
HighlightView(state: damus, event: event, options: options)
} else { } else {
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options) TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
//.padding([.top], 6) //.padding([.top], 6)

View File

@@ -16,7 +16,15 @@ struct ReplyPart: View {
var body: some View { var body: some View {
Group { Group {
if let reply_ref = event.thread_reply()?.reply { if let reply_ref = event.thread_reply()?.reply {
ReplyDescription(event: event, replying_to: events.lookup(reply_ref.note_id), ndb: ndb) let replying_to = events.lookup(reply_ref.note_id)
if event.known_kind != .highlight {
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
} else if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: replying_to, ndb: ndb)
}
else {
EmptyView()
}
} else { } else {
EmptyView() EmptyView()
} }

View File

@@ -35,6 +35,8 @@ struct EventBody: View {
if !options.contains(.truncate_content) { if !options.contains(.truncate_content) {
note_content note_content
} }
} else if event.known_kind == .highlight {
HighlightBodyView(state: damus_state, ev: event, options: options)
} else { } else {
note_content note_content
} }

View File

@@ -0,0 +1,54 @@
//
// HighlightDescription.swift
// damus
//
// Created by eric on 4/28/24.
//
import SwiftUI
// Modified from Reply Description
struct HighlightDescription: View {
let event: NostrEvent
let highlighted_event: NostrEvent?
let ndb: Ndb
var body: some View {
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_desc(ndb: ndb, event: event, highlighted_event: highlighted_event))"))
.font(.footnote)
.foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct HighlightDescription_Previews: PreviewProvider {
static var previews: some View {
HighlightDescription(event: test_note, highlighted_event: test_note, 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 desc.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 = NSOrderedSet(array: names).array as! [String]
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames[0])
}

View File

@@ -0,0 +1,92 @@
//
// HighlightEventRef.swift
// damus
//
// Created by eric on 4/29/24.
//
import SwiftUI
import Kingfisher
struct HighlightEventRef: View {
let damus_state: DamusState
let event_ref: NoteId
init(damus_state: DamusState, event_ref: NoteId) {
self.damus_state = damus_state
self.event_ref = event_ref
}
struct FailedImage: View {
var body: some View {
Image("markdown")
.resizable()
.foregroundColor(DamusColors.neutral6)
.background(DamusColors.neutral3)
.frame(width: 35, height: 35)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
.scaledToFit()
}
}
var body: some View {
EventLoaderView(damus_state: damus_state, event_id: event_ref) { event in
EventMutingContainerView(damus_state: damus_state, event: event) {
if event.known_kind == .longform {
HStack(alignment: .top, spacing: 10) {
let longform_event = LongformEvent.parse(from: event)
if let url = longform_event.image {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: true)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.background {
FailedImage()
}
.frame(width: 35, height: 35)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
.scaledToFit()
} else {
FailedImage()
}
VStack(alignment: .leading, spacing: 5) {
Text(longform_event.title ?? "Untitled")
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
let profile_txn = damus_state.profiles.lookup(id: longform_event.event.pubkey, txn_name: "highlight-profile")
let profile = profile_txn?.unsafeUnownedValue
if let display_name = profile?.display_name {
Text(display_name)
.font(.system(size: 12))
.foregroundColor(.gray)
} else if let name = profile?.name {
Text(name)
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
.padding([.leading, .vertical], 7)
.frame(maxWidth: .infinity, alignment: .leading)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.neutral3, lineWidth: 2)
)
} else {
EmptyView()
}
}
}
}
}

View File

@@ -0,0 +1,101 @@
//
// HighlightLink.swift
// damus
//
// Created by eric on 4/28/24.
//
import SwiftUI
import Kingfisher
struct HighlightLink: View {
let state: DamusState
let url: URL
let content: String
@Environment(\.openURL) var openURL
func text_fragment_url() -> URL? {
let fragmentDirective = "#:~:"
let textDirective = "text="
let separator = ","
var text = ""
let components = content.components(separatedBy: " ")
if components.count <= 10 {
text = content
} else {
let textStart = Array(components.prefix(5)).joined(separator: " ")
let textEnd = Array(components.suffix(2)).joined(separator: " ")
text = textStart + separator + textEnd
}
let url_with_fragments = url.absoluteString + fragmentDirective + textDirective + text
return URL(string: url_with_fragments)
}
func get_url_icon() -> URL? {
var icon = URL(string: url.absoluteString + "/favicon.ico")
if let url_host = url.host() {
icon = URL(string: "https://" + url_host + "/favicon.ico")
}
return icon
}
var body: some View {
Button(action: {
openURL(text_fragment_url() ?? url)
}, label: {
HStack(spacing: 10) {
if let url = get_url_icon() {
KFAnimatedImage(url)
.imageContext(.pfp, disable_animation: true)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.placeholder { _ in
Image("link")
.resizable()
.padding(5)
.foregroundColor(DamusColors.neutral6)
.background(DamusColors.adaptableWhite)
}
.frame(width: 35, height: 35)
.clipShape(RoundedRectangle(cornerRadius: 10))
.scaledToFit()
} else {
Image("link")
.resizable()
.padding(5)
.foregroundColor(DamusColors.neutral6)
.background(DamusColors.adaptableWhite)
.frame(width: 35, height: 35)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Text(url.absoluteString)
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
.foregroundColor(DamusColors.adaptableBlack)
.truncationMode(.tail)
.lineLimit(1)
}
.padding([.leading, .vertical], 7)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DamusColors.neutral3)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(DamusColors.neutral3, lineWidth: 2)
)
})
}
}
struct HighlightLink_Previews: PreviewProvider {
static var previews: some View {
let url = URL(string: "https://damus.io")!
VStack {
HighlightLink(state: test_damus_state, url: url, content: "")
}
}
}

View File

@@ -0,0 +1,192 @@
//
// HighlightView.swift
// damus
//
// Created by eric on 4/22/24.
//
import SwiftUI
import Kingfisher
struct HighlightTruncatedText: View {
let attributedString: AttributedString
let maxChars: Int
init(attributedString: AttributedString, maxChars: Int = 360) {
self.attributedString = attributedString
self.maxChars = maxChars
}
var body: some View {
VStack(alignment: .leading) {
let truncatedAttributedString: AttributedString? = attributedString.truncateOrNil(maxLength: maxChars)
if let truncatedAttributedString {
Text(truncatedAttributedString)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(attributedString)
.fixedSize(horizontal: false, vertical: true)
}
if truncatedAttributedString != nil {
Spacer()
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
.allowsHitTesting(false)
}
}
}
}
struct HighlightBodyView: View {
let state: DamusState
let event: HighlightEvent
let options: EventViewOptions
init(state: DamusState, ev: HighlightEvent, options: EventViewOptions) {
self.state = state
self.event = ev
self.options = options
}
init(state: DamusState, ev: NostrEvent, options: EventViewOptions) {
self.state = state
self.event = HighlightEvent.parse(from: ev)
self.options = options
}
var body: some View {
Group {
if options.contains(.wide) {
Main.padding(.horizontal)
} else {
Main
}
}
}
var truncate: Bool {
return options.contains(.truncate_content)
}
var truncate_very_short: Bool {
return options.contains(.truncate_content_very_short)
}
func truncatedText(attributedString: AttributedString) -> some View {
Group {
if truncate_very_short {
HighlightTruncatedText(attributedString: attributedString, maxChars: 140)
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
}
else if truncate {
HighlightTruncatedText(attributedString: attributedString)
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
} else {
Text(attributedString)
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
}
}
}
var Main: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
var attributedString: AttributedString {
var attributedString: AttributedString = ""
if let context = event.context {
if context.count < event.event.content.count {
attributedString = AttributedString(event.event.content)
} else {
attributedString = AttributedString(context)
}
} else {
attributedString = AttributedString(event.event.content)
}
if let range = attributedString.range(of: event.event.content) {
attributedString[range].backgroundColor = DamusColors.highlight
}
return attributedString
}
truncatedText(attributedString: attributedString)
.lineSpacing(5)
.padding(10)
}
.overlay(
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
.padding(.bottom, 10)
if let url = event.url_ref {
HighlightLink(state: state, url: url, content: event.event.content)
} else {
if let evRef = event.event_ref {
if let eventHex = hex_decode_id(evRef) {
HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex))
.padding(.top, 5)
}
}
}
}
}
}
struct HighlightView: View {
let state: DamusState
let event: HighlightEvent
let options: EventViewOptions
init(state: DamusState, event: NostrEvent, options: EventViewOptions) {
self.state = state
self.event = HighlightEvent.parse(from: event)
self.options = options.union(.no_mentions)
}
var body: some View {
VStack(alignment: .leading) {
EventShell(state: state, event: event.event, options: options) {
HighlightBodyView(state: state, ev: event, options: options)
}
}
}
}
struct HighlightView_Previews: PreviewProvider {
static var previews: some View {
let content = "Nostr, a decentralized and open social network protocol. Without ads, toxic algorithms, or censorship"
let context = "Damus is built on Nostr, a decentralized and open social network protocol. Without ads, toxic algorithms, or censorship, Damus gives you access to the social network that a truly free and healthy society needs — and deserves."
let test_highlight_event = HighlightEvent.parse(from: NostrEvent(
content: content,
keypair: test_keypair,
kind: NostrKind.highlight.rawValue,
tags: [
["context", context],
["r", "https://damus.io"],
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
])!
)
let test_highlight_event2 = HighlightEvent.parse(from: NostrEvent(
content: content,
keypair: test_keypair,
kind: NostrKind.highlight.rawValue,
tags: [
["context", context],
["e", "36017b098859d62e1dbd802290d59c9de9f18bb0ca00ba4b875c2930dd5891ae"],
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
])!
)
VStack {
HighlightView(state: test_damus_state, event: test_highlight_event.event, options: [])
HighlightView(state: test_damus_state, event: test_highlight_event2.event, options: [.wide])
}
}
}

View File

@@ -41,7 +41,16 @@ struct SelectedEventView: View {
.lineLimit(1) .lineLimit(1)
if let reply_ref = event.thread_reply()?.reply { if let reply_ref = event.thread_reply()?.reply {
ReplyDescription(event: event, replying_to: damus.events.lookup(reply_ref.note_id), ndb: damus.ndb) let replying_to = damus.events.lookup(reply_ref.note_id)
if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: replying_to, ndb: damus.ndb)
.padding(.horizontal)
} else {
ReplyDescription(event: event, replying_to: replying_to, ndb: damus.ndb)
.padding(.horizontal)
}
} else if event.known_kind == .highlight {
HighlightDescription(event: event, highlighted_event: nil, ndb: damus.ndb)
.padding(.horizontal) .padding(.horizontal)
} }

View File

@@ -14,7 +14,7 @@ extension NdbNote {
} }
func get_cached_inner_event(cache: EventCache) -> NdbNote? { func get_cached_inner_event(cache: EventCache) -> NdbNote? {
guard self.known_kind == .boost else { guard self.known_kind == .boost || self.known_kind == .highlight else {
return nil return nil
} }

View File

@@ -277,7 +277,7 @@ class NdbNote: Encodable, Equatable, Hashable {
// Extension to make NdbNote compatible with NostrEvent's original API // Extension to make NdbNote compatible with NostrEvent's original API
extension NdbNote { extension NdbNote {
var is_textlike: Bool { var is_textlike: Bool {
return kind == 1 || kind == 42 || kind == 30023 return kind == 1 || kind == 42 || kind == 30023 || kind == 9802
} }
var is_quote_repost: NoteId? { var is_quote_repost: NoteId? {