diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ff488922..13608028 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -122,21 +122,7 @@ class ChatViewModel: ObservableObject { .sink { [weak self] (messageID, status) in self?.updateMessageDeliveryStatus(messageID, status: status) } - - // Show welcome message after delay if still no peers - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in - guard let self = self else { return } - if self.connectedPeers.isEmpty && self.messages.isEmpty { - let welcomeMessage = BitchatMessage( - sender: "system", - content: "get people around you to download bitchat…and chat with them here!", - timestamp: Date(), - isRelay: false - ) - self.messages.append(welcomeMessage) - } - } - + // When app becomes active, send read receipts for visible messages #if os(macOS) NotificationCenter.default.addObserver( @@ -2061,12 +2047,7 @@ class ChatViewModel: ObservableObject { } else { // System message var contentStyle = AttributeContainer() - // Check for welcome message - if message.content.contains("get people around you to download bitchat") { - contentStyle.foregroundColor = Color.blue - } else { - contentStyle.foregroundColor = Color.gray - } + contentStyle.foregroundColor = Color.gray let content = AttributedString("* \(message.content) *") contentStyle.font = .system(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 9ff74b88..f6b673d0 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -16,6 +16,59 @@ struct AppInfoView: View { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } + // MARK: - Constants + private enum Strings { + static let appName = "bitchat" + static let tagline = "mesh sidegroupchat" + + enum Features { + static let title = "FEATURES" + static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth mesh networking") + static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol") + static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, increasing the distance") + static let favorites = ("star.fill", "favorites", "store-and-forward messages for favorite people") + static let mentions = ("at", "mentions", "use @nickname to notify specific people") + static let channels = ("number", "channels", "create #channels for topic-based conversations") + static let privateChannels = ("lock.fill", "private channels", "secure channels with passwords and noise encryption") + } + + enum Privacy { + static let title = "PRIVACY" + static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection") + static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated each session") + static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data") + } + + enum HowToUse { + static let title = "HOW TO USE" + static let instructions = [ + "• set your nickname by tapping it", + "• swipe left for sidebar", + "• tap a peer to start a private chat", + "• use @nickname to mention someone", + "• use #channelname to create/join channels", + "• triple-tap \"bitchat\" for panic mode", + "• triple-tap chat messages to clear current chat" + ] + } + + enum Commands { + static let title = "COMMANDS" + static let list = [ + "/j #channel - join or create a channel", + "/m @name - send private message", + "/w - see who's online", + "/channels - show all discovered channels", + "/block @name - block a peer", + "/block - list blocked peers", + "/unblock @name - unblock a peer", + "/clear - clear current chat", + "/hug @name - send someone a hug", + "/slap @name - slap with a trout" + ] + } + } + var body: some View { #if os(macOS) VStack(spacing: 0) { @@ -32,125 +85,7 @@ struct AppInfoView: View { .background(backgroundColor.opacity(0.95)) ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Header - VStack(alignment: .center, spacing: 8) { - Text("bitchat*") - .font(.system(size: 32, weight: .bold, design: .monospaced)) - .foregroundColor(textColor) - - Text("mesh sidegroupchat") - .font(.system(size: 16, design: .monospaced)) - .foregroundColor(secondaryTextColor) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - - // Features - VStack(alignment: .leading, spacing: 16) { - SectionHeader("FEATURES") - - FeatureRow(icon: "wifi.slash", title: "offline communication", - description: "works without internet using Bluetooth mesh networking") - - FeatureRow(icon: "lock.shield", title: "end-to-end encryption", - description: "private messages encrypted with noise protocol") - - FeatureRow(icon: "antenna.radiowaves.left.and.right", title: "extended range", - description: "messages relay through peers, increasing the distance") - - FeatureRow(icon: "star.fill", title: "favorites", - description: "store-and-forward messages for favorite people") - - FeatureRow(icon: "at", title: "mentions", - description: "use @nickname to notify specific people") - - FeatureRow(icon: "number", title: "channels", - description: "create #channels for topic-based conversations") - - FeatureRow(icon: "lock.fill", title: "private channels", - description: "secure channels with passwords and noise encryption") - } - - // Privacy - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Privacy") - - FeatureRow(icon: "eye.slash", title: "no tracking", - description: "no servers, accounts, or data collection") - - FeatureRow(icon: "shuffle", title: "ephemeral identity", - description: "new peer ID generated each session") - - FeatureRow(icon: "hand.raised.fill", title: "panic mode", - description: "triple-tap logo to instantly clear all data") - } - - // How to Use - VStack(alignment: .leading, spacing: 16) { - SectionHeader("How to Use") - - VStack(alignment: .leading, spacing: 8) { - Text("• set your nickname by tapping it") - Text("• swipe left for sidebar") - Text("• tap a peer to start a private chat") - Text("• use @nickname to mention someone") - Text("• use #channelname to create/join channels") - Text("• triple-tap the logo for panic mode") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Commands - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Commands") - - VStack(alignment: .leading, spacing: 8) { - Text("/j #channel - join or create a channel") - Text("/m @name - send private message") - Text("/w - see who's online") - Text("/channels - show all discovered channels") - Text("/block @name - block a peer") - Text("/block - list blocked peers") - Text("/unblock @name - unblock a peer") - Text("/clear - clear current chat") - Text("/hug @name - send someone a hug") - Text("/slap @name - slap with a trout") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Technical Details - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Technical Details") - - VStack(alignment: .leading, spacing: 8) { - Text("protocol: custom binary over BLE") - Text("encryption: noise protocol") - Text("range: ~30m direct, 300m+ with relay") - Text("store & forward: 12h for all, ∞ for favorites") - Text("battery: Adaptive scanning based on level") - Text("platform: Universal (iOS, iPadOS, macOS)") - Text("channels: Password-protected with key commitments") - Text("storage: Keychain for passwords, encrypted retention") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Version - HStack { - Spacer() - Text("VERSION 1.0.0") - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(secondaryTextColor) - Spacer() - } - .padding(.top) - } - .padding() + infoContent } .background(backgroundColor) } @@ -158,125 +93,7 @@ struct AppInfoView: View { #else NavigationView { ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Header - VStack(alignment: .center, spacing: 8) { - Text("bitchat*") - .font(.system(size: 32, weight: .bold, design: .monospaced)) - .foregroundColor(textColor) - - Text("mesh sidegroupchat") - .font(.system(size: 16, design: .monospaced)) - .foregroundColor(secondaryTextColor) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - - // Features - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Features") - - FeatureRow(icon: "wifi.slash", title: "offline communication", - description: "works without internet using Bluetooth mesh networking") - - FeatureRow(icon: "lock.shield", title: "end-to-end encryption", - description: "private messages and channels encrypted with noise protocol") - - FeatureRow(icon: "antenna.radiowaves.left.and.right", title: "Extended Range", - description: "messages relay through peers, increasing the distance") - - FeatureRow(icon: "star.fill", title: "favorites", - description: "store-and-forward messages for favorite people") - - FeatureRow(icon: "at", title: "mentions", - description: "use @nickname to notify specific people") - - FeatureRow(icon: "number", title: "channels", - description: "create #channels for topic-based conversations") - - FeatureRow(icon: "lock.fill", title: "private channels", - description: "secure channels with passwords and noise encryption") - } - - // Privacy - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Privacy") - - FeatureRow(icon: "eye.slash", title: "no tracking", - description: "no servers, accounts, or data collection") - - FeatureRow(icon: "shuffle", title: "ephemeral identity", - description: "new peer ID generated each session") - - FeatureRow(icon: "hand.raised.fill", title: "panic mode", - description: "triple-tap logo to instantly clear all data") - } - - // How to Use - VStack(alignment: .leading, spacing: 16) { - SectionHeader("How to Use") - - VStack(alignment: .leading, spacing: 8) { - Text("• set your nickname by tapping it") - Text("• swipe left for sidebar") - Text("• tap a peer to start a private chat") - Text("• use @nickname to mention someone") - Text("• use #channelname to create/join channels") - Text("• triple-tap the logo for panic mode") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Commands - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Commands") - - VStack(alignment: .leading, spacing: 8) { - Text("/j #channel - join or create a channel") - Text("/m @name - send private message") - Text("/w - see who's online") - Text("/channels - show all discovered channels") - Text("/block @name - block a peer") - Text("/block - list blocked peers") - Text("/unblock @name - unblock a peer") - Text("/clear - clear current chat") - Text("/hug @name - send someone a hug") - Text("/slap @name - slap with a trout") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Technical Details - VStack(alignment: .leading, spacing: 16) { - SectionHeader("Technical Details") - - VStack(alignment: .leading, spacing: 8) { - Text("protocol: custom binary over BLE") - Text("encryption: noise protocol") - Text("range: ~30m direct, 300m+ with relay") - Text("store & forward: 12h for all, ∞ for favorites") - Text("battery: adaptive scanning based on level") - Text("platform: universal (iOS, iPadOS, macOS)") - Text("channels: password-protected with key commitments") - Text("storage: keychain for passwords, encrypted retention") - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - - // Version - HStack { - Spacer() - Text("VERSION 1.0.0") - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(secondaryTextColor) - Spacer() - } - .padding(.top) - } - .padding() + infoContent } .background(backgroundColor) .navigationBarTitleDisplayMode(.inline) @@ -291,6 +108,103 @@ struct AppInfoView: View { } #endif } + + @ViewBuilder + private var infoContent: some View { + VStack(alignment: .leading, spacing: 24) { + // Header + VStack(alignment: .center, spacing: 8) { + Text(Strings.appName) + .font(.system(size: 32, weight: .bold, design: .monospaced)) + .foregroundColor(textColor) + + Text(Strings.tagline) + .font(.system(size: 16, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + + // Features + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Features.title) + + FeatureRow(icon: Strings.Features.offlineComm.0, + title: Strings.Features.offlineComm.1, + description: Strings.Features.offlineComm.2) + + FeatureRow(icon: Strings.Features.encryption.0, + title: Strings.Features.encryption.1, + description: Strings.Features.encryption.2) + + FeatureRow(icon: Strings.Features.extendedRange.0, + title: Strings.Features.extendedRange.1, + description: Strings.Features.extendedRange.2) + + FeatureRow(icon: Strings.Features.favorites.0, + title: Strings.Features.favorites.1, + description: Strings.Features.favorites.2) + + FeatureRow(icon: Strings.Features.mentions.0, + title: Strings.Features.mentions.1, + description: Strings.Features.mentions.2) + + FeatureRow(icon: Strings.Features.channels.0, + title: Strings.Features.channels.1, + description: Strings.Features.channels.2) + + FeatureRow(icon: Strings.Features.privateChannels.0, + title: Strings.Features.privateChannels.1, + description: Strings.Features.privateChannels.2) + } + + // Privacy + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Privacy.title) + + FeatureRow(icon: Strings.Privacy.noTracking.0, + title: Strings.Privacy.noTracking.1, + description: Strings.Privacy.noTracking.2) + + FeatureRow(icon: Strings.Privacy.ephemeral.0, + title: Strings.Privacy.ephemeral.1, + description: Strings.Privacy.ephemeral.2) + + FeatureRow(icon: Strings.Privacy.panic.0, + title: Strings.Privacy.panic.1, + description: Strings.Privacy.panic.2) + } + + // How to Use + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.HowToUse.title) + + VStack(alignment: .leading, spacing: 8) { + ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in + Text(instruction) + } + } + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + } + + // Commands + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Commands.title) + + VStack(alignment: .leading, spacing: 8) { + ForEach(Strings.Commands.list, id: \.self) { command in + Text(command) + } + } + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + } + + .padding(.top) + } + .padding() + } } struct SectionHeader: View { @@ -306,7 +220,7 @@ struct SectionHeader: View { } var body: some View { - Text(title.uppercased()) + Text(title) .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) .padding(.top, 8) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 17d880dc..8949f854 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -244,7 +244,7 @@ struct ContentView: View { .frame(maxWidth: .infinity, alignment: .leading) } else { // Regular messages with natural text wrapping - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { HStack(alignment: .top, spacing: 0) { // Single text view for natural wrapping Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) @@ -266,14 +266,16 @@ struct ContentView: View { let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines) if cleanContent.hasPrefix("👇") { LinkPreviewView(url: markdownLink.url, title: markdownLink.title) - .padding(.top, 4) + .padding(.top, 2) + .id("\(message.id)-\(markdownLink.url.absoluteString)") } } else { // Check for plain URLs let urls = message.content.extractURLs() - ForEach(urls.prefix(3), id: \.url) { urlInfo in + ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in LinkPreviewView(url: urlInfo.url, title: nil) - .padding(.top, 4) + .padding(.top, 2) + .id("\(message.id)-\(urlInfo.url.absoluteString)") } } } @@ -287,6 +289,10 @@ struct ContentView: View { .padding(.vertical, 8) } .background(backgroundColor) + .onTapGesture(count: 3) { + // Triple-tap to clear current chat + viewModel.sendMessage("/clear") + } .onChange(of: viewModel.messages.count) { _ in if channel == nil && privatePeer == nil && !viewModel.messages.isEmpty { withAnimation { @@ -464,7 +470,6 @@ struct ContentView: View { .textFieldStyle(.plain) .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) - .autocorrectionDisabled() .focused($isTextFieldFocused) .onChange(of: messageText) { newValue in // Get cursor position (approximate - end of text for now) @@ -802,8 +807,8 @@ struct ContentView: View { .foregroundColor(Color.orange) .accessibilityLabel("Unread message from \(displayName)") } else { - Image(systemName: "radiowaves.left") - .font(.system(size: 12)) + Image(systemName: "circle.fill") + .font(.system(size: 8)) .foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) .accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")") } @@ -959,8 +964,8 @@ struct ContentView: View { } private var mainHeaderView: some View { - HStack(spacing: 4) { - Text("bitchat*") + HStack(spacing: 0) { + Text("bitchat/") .font(.system(size: 18, weight: .medium, design: .monospaced)) .foregroundColor(textColor) .onTapGesture(count: 3) { diff --git a/bitchat/Views/LinkPreviewView.swift b/bitchat/Views/LinkPreviewView.swift index a420dd3c..74d30e1b 100644 --- a/bitchat/Views/LinkPreviewView.swift +++ b/bitchat/Views/LinkPreviewView.swift @@ -9,14 +9,103 @@ import SwiftUI #if os(iOS) import LinkPresentation +import UIKit #endif +// MARK: - Link Metadata Cache + +/// Cache for link metadata to prevent repeated network requests +private class LinkMetadataCache { + static let shared = LinkMetadataCache() + + #if os(iOS) + private let cache = NSCache() + private let imageCache = NSCache() + #endif + private let queue = DispatchQueue(label: "chat.bitchat.linkmetadata.cache", attributes: .concurrent) + + private init() { + #if os(iOS) + cache.countLimit = 100 // Keep metadata for up to 100 URLs + imageCache.countLimit = 50 // Keep images for up to 50 URLs + imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB limit for images + #endif + } + + #if os(iOS) + class CachedMetadata { + let metadata: LPLinkMetadata? + let title: String? + let host: String? + let error: Error? + let timestamp: Date + + init(metadata: LPLinkMetadata? = nil, title: String? = nil, host: String? = nil, error: Error? = nil) { + self.metadata = metadata + self.title = title + self.host = host + self.error = error + self.timestamp = Date() + } + } + + func getCachedMetadata(for url: URL) -> (metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?)? { + return queue.sync { + guard let cached = cache.object(forKey: url as NSURL) else { return nil } + + // Check if cache is older than 24 hours + if Date().timeIntervalSince(cached.timestamp) > 86400 { + cache.removeObject(forKey: url as NSURL) + imageCache.removeObject(forKey: url as NSURL) + return nil + } + + let image = imageCache.object(forKey: url as NSURL) + return (cached.metadata, cached.title, cached.host, image) + } + } + + func cacheMetadata(_ metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?, for url: URL) { + queue.async(flags: .barrier) { + let cached = CachedMetadata(metadata: metadata, title: title, host: host) + self.cache.setObject(cached, forKey: url as NSURL) + + if let image = image { + let cost = Int(image.size.width * image.size.height * 4) // Approximate memory usage + self.imageCache.setObject(image, forKey: url as NSURL, cost: cost) + } + } + } + + func cacheError(_ error: Error, for url: URL) { + queue.async(flags: .barrier) { + let cached = CachedMetadata(error: error) + self.cache.setObject(cached, forKey: url as NSURL) + } + } + #endif + + func clearCache() { + queue.async(flags: .barrier) { + #if os(iOS) + self.cache.removeAllObjects() + self.imageCache.removeAllObjects() + #endif + } + } +} + +// MARK: - Link Preview View + struct LinkPreviewView: View { let url: URL let title: String? @Environment(\.colorScheme) var colorScheme #if os(iOS) @State private var metadata: LPLinkMetadata? + @State private var cachedTitle: String? + @State private var cachedHost: String? + @State private var isLoading = false #endif private var textColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) @@ -34,7 +123,7 @@ struct LinkPreviewView: View { // Always use our custom compact view for consistent appearance compactLinkView .onAppear { - loadMetadata() + loadFromCacheOrFetch() } } @@ -87,7 +176,13 @@ struct LinkPreviewView: View { VStack(alignment: .leading, spacing: 4) { // Title #if os(iOS) - Text(metadata?.title ?? title ?? url.host ?? "Link") + Text(cachedTitle ?? metadata?.title ?? title ?? url.host ?? "Link") + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .lineLimit(2) + .multilineTextAlignment(.leading) + #else + Text(title ?? url.host ?? "Link") .font(.system(size: 14, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) .lineLimit(2) @@ -95,10 +190,17 @@ struct LinkPreviewView: View { #endif // Host + #if os(iOS) + Text(cachedHost ?? url.host ?? url.absoluteString) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(textColor.opacity(0.6)) + .lineLimit(1) + #else Text(url.host ?? url.absoluteString) .font(.system(size: 11, design: .monospaced)) .foregroundColor(textColor.opacity(0.6)) .lineLimit(1) + #endif } Spacer() @@ -169,19 +271,66 @@ struct LinkPreviewView: View { .buttonStyle(.plain) } - private func loadMetadata() { + private func loadFromCacheOrFetch() { #if os(iOS) - guard metadata == nil else { return } + // Check if we already have data in state + guard metadata == nil && !isLoading else { + return + } + + // Check cache first + if let cached = LinkMetadataCache.shared.getCachedMetadata(for: url) { + // print("🔗 LinkPreviewView: Using CACHED metadata for: \(url.absoluteString)") + self.metadata = cached.metadata + self.cachedTitle = cached.title ?? cached.metadata?.title + self.cachedHost = cached.host ?? url.host + self.previewImage = cached.image + return + } + + // Not in cache, fetch it + // print("🔗 LinkPreviewView: FETCHING metadata for: \(url.absoluteString)") + isLoading = true let provider = LPMetadataProvider() provider.startFetchingMetadata(for: url) { fetchedMetadata, error in DispatchQueue.main.async { + self.isLoading = false + + if let error = error { + // Check if it's an ATS error for subresources (non-critical) + let errorString = error.localizedDescription.lowercased() + let isATSError = errorString.contains("app transport security") || + errorString.contains("secure connection") + + if !isATSError { + // Only log non-ATS errors + // print("🔗 LinkPreviewView: Error fetching metadata: \(error)") + } + + // Still try to show basic preview with URL info + self.cachedTitle = self.title ?? self.url.host + self.cachedHost = self.url.host + + // Cache even failed attempts to avoid repeated fetches + LinkMetadataCache.shared.cacheMetadata( + nil, + title: self.cachedTitle, + host: self.cachedHost, + image: nil, + for: self.url + ) + return + } + if let fetchedMetadata = fetchedMetadata { // Use the fetched metadata, or create new with our title if let title = self.title, !title.isEmpty { fetchedMetadata.title = title } self.metadata = fetchedMetadata + self.cachedTitle = fetchedMetadata.title ?? self.title + self.cachedHost = self.url.host // Try to extract image if let imageProvider = fetchedMetadata.imageProvider { @@ -189,9 +338,35 @@ struct LinkPreviewView: View { DispatchQueue.main.async { if let image = image as? UIImage { self.previewImage = image + // Cache everything including the image + LinkMetadataCache.shared.cacheMetadata( + fetchedMetadata, + title: self.cachedTitle, + host: self.cachedHost, + image: image, + for: self.url + ) + } else { + // Cache without image + LinkMetadataCache.shared.cacheMetadata( + fetchedMetadata, + title: self.cachedTitle, + host: self.cachedHost, + image: nil, + for: self.url + ) } } } + } else { + // No image, cache what we have + LinkMetadataCache.shared.cacheMetadata( + fetchedMetadata, + title: self.cachedTitle, + host: self.cachedHost, + image: nil, + for: self.url + ) } } }