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