UI improvements and performance optimizations

- Changed toolbar text from "bitchat*" to "bitchat/" with tighter spacing
- Removed blue intro message when no peers are connected
- Changed RSSI indicator back to simple dot instead of radiowaves icon
- Added triple-tap gesture on chat area to clear current context
- Improved LinkPreviewView performance with metadata caching
- Moved link previews closer to messages for better visual connection
- Fixed AppInfoView duplication by consolidating strings into single location
- Better error handling for link preview ATS errors
This commit is contained in:
jack
2025-07-22 16:19:40 +02:00
parent 849da32947
commit 8f9df5beb3
4 changed files with 348 additions and 273 deletions
+2 -21
View File
@@ -122,21 +122,7 @@ class ChatViewModel: ObservableObject {
.sink { [weak self] (messageID, status) in .sink { [weak self] (messageID, status) in
self?.updateMessageDeliveryStatus(messageID, status: status) 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 // When app becomes active, send read receipts for visible messages
#if os(macOS) #if os(macOS)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -2061,12 +2047,7 @@ class ChatViewModel: ObservableObject {
} else { } else {
// System message // System message
var contentStyle = AttributeContainer() var contentStyle = AttributeContainer()
// Check for welcome message contentStyle.foregroundColor = Color.gray
if message.content.contains("get people around you to download bitchat") {
contentStyle.foregroundColor = Color.blue
} else {
contentStyle.foregroundColor = Color.gray
}
let content = AttributedString("* \(message.content) *") let content = AttributedString("* \(message.content) *")
contentStyle.font = .system(size: 12, design: .monospaced).italic() contentStyle.font = .system(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle)) result.append(content.mergingAttributes(contentStyle))
+153 -239
View File
@@ -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) 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 { var body: some View {
#if os(macOS) #if os(macOS)
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -32,125 +85,7 @@ struct AppInfoView: View {
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { infoContent
// 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()
} }
.background(backgroundColor) .background(backgroundColor)
} }
@@ -158,125 +93,7 @@ struct AppInfoView: View {
#else #else
NavigationView { NavigationView {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { infoContent
// 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()
} }
.background(backgroundColor) .background(backgroundColor)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -291,6 +108,103 @@ struct AppInfoView: View {
} }
#endif #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 { struct SectionHeader: View {
@@ -306,7 +220,7 @@ struct SectionHeader: View {
} }
var body: some View { var body: some View {
Text(title.uppercased()) Text(title)
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.padding(.top, 8) .padding(.top, 8)
+14 -9
View File
@@ -244,7 +244,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} else { } else {
// Regular messages with natural text wrapping // Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping // Single text view for natural wrapping
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
@@ -266,14 +266,16 @@ struct ContentView: View {
let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines) let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
if cleanContent.hasPrefix("👇") { if cleanContent.hasPrefix("👇") {
LinkPreviewView(url: markdownLink.url, title: markdownLink.title) LinkPreviewView(url: markdownLink.url, title: markdownLink.title)
.padding(.top, 4) .padding(.top, 2)
.id("\(message.id)-\(markdownLink.url.absoluteString)")
} }
} else { } else {
// Check for plain URLs // Check for plain URLs
let urls = message.content.extractURLs() 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) 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) .padding(.vertical, 8)
} }
.background(backgroundColor) .background(backgroundColor)
.onTapGesture(count: 3) {
// Triple-tap to clear current chat
viewModel.sendMessage("/clear")
}
.onChange(of: viewModel.messages.count) { _ in .onChange(of: viewModel.messages.count) { _ in
if channel == nil && privatePeer == nil && !viewModel.messages.isEmpty { if channel == nil && privatePeer == nil && !viewModel.messages.isEmpty {
withAnimation { withAnimation {
@@ -464,7 +470,6 @@ struct ContentView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.autocorrectionDisabled()
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Get cursor position (approximate - end of text for now) // Get cursor position (approximate - end of text for now)
@@ -802,8 +807,8 @@ struct ContentView: View {
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
.accessibilityLabel("Unread message from \(displayName)") .accessibilityLabel("Unread message from \(displayName)")
} else { } else {
Image(systemName: "radiowaves.left") Image(systemName: "circle.fill")
.font(.system(size: 12)) .font(.system(size: 8))
.foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) .foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme))
.accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")") .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 { private var mainHeaderView: some View {
HStack(spacing: 4) { HStack(spacing: 0) {
Text("bitchat*") Text("bitchat/")
.font(.system(size: 18, weight: .medium, design: .monospaced)) .font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
+179 -4
View File
@@ -9,14 +9,103 @@
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import LinkPresentation import LinkPresentation
import UIKit
#endif #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<NSURL, CachedMetadata>()
private let imageCache = NSCache<NSURL, UIImage>()
#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 { struct LinkPreviewView: View {
let url: URL let url: URL
let title: String? let title: String?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
#if os(iOS) #if os(iOS)
@State private var metadata: LPLinkMetadata? @State private var metadata: LPLinkMetadata?
@State private var cachedTitle: String?
@State private var cachedHost: String?
@State private var isLoading = false
#endif #endif
private var textColor: Color { private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) 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 // Always use our custom compact view for consistent appearance
compactLinkView compactLinkView
.onAppear { .onAppear {
loadMetadata() loadFromCacheOrFetch()
} }
} }
@@ -87,7 +176,13 @@ struct LinkPreviewView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Title // Title
#if os(iOS) #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)) .font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.lineLimit(2) .lineLimit(2)
@@ -95,10 +190,17 @@ struct LinkPreviewView: View {
#endif #endif
// Host // 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) Text(url.host ?? url.absoluteString)
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor.opacity(0.6)) .foregroundColor(textColor.opacity(0.6))
.lineLimit(1) .lineLimit(1)
#endif
} }
Spacer() Spacer()
@@ -169,19 +271,66 @@ struct LinkPreviewView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private func loadMetadata() { private func loadFromCacheOrFetch() {
#if os(iOS) #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() let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: url) { fetchedMetadata, error in provider.startFetchingMetadata(for: url) { fetchedMetadata, error in
DispatchQueue.main.async { 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 { if let fetchedMetadata = fetchedMetadata {
// Use the fetched metadata, or create new with our title // Use the fetched metadata, or create new with our title
if let title = self.title, !title.isEmpty { if let title = self.title, !title.isEmpty {
fetchedMetadata.title = title fetchedMetadata.title = title
} }
self.metadata = fetchedMetadata self.metadata = fetchedMetadata
self.cachedTitle = fetchedMetadata.title ?? self.title
self.cachedHost = self.url.host
// Try to extract image // Try to extract image
if let imageProvider = fetchedMetadata.imageProvider { if let imageProvider = fetchedMetadata.imageProvider {
@@ -189,9 +338,35 @@ struct LinkPreviewView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
if let image = image as? UIImage { if let image = image as? UIImage {
self.previewImage = image 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
)
} }
} }
} }