commit a88e80a34618ba27c06f21fb3218e137246464fb parent 23c3130a8267c6bfaa37a392c515ac261813d1bb Author: Daniel D’Aquino <daniel@daquino.me> Date: Mon, 24 Jun 2024 11:30:59 -0700 Merge pull request #2307 from damus-io/review_highlights_2024-06-19_rebased Highlights (rebased to solve merge conflicts + minor tweaks) Diffstat:
24 files changed, 724 insertions(+), 26 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -399,6 +399,7 @@ 5C14C29D2BBBA40B00079FD2 /* RelayAdminDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */; }; 5C14C29F2BBBA5C600079FD2 /* RelayNipList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */; }; 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; }; + 5C4D9EA72C042FA5005EA0F7 /* HighlightPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4D9EA62C042FA5005EA0F7 /* HighlightPostView.swift */; }; 5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; }; 5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; }; 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; }; @@ -406,6 +407,11 @@ 5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; }; 5C7389B72B9E692E00781E0A /* MutinyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B62B9E692E00781E0A /* MutinyButton.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 */; }; 5CF2DCCC2AA3AF0B00984B8D /* RelayPicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCB2AA3AF0B00984B8D /* RelayPicView.swift */; }; 5CF2DCCE2AABE1A500984B8D /* DamusLightGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF2DCCD2AABE1A500984B8D /* DamusLightGradient.swift */; }; @@ -1334,6 +1340,7 @@ 5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayAdminDetail.swift; sourceTree = "<group>"; }; 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayNipList.swift; sourceTree = "<group>"; }; 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; }; + 5C4D9EA62C042FA5005EA0F7 /* HighlightPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightPostView.swift; sourceTree = "<group>"; }; 5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; }; 5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; }; 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; }; @@ -1341,6 +1348,11 @@ 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>"; }; 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>"; }; 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>"; }; @@ -1668,6 +1680,8 @@ B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */, B533694D2B66D791008A805E /* MutelistManager.swift */, D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */, + D7C28E3A2BBB4D0000EE459F /* VideoCache.swift */, + 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */, ); path = Models; sourceTree = "<group>"; @@ -2402,6 +2416,7 @@ 4CC7AAEE297F11B300430951 /* Events */ = { isa = PBXGroup; children = ( + 5CC852A02BDED9970039FFC5 /* Highlight */, 4CA927682A290F8F0098A105 /* Components */, 4CC7AAEF297F11C700430951 /* SelectedEventView.swift */, 4CC7AAF5297F1A6A00430951 /* EventBody.swift */, @@ -2699,6 +2714,18 @@ path = Images; sourceTree = "<group>"; }; + 5CC852A02BDED9970039FFC5 /* Highlight */ = { + isa = PBXGroup; + children = ( + 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */, + 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */, + 5CC852A32BDF3CA10039FFC5 /* HighlightLink.swift */, + 5CC852A52BE00F180039FFC5 /* HighlightEventRef.swift */, + 5C4D9EA62C042FA5005EA0F7 /* HighlightPostView.swift */, + ); + path = Highlight; + sourceTree = "<group>"; + }; 7C0F392D29B57C8F0039859C /* Extensions */ = { isa = PBXGroup; children = ( @@ -3153,6 +3180,7 @@ 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */, D7EDED1E2B11797D0018B19C /* LongformEvent.swift in Sources */, 504323A92A3495B6006AE6DC /* RelayModelCache.swift in Sources */, + 5C4D9EA72C042FA5005EA0F7 /* HighlightPostView.swift in Sources */, 3A8CC6CC2A2CFEF900940F5F /* StringUtil.swift in Sources */, D7FD12262BD345A700CF195B /* FirstAidSettingsView.swift in Sources */, D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */, @@ -3183,6 +3211,7 @@ 4C32B94D2A9AD44700DC3548 /* Offset.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, + 5CC852A42BDF3CA10039FFC5 /* HighlightLink.swift in Sources */, 4C32B9552A9AD44700DC3548 /* ByteBuffer.swift in Sources */, 4C32B95B2A9AD44700DC3548 /* NativeObject.swift in Sources */, 3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */, @@ -3322,6 +3351,7 @@ 4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */, 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */, 4C285C8C28398BC7008A31F1 /* Keys.swift in Sources */, + 5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */, 4C94D6432BA5AEFE00C26EFF /* QuoteRepostsView.swift in Sources */, D7EDED332B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */, 4CA352AE2A76C1AC003BB08B /* FollowedNotify.swift in Sources */, @@ -3365,7 +3395,9 @@ 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayer.swift in Sources */, 4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */, + 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */, BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */, + 5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */, 4C9146FD2A2A87C200DDEA40 /* wasm.c in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, @@ -3477,6 +3509,7 @@ B51C1CEB2B55A60A00E312A9 /* MuteDurationMenu.swift in Sources */, 4CB88389296AF99A00DC99E7 /* EventDetailBar.swift in Sources */, 4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */, + 5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */, 4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */, 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */, 4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */, diff --git a/damus/Assets.xcassets/Colors/DamusHighlight.colorset/Contents.json b/damus/Assets.xcassets/Colors/DamusHighlight.colorset/Contents.json @@ -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 + } +} diff --git a/damus/Components/DamusColors.swift b/damus/Components/DamusColors.swift @@ -28,6 +28,7 @@ class DamusColors { static let green = Color("DamusGreen") static let purple = Color("DamusPurple") static let deepPurple = Color("DamusDeepPurple") + static let highlight = Color("DamusHighlight") static let blue = Color("DamusBlue") static let bitcoin = Color("Bitcoin") static let success = Color("DamusSuccessPrimary") diff --git a/damus/Components/SelectableText.swift b/damus/Components/SelectableText.swift @@ -9,16 +9,20 @@ import UIKit import SwiftUI struct SelectableText: View { - + let damus_state: DamusState + let event: NostrEvent? let attributedString: AttributedString let textAlignment: NSTextAlignment - + @State private var showHighlightPost = false + @State private var selectedText = "" @State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero - + let size: EventViewKind - - init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { + + init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { + self.damus_state = damus_state + self.event = event self.attributedString = attributedString self.textAlignment = textAlignment ?? NSTextAlignment.natural self.size = size @@ -32,6 +36,9 @@ struct SelectableText: View { font: eventviewsize_to_uifont(size), fixedWidth: selectedTextWidth, textAlignment: self.textAlignment, + enableHighlighting: self.enableHighlighting(), + showHighlightPost: $showHighlightPost, + selectedText: $selectedText, height: $selectedTextHeight ) .padding([.leading, .trailing], -1.0) @@ -46,8 +53,48 @@ struct SelectableText: View { self.selectedTextWidth = newSize.width } } + .sheet(isPresented: $showHighlightPost) { + if let event { + HighlightPostView(damus_state: damus_state, event: event, selectedText: $selectedText) + .presentationDragIndicator(.visible) + .presentationDetents([.height(selectedTextHeight + 150), .medium, .large]) + } + } .frame(height: selectedTextHeight) } + + func enableHighlighting() -> Bool { + self.event != nil + } +} + +fileprivate class TextView: UITextView { + @Binding var showHighlightPost: Bool + @Binding var selectedText: String + + init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, selectedText: Binding<String>) { + self._showHighlightPost = showHighlightPost + self._selectedText = selectedText + super.init(frame: frame, textContainer: textContainer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(highlightText(_:)) { + return true + } + return super.canPerformAction(action, withSender: sender) + } + + @objc public func highlightText(_ sender: Any?) { + guard let selectedRange = self.selectedTextRange else { return } + selectedText = self.text(in: selectedRange) ?? "" + showHighlightPost.toggle() + } + } fileprivate struct TextViewRepresentable: UIViewRepresentable { @@ -57,11 +104,13 @@ struct SelectableText: View { let font: UIFont let fixedWidth: CGFloat let textAlignment: NSTextAlignment - + let enableHighlighting: Bool + @Binding var showHighlightPost: Bool + @Binding var selectedText: String @Binding var height: CGFloat - func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView { - let view = UITextView() + func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView { + let view = TextView(frame: .zero, textContainer: nil, showHighlightPost: $showHighlightPost, selectedText: $selectedText) view.isEditable = false view.dataDetectorTypes = .all view.isSelectable = true @@ -71,10 +120,15 @@ struct SelectableText: View { view.textContainerInset.left = 1.0 view.textContainerInset.right = 1.0 view.textAlignment = textAlignment + + let menuController = UIMenuController.shared + let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:))) + menuController.menuItems = self.enableHighlighting ? [highlightItem] : [] + return view } - func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) { + func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) { let mutableAttributedString = createNSAttributedString() uiView.attributedText = mutableAttributedString uiView.textAlignment = self.textAlignment diff --git a/damus/Components/TranslateView.swift b/damus/Components/TranslateView.swift @@ -51,9 +51,9 @@ struct TranslateView: View { .foregroundColor(.gray) .font(.footnote) .padding([.top, .bottom], 10) - + if self.size == .selected { - SelectableText(attributedString: artifacts.content.attributed, size: self.size) + SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size) } else { artifacts.content.text .font(eventviewsize_to_font(self.size, font_size: font_size)) diff --git a/damus/Models/HighlightEvent.swift b/damus/Models/HighlightEvent.swift @@ -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 + } +} diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -184,7 +184,7 @@ class HomeModel: ContactsDelegate { } switch kind { - case .chat, .longform, .text: + case .chat, .longform, .text, .highlight: handle_text_event(sub_id: sub_id, ev) case .contacts: 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) { // TODO: separate likes? var home_filter_kinds: [NostrKind] = [ - .text, .longform, .boost + .text, .longform, .boost, .highlight ] if !damus_state.settings.onlyzaps_mode { home_filter_kinds.append(.like) diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -60,11 +60,11 @@ class ProfileModel: ObservableObject, Equatable { damus.pool.unsubscribe(sub_id: sub_id) damus.pool.unsubscribe(sub_id: prof_subid) } - + 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]) - + profile_filter.authors = [pubkey] text_filter.authors = [pubkey] diff --git a/damus/Models/SearchModel.swift b/damus/Models/SearchModel.swift @@ -36,7 +36,7 @@ class SearchModel: ObservableObject { func subscribe() { // since 1 month search.limit = self.limit - search.kinds = [.text, .like, .longform] + search.kinds = [.text, .like, .longform, .highlight] //likes_filter.ids = ref_events.referenced_ids! diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift @@ -22,6 +22,7 @@ enum NostrKind: UInt32, Codable { case longform = 30023 case zap = 9735 case zap_request = 9734 + case highlight = 9802 case nwc_request = 23194 case nwc_response = 23195 case http_auth = 27235 diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -45,6 +45,8 @@ struct EventView: View { } } else if event.known_kind == .longform { LongformPreview(state: damus, ev: event, options: options) + } else if event.known_kind == .highlight { + HighlightView(state: damus, event: event, options: options) } else { TextEvent(damus: damus, event: event, pubkey: pubkey, options: options) //.padding([.top], 6) diff --git a/damus/Views/Events/Components/ReplyPart.swift b/damus/Views/Events/Components/ReplyPart.swift @@ -16,7 +16,15 @@ struct ReplyPart: View { var body: some View { Group { 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 { EmptyView() } diff --git a/damus/Views/Events/EventBody.swift b/damus/Views/Events/EventBody.swift @@ -35,6 +35,8 @@ struct EventBody: View { if !options.contains(.truncate_content) { note_content } + } else if event.known_kind == .highlight { + HighlightBodyView(state: damus_state, ev: event, options: options) } else { note_content } diff --git a/damus/Views/Events/Highlight/HighlightDescription.swift b/damus/Views/Events/Highlight/HighlightDescription.swift @@ -0,0 +1,53 @@ +// +// 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 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 ?? "") +} diff --git a/damus/Views/Events/Highlight/HighlightEventRef.swift b/damus/Views/Events/Highlight/HighlightEventRef.swift @@ -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() + } + } + } + } +} diff --git a/damus/Views/Events/Highlight/HighlightLink.swift b/damus/Views/Events/Highlight/HighlightLink.swift @@ -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: "") + } + } +} diff --git a/damus/Views/Events/Highlight/HighlightPostView.swift b/damus/Views/Events/Highlight/HighlightPostView.swift @@ -0,0 +1,78 @@ +// +// HighlightPostView.swift +// damus +// +// Created by eric on 5/26/24. +// + +import SwiftUI + +struct HighlightPostView: View { + let damus_state: DamusState + let event: NostrEvent + @Binding var selectedText: String + + @Environment(\.dismiss) var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack { + HStack(spacing: 5.0) { + Button(action: { + dismiss() + }, label: { + Text("Cancel", comment: "Button to cancel out of highlighting a note.") + .padding(10) + }) + .buttonStyle(NeutralButtonStyle()) + + Spacer() + + Button(NSLocalizedString("Post", comment: "Button to post a highlight.")) { + var tags: [[String]] = [ ["e", "\(self.event.id)"] ] + tags.append(["context", self.event.content]) + + let kind = NostrKind.highlight.rawValue + guard let ev = NostrEvent(content: selectedText, keypair: damus_state.keypair, kind: kind, tags: tags) else { + return + } + damus_state.postbox.send(ev) + dismiss() + } + .bold() + .buttonStyle(GradientButtonStyle(padding: 10)) + } + + Divider() + .foregroundColor(DamusColors.neutral3) + .padding(.top, 5) + } + .frame(height: 30) + .padding() + .padding(.top, 15) + + HStack { + var attributedString: AttributedString { + var attributedString = AttributedString(self.event.content) + + if let range = attributedString.range(of: selectedText) { + attributedString[range].backgroundColor = DamusColors.highlight + } + + return attributedString + } + + Text(attributedString) + .lineSpacing(5) + .padding(10) + } + .overlay( + RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4), + alignment: .leading + ) + .padding() + + Spacer() + } + } +} diff --git a/damus/Views/Events/Highlight/HighlightView.swift b/damus/Views/Events/Highlight/HighlightView.swift @@ -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]) + } + } +} diff --git a/damus/Views/Events/Longform/LongformView.swift b/damus/Views/Events/Longform/LongformView.swift @@ -21,10 +21,10 @@ struct LongformView: View { var options: EventViewOptions { return [.wide, .no_mentions, .no_replying_to] } - + var body: some View { EventShell(state: state, event: event.event, options: options) { - SelectableText(attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title) + SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title) NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options) } diff --git a/damus/Views/Events/SelectedEventView.swift b/damus/Views/Events/SelectedEventView.swift @@ -41,7 +41,16 @@ struct SelectedEventView: View { .lineLimit(1) 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) } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -132,10 +132,10 @@ struct NoteContentView: View { VStack(alignment: .leading) { if size == .selected { if with_padding { - SelectableText(attributedString: artifacts.content.attributed, size: self.size) + SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size) .padding(.horizontal) } else { - SelectableText(attributedString: artifacts.content.attributed, size: self.size) + SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size) } } else { if with_padding { diff --git a/damus/Views/Profile/AboutView.swift b/damus/Views/Profile/AboutView.swift @@ -26,7 +26,7 @@ struct AboutView: View { Group { if let about_string { let truncated_about = show_full_about ? about_string : about_string.truncateOrNil(maxLength: max_about_length) - SelectableText(attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline) + SelectableText(damus_state: state, event: nil, attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline) if truncated_about != nil { if show_full_about { diff --git a/nostrdb/NdbNote+.swift b/nostrdb/NdbNote+.swift @@ -14,7 +14,7 @@ extension 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 } diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift @@ -277,7 +277,7 @@ class NdbNote: Encodable, Equatable, Hashable { // Extension to make NdbNote compatible with NostrEvent's original API extension NdbNote { 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? {