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