diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index cadf9fb..3e2e209 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -21,8 +21,8 @@ final class AppCoordinator { let isDemoMode: Bool /// `UserDefaults` key the status-menu toggle flips; read at launch to enter demo mode. static let demoModeKey = "demoMode" - private(set) lazy var shelfViewModel = ShelfViewModel(store: store, pinboardStore: pinboardStore, settings: settings) - private(set) lazy var linkFetcher = LinkMetadataFetcher(store: store) + private(set) lazy var shelfViewModel = ShelfViewModel(store: store, pinboardStore: pinboardStore, settings: settings, linkFetcher: linkFetcher) + private(set) var linkFetcher: LinkMetadataFetcher private(set) lazy var ocrController = OCRController(store: store) private(set) lazy var archiveController = ArchiveController(store: store, pinboardStore: pinboardStore) diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index 086c143..63479a7 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -444,36 +444,21 @@ struct ItemCardView: View { case .text, .richText: textBody case .link: - if let linkTitle = item.linkTitle { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - LinkFaviconView(item: item, store: store) - Text(URL(string: item.plainText ?? "")?.host ?? "Link") - .font(Tokens.cardSubtitle) - .lineLimit(1) - } - Text(linkTitle) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + LinkFaviconView(item: item, store: store) + Text(item.linkTitle ?? URL(string: item.plainText ?? "")?.host ?? "Link") .font(.system(size: 13, weight: .semibold)) .lineLimit(bodyLineLimit(standard: 2, compact: 1)) .multilineTextAlignment(.leading) - Text(String((item.plainText ?? "").prefix(1_500))) - .font(Tokens.bodyMono) - .foregroundStyle(.secondary) - .lineLimit(bodyLineLimit(standard: 2, compact: 1)) - } - } else { - VStack(alignment: .leading, spacing: 4) { - Image(systemName: "link") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - Text(URL(string: item.plainText ?? "")?.host ?? "Link") - .font(Tokens.cardSubtitle) - .lineLimit(1) - Text(String((item.plainText ?? "").prefix(1_500))) - .font(Tokens.bodyMono) - .foregroundStyle(.secondary) - .lineLimit(bodyLineLimit(standard: 5, compact: 3)) } + // Give the URL the remaining body lines while keeping the footer visible. + Text(String((item.plainText ?? "").prefix(1_500))) + .font(Tokens.bodyMono) + .foregroundStyle(.secondary) + .lineLimit(bodyLineLimit(standard: 8, compact: 3)) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) } case .image: imageBody @@ -550,12 +535,20 @@ struct LinkFaviconView: View { } } .frame(width: 16, height: 16) - .onAppear { - if image == nil { - image = FaviconCache.shared.cached(for: item) - if image == nil { - FaviconCache.shared.favicon(for: item, store: store) { image = $0 } - } + .onAppear(perform: loadImage) + // Metadata persistence updates the item title after writing its favicon. The + // view may already be mounted by then, so `onAppear` alone would leave its + // earlier nil lookup stuck until the app relaunched. + .onChange(of: item.linkTitle) { _, _ in loadImage() } + } + + private func loadImage() { + guard image == nil else { return } + image = FaviconCache.shared.cached(for: item) + if image == nil { + FaviconCache.shared.favicon(for: item, store: store) { loaded in + // An older miss must not overwrite a newer successful lookup. + if let loaded { image = loaded } } } } diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 7e5889d..d0dbd5c 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -540,7 +540,13 @@ private struct ShelfItemsRow: View { // The LazyHStack only builds a card once it scrolls into view, // so this fires as the user nears the oldest card and widens the // fetch window. Without it the shelf stopped at the first page. - .onAppear { viewModel.loadMoreIfNeeded(at: index) } + .onAppear { + viewModel.loadMoreIfNeeded(at: index) + viewModel.fetchLinkPreviewIfNeeded(for: item) + } + .onChange(of: viewModel.settings.fetchLinkPreviews) { _, enabled in + if enabled { viewModel.fetchLinkPreviewIfNeeded(for: item) } + } .popover(isPresented: Binding( get: { viewModel.isSelected(item) && item.uuid == viewModel.selection.primary && viewModel.previewShown }, set: { viewModel.previewShown = $0 } diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 625b7c7..da0721b 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -27,6 +27,7 @@ final class ShelfViewModel { /// `viewModel.settings.compactShelf` re-renders on toggle without any extra /// change-hook plumbing (the pattern `AppCoordinator` uses for AppKit-side settings /// like `hideDuringScreenSharing` doesn't apply here since this is pure SwiftUI). + private let linkFetcher: LinkMetadataFetcher let settings: SettingsStore var items: [ClipItem] = [] @@ -159,10 +160,11 @@ final class ShelfViewModel { /// every item on the board, so there's nothing to page there. @ObservationIgnored private var isPaged = true - init(store: ItemStore, pinboardStore: PinboardStore, settings: SettingsStore) { + init(store: ItemStore, pinboardStore: PinboardStore, settings: SettingsStore, linkFetcher: LinkMetadataFetcher) { self.store = store self.pinboardStore = pinboardStore self.settings = settings + self.linkFetcher = linkFetcher pinboardsToken = pinboardStore.observeAll( onError: { NSLog("Copy: pinboard observation failed: \($0)") }, onChange: { [weak self] in self?.pinboards = $0 }) @@ -174,6 +176,10 @@ final class ShelfViewModel { return items.first(where: { $0.uuid == uuid }) } + func fetchLinkPreviewIfNeeded(for item: ClipItem) { + linkFetcher.fetchIfNeeded(for: item, enabled: settings.fetchLinkPreviews) + } + func isSelected(_ item: ClipItem) -> Bool { selection.selected.contains(item.uuid) }