damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

NoteContentView.swift (15967B)


      1 //
      2 //  NoteContentView.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-05-04.
      6 //
      7 
      8 import SwiftUI
      9 import LinkPresentation
     10 import NaturalLanguage
     11 import MarkdownUI
     12 import Translation
     13 
     14 struct Blur: UIViewRepresentable {
     15     var style: UIBlurEffect.Style = .systemUltraThinMaterial
     16 
     17     func makeUIView(context: Context) -> UIVisualEffectView {
     18         return UIVisualEffectView(effect: UIBlurEffect(style: style))
     19     }
     20 
     21     func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
     22         uiView.effect = UIBlurEffect(style: style)
     23     }
     24 }
     25 
     26 struct NoteContentView: View {
     27     
     28     let damus_state: DamusState
     29     let event: NostrEvent
     30     @State var blur_images: Bool
     31     @State var load_media: Bool = false
     32     let size: EventViewKind
     33     let preview_height: CGFloat?
     34     let options: EventViewOptions
     35 
     36     @State var isAppleTranslationPopoverPresented: Bool = false
     37 
     38     @ObservedObject var artifacts_model: NoteArtifactsModel
     39     @ObservedObject var preview_model: PreviewModel
     40     @ObservedObject var settings: UserSettingsStore
     41 
     42     var note_artifacts: NoteArtifacts {
     43         return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
     44     }
     45     
     46     init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) {
     47         self.damus_state = damus_state
     48         self.event = event
     49         self.blur_images = blur_images
     50         self.size = size
     51         self.options = options
     52         self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
     53         let cached = damus_state.events.get_cache_data(event.id)
     54         self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
     55         self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model)
     56         self._settings = ObservedObject(wrappedValue: damus_state.settings)
     57     }
     58     
     59     var truncate: Bool {
     60         return options.contains(.truncate_content)
     61     }
     62     
     63     var truncate_very_short: Bool {
     64         return options.contains(.truncate_content_very_short)
     65     }
     66     
     67     var with_padding: Bool {
     68         return options.contains(.wide)
     69     }
     70     
     71     var preview: LinkViewRepresentable? {
     72         guard !blur_images,
     73               case .loaded(let preview) = preview_model.state,
     74               case .value(let cached) = preview else {
     75             return nil
     76         }
     77         
     78         return LinkViewRepresentable(meta: .linkmeta(cached))
     79     }
     80     
     81     func truncatedText(content: CompatibleText) -> some View {
     82         Group {
     83             if truncate_very_short {
     84                 TruncatedText(text: content, maxChars: 140, show_show_more_button: !options.contains(.no_show_more))
     85                     .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
     86             }
     87             else if truncate {
     88                 TruncatedText(text: content, show_show_more_button: !options.contains(.no_show_more))
     89                     .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
     90             } else {
     91                 content.text
     92                     .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
     93             }
     94         }
     95     }
     96     
     97     func invoicesView(invoices: [Invoice]) -> some View {
     98         InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: invoices, settings: damus_state.settings)
     99     }
    100 
    101     var translateView: some View {
    102         TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
    103     }
    104     
    105     func previewView(links: [URL]) -> some View {
    106         Group {
    107             if let preview = self.preview, !blur_images {
    108                 if let preview_height {
    109                     preview
    110                         .frame(height: preview_height)
    111                 } else {
    112                     preview
    113                 }
    114             } else if let link = links.first {
    115                 LinkViewRepresentable(meta: .url(link))
    116                     .frame(height: 50)
    117             }
    118         }
    119     }
    120     
    121     func fullscreen_preview(dismiss: @escaping () -> Void) -> some View {
    122         VStack {
    123             EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
    124                 .padding(.top)
    125         }
    126         .background(.thickMaterial)
    127         .onTapGesture(perform: {
    128             damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
    129             dismiss()
    130         })
    131     }
    132     
    133     func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
    134         VStack(alignment: .leading) {
    135             if size == .selected {
    136                 if with_padding {
    137                     SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
    138                         .padding(.horizontal)
    139                 } else {
    140                     SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
    141                 }
    142             } else {
    143                 if with_padding {
    144                     truncatedText(content: artifacts.content)
    145                         .padding(.horizontal)
    146                 } else {
    147                     truncatedText(content: artifacts.content)
    148                 }
    149             }
    150 
    151             if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
    152                 if with_padding {
    153                     translateView
    154                         .padding(.horizontal)
    155                 } else {
    156                     translateView
    157                 }
    158             }
    159 
    160             if artifacts.media.count > 0 {
    161                 if (self.options.contains(.no_media)) {
    162                     EmptyView()
    163                 } else if !damus_state.settings.media_previews && !load_media {
    164                     loadMediaButton(artifacts: artifacts)
    165                 } else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) {
    166                     ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
    167                         fullscreen_preview(dismiss: dismiss)
    168                     }
    169                 } else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) {
    170                     ZStack {
    171                         ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
    172                             fullscreen_preview(dismiss: dismiss)
    173                         }
    174                         Blur()
    175                             .onTapGesture {
    176                                 blur_images = false
    177                             }
    178                     }
    179                 }
    180             }
    181             
    182             if artifacts.invoices.count > 0 {
    183                 if with_padding {
    184                     invoicesView(invoices: artifacts.invoices)
    185                         .padding(.horizontal)
    186                 } else {
    187                     invoicesView(invoices: artifacts.invoices)
    188                 }
    189             }
    190 
    191             if damus_state.settings.media_previews, has_previews {
    192                 if with_padding {
    193                     previewView(links: artifacts.links).padding(.horizontal)
    194                 } else {
    195                     previewView(links: artifacts.links)
    196                 }
    197             }
    198 
    199         }
    200     }
    201 
    202     var has_previews: Bool {
    203         !options.contains(.no_previews)
    204     }
    205 
    206     func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
    207         Button(action: {
    208             load_media = true
    209         }, label: {
    210             VStack(alignment: .leading) {
    211                 HStack {
    212                     Image("images")
    213                     Text("Load media", comment: "Button to show media in note.")
    214                         .fontWeight(.bold)
    215                         .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
    216                 }
    217                 .padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
    218                 
    219                 ForEach(artifacts.media.indices, id: \.self) { index in
    220                     Divider()
    221                         .frame(height: 1)
    222                     switch artifacts.media[index] {
    223                     case .image(let url), .video(let url):
    224                         Text(abbreviateURL(url))
    225                             .font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
    226                             .foregroundStyle(DamusColors.neutral6)
    227                             .multilineTextAlignment(.leading)
    228                             .padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
    229                     }
    230                 }
    231             }
    232             .background(DamusColors.neutral1)
    233             .frame(minWidth: nil, maxWidth: .infinity, alignment: .center)
    234             .cornerRadius(8)
    235             .overlay(
    236                 RoundedRectangle(cornerRadius: 8)
    237                     .stroke(DamusColors.neutral3, lineWidth: 1)
    238             )
    239         })
    240         .padding(.horizontal)
    241     }
    242     
    243     func load(force_artifacts: Bool = false) {
    244         if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
    245             return
    246         }
    247         
    248         // always reload artifacts on load
    249         let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
    250         
    251         // TODO: make this cleaner
    252         Task {
    253             // this is surprisingly slow
    254             let rel = format_relative_time(event.created_at)
    255             Task { @MainActor in
    256                 self.damus_state.events.get_cache_data(event.id).relative_time.value = rel
    257             }
    258             
    259             if var plan {
    260                 if force_artifacts {
    261                     plan.load_artifacts = true
    262                 }
    263                 await preload_event(plan: plan, state: damus_state)
    264             } else if force_artifacts {
    265                 let arts = render_note_content(ev: event, profiles: damus_state.profiles, keypair: damus_state.keypair)
    266                 self.artifacts_model.state = .loaded(arts)
    267             }
    268         }
    269     }
    270     
    271     func artifactPartsView(_ parts: [ArtifactPart]) -> some View {
    272         
    273         LazyVStack(alignment: .leading) {
    274             ForEach(parts.indices, id: \.self) { ind in
    275                 let part = parts[ind]
    276                 switch part {
    277                 case .text(let txt):
    278                     if with_padding {
    279                         txt.padding(.horizontal)
    280                     } else {
    281                         txt
    282                     }
    283                 case .invoice(let inv):
    284                     if with_padding {
    285                         InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
    286                             .padding(.horizontal)
    287                     } else {
    288                         InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
    289                     }
    290                 case .media(let media):
    291                     Text(verbatim: "media \(media.url.absoluteString)")
    292                 }
    293             }
    294         }
    295     }
    296     
    297     var ArtifactContent: some View {
    298         Group {
    299             switch self.note_artifacts {
    300             case .longform(let md):
    301                 Markdown(md.markdown)
    302                     .padding([.leading, .trailing, .top])
    303             case .separated(let separated):
    304                 if #available(iOS 17.4, macOS 14.4, *) {
    305                     MainContent(artifacts: separated)
    306 #if !targetEnvironment(macCatalyst)
    307                         .translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
    308 #endif
    309                 } else {
    310                     MainContent(artifacts: separated)
    311                 }
    312             }
    313         }
    314         .fixedSize(horizontal: false, vertical: true)
    315     }
    316     
    317     var body: some View {
    318         ArtifactContent
    319             .onReceive(handle_notify(.profile_updated)) { profile in
    320                 let blocks = event.blocks(damus_state.keypair)
    321                 for block in blocks.blocks {
    322                     switch block {
    323                     case .mention(let m):
    324                         if case .pubkey(let pk) = m.ref, pk == profile.pubkey {
    325                             load(force_artifacts: true)
    326                             return
    327                         }
    328                     case .relay: return
    329                     case .text: return
    330                     case .hashtag: return
    331                     case .url: return
    332                     case .invoice: return
    333                     }
    334                 }
    335             }
    336             .onAppear {
    337                 load()
    338             }
    339     }
    340     
    341 }
    342 
    343 class NoteArtifactsParts {
    344     var parts: [ArtifactPart]
    345     var words: Int
    346 
    347     init(parts: [ArtifactPart], words: Int) {
    348         self.parts = parts
    349         self.words = words
    350     }
    351 }
    352 
    353 enum ArtifactPart {
    354     case text(Text)
    355     case media(MediaUrl)
    356     case invoice(Invoice)
    357     
    358     var is_text: Bool {
    359         switch self {
    360         case .text:    return true
    361         case .media:   return false
    362         case .invoice: return false
    363         }
    364     }
    365 }
    366 
    367 fileprivate func artifact_part_last_text_ind(parts: [ArtifactPart]) -> (Int, Text)? {
    368     let ind = parts.count - 1
    369     if ind < 0 {
    370         return nil
    371     }
    372     
    373     guard case .text(let txt) = parts[safe: ind] else {
    374         return nil
    375     }
    376     
    377     return (ind, txt)
    378 }
    379 
    380 func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat? {
    381     guard case .value(let cached) = previews.lookup(evid) else {
    382         return nil
    383     }
    384     
    385     guard let height = cached.intrinsic_height else {
    386         return nil
    387     }
    388     
    389     return height
    390 }
    391 
    392 struct NoteContentView_Previews: PreviewProvider {
    393     static var previews: some View {
    394         let state = test_damus_state
    395         let state2 = test_damus_state
    396 
    397         Group {
    398             VStack {
    399                 NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
    400             }
    401             .previewDisplayName("Short note")
    402 
    403             VStack {
    404                 NoteContentView(damus_state: state, event: test_super_short_note, blur_images: true, size: .normal, options: [])
    405             }
    406             .previewDisplayName("Super short note")
    407 
    408             VStack {
    409                 NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
    410             }
    411             .previewDisplayName("Note with image")
    412 
    413             VStack {
    414                 NoteContentView(damus_state: state2, event: test_longform_event.event, blur_images: false, size: .normal, options: [.wide])
    415                     .border(Color.red)
    416             }
    417             .previewDisplayName("Long-form note")
    418             
    419             VStack {
    420                 NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .small, options: [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more])
    421                     .font(.callout)
    422                     .foregroundColor(.secondary)
    423                     .lineLimit(1)
    424             }
    425             .previewDisplayName("Small single-line note")
    426         }
    427     }
    428 }
    429 
    430 func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
    431     let urlBlocks: [URL] = ev.blocks(keypair).blocks.reduce(into: []) { urls, block in
    432         guard case .url(let url) = block else {
    433             return
    434         }
    435         if classify_url(url).is_img != nil {
    436             urls.append(url)
    437         }
    438     }
    439     let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
    440     return mediaUrls.isEmpty ? nil : mediaUrls
    441 }
    442