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