Files
damus/damus/Views/NoteContentView.swift
William Casarin bfad2ab42d ndb/txn: make transactions failable
Since there may be situations where we close and re-open the database,
we need to make sure transactions fail when the database is not open.

Make NdbTxn an init?() constructor and check for ndb.closed. If it's
closed, then fail transaction construction.

This fixes crashes during high database activity when switching from
background to foreground and vice-versa.

Fixes: da2bdad18d ("nostrdb: close database when backgrounded")
2024-01-10 14:27:02 -08:00

390 lines
13 KiB
Swift

//
// NoteContentView.swift
// damus
//
// Created by William Casarin on 2022-05-04.
//
import SwiftUI
import LinkPresentation
import NaturalLanguage
import MarkdownUI
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial
func makeUIView(context: Context) -> UIVisualEffectView {
return UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
struct NoteContentView: View {
let damus_state: DamusState
let event: NostrEvent
@State var blur_images: Bool
@State var load_media: Bool = false
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
@ObservedObject var artifacts_model: NoteArtifactsModel
@ObservedObject var preview_model: PreviewModel
@ObservedObject var settings: UserSettingsStore
var note_artifacts: NoteArtifacts {
return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
}
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) {
self.damus_state = damus_state
self.event = event
self.blur_images = blur_images
self.size = size
self.options = options
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
let cached = damus_state.events.get_cache_data(event.id)
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model)
self._settings = ObservedObject(wrappedValue: damus_state.settings)
}
var truncate: Bool {
return options.contains(.truncate_content)
}
var with_padding: Bool {
return options.contains(.wide)
}
var preview: LinkViewRepresentable? {
guard blur_images,
case .loaded(let preview) = preview_model.state,
case .value(let cached) = preview else {
return nil
}
return LinkViewRepresentable(meta: .linkmeta(cached))
}
func truncatedText(content: CompatibleText) -> some View {
Group {
if truncate {
TruncatedText(text: content)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
} else {
content.text
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
}
}
func invoicesView(invoices: [Invoice]) -> some View {
InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: invoices, settings: damus_state.settings)
}
var translateView: some View {
TranslateView(damus_state: damus_state, event: event, size: self.size)
}
func previewView(links: [URL]) -> some View {
Group {
if let preview = self.preview, blur_images {
if let preview_height {
preview
.frame(height: preview_height)
} else {
preview
}
} else if let link = links.first {
LinkViewRepresentable(meta: .url(link))
.frame(height: 50)
}
}
}
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
VStack(alignment: .leading) {
if size == .selected {
if with_padding {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
.padding(.horizontal)
} else {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
}
} else {
if with_padding {
truncatedText(content: artifacts.content)
.padding(.horizontal)
} else {
truncatedText(content: artifacts.content)
}
}
if !options.contains(.no_translate) && (size == .selected || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
} else {
translateView
}
}
if artifacts.media.count > 0 {
if !damus_state.settings.media_previews && !load_media {
loadMediaButton(artifacts: artifacts)
} else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
} else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
Blur()
.onTapGesture {
blur_images = false
}
}
}
}
if artifacts.invoices.count > 0 {
if with_padding {
invoicesView(invoices: artifacts.invoices)
.padding(.horizontal)
} else {
invoicesView(invoices: artifacts.invoices)
}
}
if damus_state.settings.media_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
previewView(links: artifacts.links)
}
}
}
}
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
Button(action: {
load_media = true
}, label: {
VStack(alignment: .leading) {
HStack {
Image("images")
Text("Load media", comment: "Button to show media in note.")
.fontWeight(.bold)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
ForEach(artifacts.media.indices, id: \.self) { index in
Divider()
.frame(height: 1)
switch artifacts.media[index] {
case .image(let url), .video(let url):
Text(url.absoluteString)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
.foregroundStyle(DamusColors.neutral6)
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
}
}
.background(DamusColors.neutral1)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
})
.padding(.horizontal)
}
func load(force_artifacts: Bool = false) {
if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
return
}
// always reload artifacts on load
let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
// TODO: make this cleaner
Task {
// this is surprisingly slow
let rel = format_relative_time(event.created_at)
Task { @MainActor in
self.damus_state.events.get_cache_data(event.id).relative_time.value = rel
}
if var plan {
if force_artifacts {
plan.load_artifacts = true
}
await preload_event(plan: plan, state: damus_state)
} else if force_artifacts {
let arts = render_note_content(ev: event, profiles: damus_state.profiles, keypair: damus_state.keypair)
self.artifacts_model.state = .loaded(arts)
}
}
}
func artifactPartsView(_ parts: [ArtifactPart]) -> some View {
LazyVStack(alignment: .leading) {
ForEach(parts.indices, id: \.self) { ind in
let part = parts[ind]
switch part {
case .text(let txt):
if with_padding {
txt.padding(.horizontal)
} else {
txt
}
case .invoice(let inv):
if with_padding {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
.padding(.horizontal)
} else {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
}
case .media(let media):
Text(verbatim: "media \(media.url.absoluteString)")
}
}
}
}
var ArtifactContent: some View {
Group {
switch self.note_artifacts {
case .longform(let md):
Markdown(md.markdown)
.padding([.leading, .trailing, .top])
case .separated(let separated):
MainContent(artifacts: separated)
}
}
.fixedSize(horizontal: false, vertical: true)
}
var body: some View {
ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in
let blocks = event.blocks(damus_state.keypair)
for block in blocks.blocks {
switch block {
case .mention(let m):
if case .pubkey(let pk) = m.ref, pk == profile.pubkey {
load(force_artifacts: true)
return
}
case .relay: return
case .text: return
case .hashtag: return
case .url: return
case .invoice: return
}
}
}
.onAppear {
load()
}
}
}
class NoteArtifactsParts {
var parts: [ArtifactPart]
var words: Int
init(parts: [ArtifactPart], words: Int) {
self.parts = parts
self.words = words
}
}
enum ArtifactPart {
case text(Text)
case media(MediaUrl)
case invoice(Invoice)
var is_text: Bool {
switch self {
case .text: return true
case .media: return false
case .invoice: return false
}
}
}
fileprivate func artifact_part_last_text_ind(parts: [ArtifactPart]) -> (Int, Text)? {
let ind = parts.count - 1
if ind < 0 {
return nil
}
guard case .text(let txt) = parts[safe: ind] else {
return nil
}
return (ind, txt)
}
func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat? {
guard case .value(let cached) = previews.lookup(evid) else {
return nil
}
guard let height = cached.intrinsic_height else {
return nil
}
return height
}
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
let state2 = test_damus_state
Group {
VStack {
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Short note")
VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Note with image")
VStack {
NoteContentView(damus_state: state2, event: test_longform_event.event, blur_images: false, size: .normal, options: [.wide])
.border(Color.red)
}
.previewDisplayName("Long-form note")
}
}
}
func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let urlBlocks: [URL] = ev.blocks(keypair).blocks.reduce(into: []) { urls, block in
guard case .url(let url) = block else {
return
}
if classify_url(url).is_img != nil {
urls.append(url)
}
}
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
}