damus

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

NoteContentView.swift (15800B)


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