damus

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

NoteContentView.swift (14557B)


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