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