mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:45:21 +00:00
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:
@@ -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))
|
||||
|
||||
+153
-239
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user