Compare commits

..

2 Commits

Author SHA1 Message Date
Evgeny Poberezkin 83fd6efed9 update 2024-11-06 09:24:06 +00:00
Evgeny Poberezkin c8936220b0 rfc: conversational agent 2024-07-04 12:10:47 +01:00
446 changed files with 4918 additions and 31477 deletions
+71 -129
View File
@@ -43,21 +43,6 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
}
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -84,11 +69,10 @@ final class ChatModel: ObservableObject {
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat
@Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -131,8 +115,6 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -198,30 +180,8 @@ final class ChatModel: ObservableObject {
}
}
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
if let i = groupMembersIndexes[groupMemberId] {
return groupMembers[i]
}
return nil
}
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
groupMembers.first { $0.groupMemberId == groupMemberId }
}
private func getChatIndex(_ id: String) -> Int? {
@@ -359,7 +319,7 @@ final class ChatModel: ObservableObject {
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
@@ -370,9 +330,6 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
@@ -389,7 +346,7 @@ final class ChatModel: ObservableObject {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
}
return true
}
@@ -413,17 +370,17 @@ final class ChatModel: ObservableObject {
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
im.reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now
reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if cItem.isRcvNew, let chatIndex = getChatIndex(cInfo.id) {
decreaseUnreadCounter(chatIndex)
if cItem.isRcvNew {
decreaseUnreadCounter(cInfo)
}
// update previews
if let chat = getChat(cInfo.id) {
@@ -435,24 +392,23 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
_ = withAnimation {
im.reversedChatItems.remove(at: i)
self.reversedChatItems.remove(at: i)
}
}
}
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
while i < im.reversedChatItems.count - 1 {
while i < reversedChatItems.count - 1 {
i += 1
if let res = map(im.reversedChatItems[i]) { return res }
if let res = map(reversedChatItems[i]) { return res }
}
} else {
while i > 0 {
i -= 1
if let res = map(im.reversedChatItems[i]) { return res }
if let res = map(reversedChatItems[i]) { return res }
}
}
return nil
@@ -483,7 +439,7 @@ final class ChatModel: ObservableObject {
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
im.reversedChatItems.insert(cItem, at: 0)
reversedChatItems.insert(cItem, at: 0)
}
return cItem
}
@@ -491,15 +447,15 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy {
if animated {
withAnimation { _ = im.reversedChatItems.removeFirst() }
withAnimation { _ = reversedChatItems.removeFirst() }
} else {
_ = im.reversedChatItems.removeFirst()
_ = reversedChatItems.removeFirst()
}
}
}
private var hasLiveDummy: Bool {
im.reversedChatItems.first?.isLiveDummy == true
reversedChatItems.first?.isLiveDummy == true
}
func markChatItemsRead(_ cInfo: ChatInfo) {
@@ -516,7 +472,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < im.reversedChatItems.count {
while j < reversedChatItems.count {
markChatItemRead_(j)
j += 1
}
@@ -530,7 +486,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
@@ -565,66 +521,37 @@ final class ChatModel: ObservableObject {
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
im.reversedChatItems = []
reversedChatItems = []
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
if chatId == cInfo.id,
let itemIndex = getChatItemIndex(cItem),
let chatIndex = getChatIndex(cInfo.id),
im.reversedChatItems[itemIndex].isRcvNew {
await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.decreaseUnreadCounter(chatIndex)
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
if reversedChatItems[i].isRcvNew {
// update current chat
markChatItemRead_(i)
// update preview
decreaseUnreadCounter(cInfo)
}
}
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Int, Never>()
private var bag = Set<AnyCancellable>()
private var dictionary = Dictionary<Int, Int>()
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { _ in
self.dictionary.forEach { key, value in
ChatModel.shared.decreaseUnreadCounter(key, by: value)
}
self.dictionary = Dictionary<Int, Int>()
}
.store(in: &bag)
}
// Only call from main thread
func decreaseUnreadCounter(_ chatIndex: Int) {
dictionary[chatIndex] = (dictionary[chatIndex] ?? 0) + 1
subject.send(chatIndex)
}
}
private func markChatItemRead_(_ i: Int) {
let meta = im.reversedChatItems[i].meta
let meta = reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
im.reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
func decreaseUnreadCounter(_ chatIndex: Int, by count: Int = 1) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - count
decreaseUnreadCounter(user: currentUser!, by: count)
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
}
}
func increaseUnreadCounter(user: any UserLike) {
@@ -654,8 +581,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
while i < reversedChatItems.count {
let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
@@ -670,7 +597,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
(i, i > 0 ? reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
}
@@ -680,10 +607,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
let fst = im.reversedChatItems.count - 1
let fst = reversedChatItems.count - 1
while i < fst {
i = i + 1
let ci = im.reversedChatItems[i]
let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
@@ -696,7 +623,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
@@ -740,17 +667,14 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembersIndexes[member.groupMemberId] {
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) {
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
withAnimation {
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
withAnimation { groupMembers.append(GMember(member)) }
return true
}
} else {
@@ -771,23 +695,23 @@ final class ChatModel: ObservableObject {
var i = 0
var totalBelow = 0
var unreadBelow = 0
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
totalBelow += 1
if im.reversedChatItems[i].isRcvNew {
if reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = im.reversedChatItems.count - 1
let maxIx = reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return im.reversedChatItems[min(i - 1, maxIx)]
return reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
@@ -816,11 +740,11 @@ struct NTFContactRequest {
}
struct UnreadChatItemCounts: Equatable {
var isNearBottom: Bool
var totalBelow: Int
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable, ChatLike {
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
@@ -871,6 +795,24 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
@@ -7,19 +7,18 @@
//
import Foundation
import SimpleXChat
import SwiftUI
import AVKit
import SwiftyGif
import LinkPresentation
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
if let file = file, file.loaded {
return file.fileSource
}
return nil
}
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
func getLoadedImage(_ file: CIFile?) -> UIImage? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
do {
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
return nil
}
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
if let cfArgs = cfArgs {
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
} else {
@@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
}
}
public func getLoadedVideo(_ file: CIFile?) -> URL? {
func getLoadedVideo(_ file: CIFile?) -> URL? {
if let fileSource = getLoadedFileSource(file) {
let filePath = getAppFilePath(fileSource.filePath)
if FileManager.default.fileExists(atPath: filePath.path) {
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
return nil
}
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
let fileName = generateNewFileName("IMG", "gif")
guard let imageData = image.imageData else { return nil }
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
let hasAlpha = imageHasAlpha(uiImage)
let ext = hasAlpha ? "png" : "jpg"
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
return nil
}
public func cropToSquare(_ image: UIImage) -> UIImage {
func cropToSquare(_ image: UIImage) -> UIImage {
let size = image.size
let side = min(size.width, size.height)
let newSize = CGSize(width: side, height: side)
@@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
}
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
var img = image
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
var dataSize = data?.count ?? 0
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
return data
}
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
var img = image
let hasAlpha = imageHasAlpha(image)
var str = compressImageStr(img, hasAlpha: hasAlpha)
@@ -116,7 +115,7 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String
return str
}
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
let ext = hasAlpha ? "png" : "jpg"
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
return "data:image/\(ext);base64,\(data.base64EncodedString())"
@@ -139,7 +138,7 @@ private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, ha
}
}
public func imageHasAlpha(_ img: UIImage) -> Bool {
func imageHasAlpha(_ img: UIImage) -> Bool {
if let cgImage = img.cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -159,35 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
return false
}
/// Reduces image size, while consuming less RAM
///
/// Used by ShareExtension to downsize large images
/// before passing them to regular image processing pipeline
/// to avoid exceeding 120MB memory
///
/// - Parameters:
/// - url: Location of the image data
/// - size: Maximum dimension (width or height)
/// - Returns: Downsampled image or `nil`, if the image can't be located
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
autoreleasepool {
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
CGImageSourceCreateThumbnailAtIndex(
source,
Int.zero,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
] as CFDictionary
)
.map { UIImage(cgImage: $0) }
} else { nil }
}
}
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
func saveFileFromURL(_ url: URL) -> CryptoFile? {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let savedFile: CryptoFile?
if url.startAccessingSecurityScopedResource() {
@@ -213,7 +184,7 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
return savedFile
}
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
do {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
let fileName = uniqueCombine(url.lastPathComponent)
@@ -226,6 +197,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
savedFile = CryptoFile.plain(fileName)
}
ChatModel.shared.filesToDelete.remove(url)
return savedFile
} catch {
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
@@ -233,7 +205,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
}
}
public func saveWallpaperFile(url: URL) -> String? {
func saveWallpaperFile(url: URL) -> String? {
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
do {
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
@@ -244,7 +216,7 @@ public func saveWallpaperFile(url: URL) -> String? {
}
}
public func saveWallpaperFile(image: UIImage) -> String? {
func saveWallpaperFile(image: UIImage) -> String? {
let hasAlpha = imageHasAlpha(image)
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
@@ -257,7 +229,7 @@ public func saveWallpaperFile(image: UIImage) -> String? {
}
}
public func removeWallpaperFile(fileName: String? = nil) {
func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
@@ -270,7 +242,7 @@ public func removeWallpaperFile(fileName: String? = nil) {
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}
@@ -302,7 +274,7 @@ private func getTimestamp() -> String {
return df.string(from: Date())
}
public func dropImagePrefix(_ s: String) -> String {
func dropImagePrefix(_ s: String) -> String {
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
}
@@ -310,23 +282,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
}
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
extension AVAsset {
public func generatePreview() -> (UIImage, Int)? {
func generatePreview() -> (UIImage, Int)? {
let generator = AVAssetImageGenerator(asset: self)
generator.appliesPreferredTrackTransform = true
var actualTime = CMTimeMake(value: 0, timescale: 0)
@@ -338,7 +295,7 @@ extension AVAsset {
}
extension UIImage {
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
if let cgImage = cgImage {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
@@ -383,67 +340,4 @@ extension UIImage {
}
return self
}
public convenience init?(base64Encoded: String?) {
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) {
self.init(data: data)
} else {
return nil
}
}
}
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
logger.error("Could not load link preview image")
cb(nil)
}
}
}
public func getLinkPreview(for url: URL) async -> LinkPreview? {
await withCheckedContinuation { cont in
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
+43 -73
View File
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
@@ -225,15 +225,6 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
}
}
func apiCheckChatRunning() throws -> Bool {
let r = chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r
}
}
func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat)
switch r {
@@ -334,12 +325,11 @@ func loadChat(chat: Chat, search: String = "") {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
im.reversedChatItems = []
m.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
im.reversedChatItems = chat.chatItems.reversed()
m.reversedChatItems = chat.chatItems.reversed()
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
@@ -407,7 +397,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
@@ -415,7 +405,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
@@ -431,15 +421,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r
}
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
throw r
}
@@ -709,7 +699,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Please check that you used the correct link or ask your contact to send you another one."
)
return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
let alert = mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
@@ -742,7 +732,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} else {
return mkAlert(
title: "Connection error",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
}
@@ -908,7 +898,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact }
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
@@ -919,7 +909,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
return nil
@@ -945,7 +935,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
return (fileTransferMeta, nil)
} else {
logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r))
return (nil, String(describing: r))
}
}
@@ -955,7 +945,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
return (rcvFileTransfer, nil)
} else {
logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, responseError(r))
return (nil, String(describing: r))
}
}
@@ -1035,7 +1025,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(responseError(r))"
message: "Error: \(String(describing: r))"
)
}
}
@@ -1102,9 +1092,18 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
}
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
if let alert = getNetworkErrorAlert(r) {
return mkAlert(title: alert.title, message: alert.message)
} else {
switch r {
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return mkAlert(
title: "Connection timeout",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return mkAlert(
title: "Connection error",
message: "Please check your network connection with \(serverHostname(addr)) and try again."
)
default:
return nil
}
}
@@ -1112,10 +1111,7 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
DispatchQueue.main.async {
ChatModel.shared.replaceChat(contactRequest.id, chat)
ChatModel.shared.setContactNetworkStatus(contact, .connected)
}
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
}
}
@@ -1211,7 +1207,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem)
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
}
@@ -1252,7 +1248,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
switch r {
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
default: throw r
}
@@ -1358,14 +1354,6 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r
}
func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
@@ -1449,16 +1437,15 @@ func startChat(refreshInvitations: Bool = true) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
let chatRunning = try apiCheckChatRunning()
let justStarted = try apiStartChat()
m.users = try listUsers()
if !chatRunning {
if justStarted {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
try refreshCallInvitations()
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
@@ -1630,19 +1617,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
}
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1746,25 +1720,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
}
case let .chatItemsDeleted(user, items, _):
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
if !active(user) {
for item in items {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
}
return
}
await MainActor.run {
for item in items {
if let toChatItem = item.toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
}
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
-12
View File
@@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
}
}
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() {
suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout)
+2 -1
View File
@@ -102,7 +102,8 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper {
if preset == nil, let image {
// Need to save image from string and to save its path
if let parsed = UIImage(base64Encoded: image),
if let data = Data(base64Encoded: dropImagePrefix(image)),
let parsed = UIImage(data: data),
let filename = saveWallpaperFile(image: parsed) {
var copy = self
copy.image = nil
@@ -28,17 +28,15 @@ struct ChatInfoToolbar: View {
color: Color(uiColor: .tertiaryLabel)
)
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
VStack(spacing: 0) {
v
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
.padding(.top, -2)
}
VStack {
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
+12 -14
View File
@@ -112,7 +112,7 @@ struct ChatInfoView: View {
case abortSwitchAddressAlert
case syncConnectionForceAlert
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -155,25 +155,23 @@ struct ChatInfoView: View {
}
Section {
Group {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
.disabled(!contact.ready || !contact.active)
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
if let conn = contact.activeConn {
Section {
@@ -273,7 +271,7 @@ struct ChatInfoView: View {
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.sndReady && contact.active {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
@@ -9,7 +9,6 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
convenience init(image: UIImage) {
self.init()
self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
}
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = contentMode
imageView!.contentMode = .scaleAspectFit
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
init(image: UIImage) {
self.image = image
self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image, contentMode: contentMode)
AnimatedImageView(image: image)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,7 +11,6 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -54,8 +53,8 @@ struct CIChatFeatureView: View {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
@@ -14,45 +14,39 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
var smallViewSize: CGFloat?
var body: some View {
if smallViewSize != nil {
fileIndicator()
.onTapGesture(perform: fileAction)
} else {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.disabled(!itemInteractive)
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.disabled(!itemInteractive)
}
private var itemInteractive: Bool {
@@ -201,22 +195,21 @@ struct CIFileView: View {
}
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
let size = smallViewSize ?? 30
return ZStack(alignment: .center) {
ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.frame(width: 30, height: 30)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
let innerIconSize = innerIconSize {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, size / 2.5)
.padding(.top, 12)
}
}
}
@@ -70,6 +70,7 @@ struct CIGroupInvitationView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -15,33 +15,22 @@ struct CIImageView: View {
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
var smallView: Bool = false
@Binding var showFullScreenImage: Bool
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
@State private var showFullScreenImage = false
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
.onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let preview {
Group {
if smallView {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -94,9 +83,6 @@ struct CIImageView: View {
}
}
}
.onDisappear {
showFullScreenImage = false
}
}
private func imageView(_ img: UIImage) -> some View {
@@ -112,26 +98,7 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
loadingIndicator()
}
}
@@ -178,12 +145,4 @@ struct CIImageView: View {
.tint(.white)
.padding(8)
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
}
@@ -22,6 +22,7 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) {
@@ -12,15 +12,14 @@ import SimpleXChat
struct CILinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
VStack(alignment: .center, spacing: 6) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
}
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
@@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View {
case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert
case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
}
@@ -132,6 +132,7 @@ struct CIRcvDecryptionError: View {
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -151,6 +152,7 @@ struct CIRcvDecryptionError: View {
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -20,44 +20,45 @@ struct CIVideoView: View {
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@Binding private var showFullScreenPlayer: Bool
@State private var showFullScreenPlayer = false
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
self.chatItem = chatItem
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
self.smallView = smallView
self._showFullScreenPlayer = showFullscreenPlayer
if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
}
var body: some View {
let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack {
ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let preview, let file {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture {
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@@ -81,64 +82,21 @@ struct CIVideoView: View {
default: ()
}
}
}
if !smallView {
durationProgress()
}
}
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
} else if !file.showStatusIconInSmallView {
}
durationProgress()
}
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
}
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
@@ -151,6 +109,11 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
@@ -159,22 +122,20 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !blurred {
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
@@ -193,7 +154,9 @@ struct CIVideoView: View {
videoPlaying = false
}
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.onTapGesture {
switch player.timeControlStatus {
case .playing:
@@ -209,7 +172,7 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying && !blurred {
if !videoPlaying {
Button {
m.stopPreviousRecPlay = url
player.play()
@@ -231,53 +194,14 @@ struct CIVideoView: View {
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.frame(width: 12, height: 12)
.foregroundColor(color)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -285,9 +209,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.frame(width: 12, height: 12)
.tint(color)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -323,23 +247,7 @@ struct CIVideoView: View {
.resizable()
.scaledToFit()
.frame(width: w)
.modifier(PrivacyBlur(blurred: $blurred))
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
fileStatusIcon()
}
}
@@ -414,7 +322,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(.white)
.padding(smallView ? 0 : padding)
.padding(padding)
}
private func progressView() -> some View {
@@ -422,7 +330,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(smallView ? 0 : 11)
.padding(11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -434,7 +342,7 @@ struct CIVideoView: View {
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11)
.padding([.trailing, .top], 11)
}
// TODO encrypt: where file size is checked?
@@ -474,8 +382,7 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
m.stopPreviousRecPlay = url
if let player = fullPlayer {
player.play()
var played = false
@@ -512,12 +419,10 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = false
decryptionInProgress = true
completed?()
}
}
@@ -15,26 +15,15 @@ struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if smallViewSize != nil {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -65,13 +54,7 @@ struct CIVoiceView: View {
}
private func player() -> some View {
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
VoiceMessagePlayer(
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -80,8 +63,7 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: sizeMultiplier
allowMenu: $allowMenu
)
}
@@ -137,7 +119,6 @@ struct VoiceMessagePlayerTime: View {
}
struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -149,9 +130,7 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View {
ZStack {
@@ -211,113 +190,49 @@ struct VoiceMessagePlayer: View {
}
}
.onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onTimer = { playbackTime = $0 }
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
stopPlayback()
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
}
}
@ViewBuilder private func playbackButton() -> some View {
if sizeMultiplier != 1 {
switch playbackState {
case .noPlayback:
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
}
}
} else {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
}
}
@@ -327,49 +242,28 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
.frame(width: 20, height: 20)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Group {
if sizeMultiplier != 1 {
playPauseIcon(icon, theme.colors.primary)
.onTapGesture {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
}
}
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} label: {
playPauseIcon(icon, theme.colors.primary)
}
}
@@ -394,96 +288,33 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.frame(width: size, height: size)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingSource: CryptoFile) {
let audioPath = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
audioPlayer = AudioPlayer(
onTimer: {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onTimer: { playbackTime = $0 },
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
)
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
notifyStateChange()
}
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
}
struct CIVoiceView_Previews: PreviewProvider {
@@ -508,12 +339,15 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -25,6 +25,7 @@ struct DeletedItemView: View {
.padding(.leading, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
}
}
@@ -13,23 +13,21 @@ import SimpleXChat
struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -38,8 +36,7 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu,
sizeMultiplier: 1
allowMenu: $allowMenu
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
File diff suppressed because one or more lines are too long
@@ -70,6 +70,7 @@ struct CIMsgError: View {
.padding(.leading, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture(perform: onTap)
}
@@ -23,6 +23,7 @@ struct MarkedDeletedItemView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -35,8 +36,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -21,7 +21,8 @@ struct ChatItemForwardingView: View {
@State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
@State private var hasSimplexLink_: Bool?
private let chatsToForwardTo = filterChatsToForwardTo()
var body: some View {
NavigationView {
@@ -66,6 +67,50 @@ struct ChatItemForwardingView: View {
}
}
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
let cInfo = chat.chatInfo
return switch cInfo {
case let .direct(contact):
viewNameContains(cInfo, searchStr) ||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
contact.fullName.localizedLowercase.contains(searchStr)
default:
viewNameContains(cInfo, searchStr)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
private func prohibitedByPref(_ chat: Chat) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
return switch chat.chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
private var hasSimplexLink: Bool {
if let hasSimplexLink_ { return hasSimplexLink_ }
let r =
if let mcText = ci.content.msgContent?.text,
let parsedMsg = parseSimpleXMarkdown(mcText) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
hasSimplexLink_ = r
return r
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
@@ -73,11 +118,7 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
let prohibited = prohibitedByPref(chat)
Button {
if prohibited {
alert = SomeAlert(
@@ -121,6 +162,27 @@ struct ChatItemForwardingView: View {
}
}
private func filterChatsToForwardTo() -> [Chat] {
var filteredChats = ChatModel.shared.chats.filter { c in
c.chatInfo.chatType != .local && canForwardToChat(c)
}
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
filteredChats.insert(privateNotes, at: 0)
}
return filteredChats
}
private func canForwardToChat(_ chat: Chat) -> Bool {
switch chat.chatInfo {
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
case let .group(groupInfo): groupInfo.sendMsgEnabled
case let .local(noteFolder): noteFolder.sendMsgEnabled
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
#Preview {
ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
@@ -128,4 +190,3 @@ struct ChatItemForwardingView: View {
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
}
@@ -232,7 +232,7 @@ struct ChatItemInfoView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(ci, theme))
.modifier(ChatItemClipped())
.cornerRadius(18)
.contextMenu {
if itemVersion.msgContent.text != "" {
Button {
@@ -302,7 +302,7 @@ struct ChatItemInfoView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
.modifier(ChatItemClipped())
.cornerRadius(18)
.contextMenu {
if qi.text != "" {
Button {
@@ -415,7 +415,7 @@ struct ChatItemInfoView: View {
}
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
@@ -428,7 +428,7 @@ struct ChatItemInfoView: View {
}
}
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
@@ -438,7 +438,7 @@ struct ChatItemInfoView: View {
})
}
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2)
@@ -450,19 +450,23 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
switch status {
case .rcvd:
ZStack(alignment: .trailing) {
if let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) {
switch status {
case .sndRcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.foregroundColor(statusColor)
}
default:
Image(systemName: icon)
.foregroundColor(statusColor)
} else {
Image(systemName: "ellipsis")
.foregroundColor(Color.secondary)
}
}
+18 -5
View File
@@ -16,20 +16,28 @@ struct ChatItemView: View {
var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(
chat: Chat,
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false)
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
}
var body: some View {
@@ -40,7 +48,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
@@ -60,7 +68,9 @@ struct ChatItemView: View {
default: nil
}
}
.flatMap { UIImage(base64Encoded: $0) }
.map { dropImagePrefix($0) }
.flatMap { Data(base64Encoded: $0) }
.flatMap { UIImage(data: $0) }
let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75
@@ -76,7 +86,10 @@ struct ChatItemView: View {
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
}
}
+211 -411
View File
@@ -15,7 +15,6 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@@ -38,25 +37,18 @@ struct ChatView: View {
@State private var searchText: String = ""
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var membersLoaded = false
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
var body: some View {
if #available(iOS 16.0, *) {
let v = viewBody
viewBody
.scrollDismissesKeyboard(.immediately)
.keyboardPadding()
if (searchMode) {
v.toolbarBackground(.thinMaterial, for: .navigationBar)
} else {
v.toolbarBackground(.visible, for: .navigationBar)
}
} else {
viewBody
}
@@ -83,58 +75,25 @@ struct ChatView: View {
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
}
connectingText()
if selectedChatItems == nil {
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
} else {
SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
allowToDeleteSelectedMessagesForAll = forAll
showDeleteSelectedMessages = true
},
moderateItems: {
if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
}
)
}
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
}
.padding(.top, 1)
.navigationTitle(cInfo.chatViewName)
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.environmentObject(theme)
.confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) }
}
if allowToDeleteSelectedMessagesForAll {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
if let selected = selectedChatItems {
allowToDeleteSelectedMessagesForAll = false
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages)
}
}
}
}
.onAppear {
loadChat(chat: chat)
initChatView()
selectedChatItems = nil
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
stopAudioPlayer()
if let cId {
selectedChatItems = nil
if let c = chatModel.getChat(cId) {
chat = c
}
@@ -147,7 +106,7 @@ struct ChatView: View {
.onChange(of: revealedChatItem) { _ in
NotificationCenter.postReverseListNeedsLayout()
}
.onChange(of: im.reversedChatItems) { reversedChatItems in
.onChange(of: chatModel.reversedChatItems) { reversedChatItems in
if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 {
loadChatItems(chat.chatInfo)
}
@@ -155,16 +114,14 @@ struct ChatView: View {
.environmentObject(scrollModel)
.onDisappear {
VideoPlayerView.players.removeAll()
stopAudioPlayer()
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
ItemsModel.shared.reversedChatItems = []
chatModel.reversedChatItems = []
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
membersLoaded = false
}
}
}
@@ -174,9 +131,7 @@ struct ChatView: View {
}
.toolbar {
ToolbarItem(placement: .principal) {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else if case let .direct(contact) = cInfo {
if case let .direct(contact) = cInfo {
Button {
Task {
do {
@@ -208,7 +163,7 @@ struct ChatView: View {
}
} else if case let .group(groupInfo) = cInfo {
Button {
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
} label: {
ChatInfoToolbar(chat: chat)
.tint(theme.colors.primary)
@@ -230,81 +185,82 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
} label: {
Text("Cancel")
}
} else {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
}
case .local:
searchButton()
default:
EmptyView()
}
case .local:
searchButton()
default:
EmptyView()
}
}
}
}
private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatModel.chatId == groupInfo.id {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
membersLoaded = true
updateView()
}
}
}
private func initChatView() {
let cInfo = chat.chatInfo
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
@@ -365,9 +321,8 @@ struct ChatView: View {
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.thinMaterial)
}
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
}
@@ -388,7 +343,7 @@ struct ChatView: View {
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
let mergedItems = filtered(im.reversedChatItems)
let mergedItems = filtered(chatModel.reversedChatItems)
return GeometryReader { g in
ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
@@ -421,7 +376,7 @@ struct ChatView: View {
loadChat(chat: c)
}
}
.onChange(of: im.reversedChatItems) { _ in
.onChange(of: chatModel.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
}
}
@@ -429,7 +384,7 @@ struct ChatView: View {
@ViewBuilder private func connectingText() -> some View {
if case let .direct(contact) = chat.chatInfo,
!contact.sndReady,
!contact.ready,
contact.active,
!contact.nextSendGrpInv {
Text("connecting…")
@@ -455,7 +410,7 @@ struct ChatView: View {
init() {
unreadChatItemCounts = UnreadChatItemCounts(
isNearBottom: true,
totalBelow: .zero,
unreadBelow: .zero
)
events
@@ -470,9 +425,9 @@ struct ChatView: View {
itemsInView
}
}
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
.removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.unreadChatItemCounts, on: self)
.store(in: &bag)
}
@@ -520,9 +475,11 @@ struct ChatView: View {
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
if let latestUnreadItem = filtered(chatModel.reversedChatItems).last(where: { $0.isRcvNew }) {
scrollModel.scrollToItem(id: latestUnreadItem.id)
}
}
} else if !counts.isNearBottom {
} else if counts.totalBelow > 16 {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
@@ -575,7 +532,7 @@ struct ChatView: View {
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
Image(systemName: "person.crop.circle.badge.plus")
@@ -601,33 +558,6 @@ struct ChatView: View {
}
}
private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) {
guard let count = selectedChatItems?.count, count > 0 else { return }
AlertManager.shared.showAlert(Alert(
title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"),
message: Text(
groupInfo.fullGroupPreferences.fullDelete.on
? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.")
: (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.")
),
primaryButton: .destructive(Text("Delete")) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages)
}
},
secondaryButton: .cancel()
))
}
private func deletedSelectedMessages() async {
await MainActor.run {
withAnimation {
selectedChatItems = nil
}
}
}
private func loadChatItems(_ cInfo: ChatInfo) {
Task {
if loadingItems || firstPage { return }
@@ -638,7 +568,7 @@ struct ChatView: View {
// Load additional items until the page is +50 large after merging
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
let pagination: ChatPagination =
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
if let lastItem = reversedPage.last ?? chatModel.reversedChatItems.last {
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
} else {
.last(count: loadItemsPerPage)
@@ -656,7 +586,7 @@ struct ChatView: View {
if reversedPage.count == 0 {
firstPage = true
} else {
im.reversedChatItems.append(contentsOf: reversedPage)
chatModel.reversedChatItems.append(contentsOf: reversedPage)
}
loadingItems = false
}
@@ -667,20 +597,16 @@ struct ChatView: View {
}
}
func stopAudioPlayer() {
VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.chatView = [:]
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu(
chat: chat,
chatItem: ci,
maxWidth: maxWidth,
itemWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems
chatView: self
)
}
@@ -688,11 +614,13 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
let chatItem: ChatItem
let maxWidth: CGFloat
var chatItem: ChatItem
var maxWidth: CGFloat
@State var itemWidth: CGFloat
@Binding var composeState: ComposeState
@Binding var selectedMember: GMember?
@Binding var revealedChatItem: ChatItem?
var chatView: ChatView
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@@ -702,10 +630,12 @@ struct ChatView: View {
@State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false
@Binding var selectedChatItems: Set<Int64>?
@State private var allowMenu: Bool = true
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
var revealed: Bool { chatItem == revealedChatItem }
var body: some View {
@@ -713,72 +643,35 @@ struct ChatView: View {
let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
let im = ItemsModel.shared
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range]))
let items = Array(zip(Array(range), m.reversedChatItems[range]))
ForEach(items, id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
} else {
chatItemView(chatItem, range, prevItem)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
}
.onAppear {
if let range {
if let items = unreadItems(range) {
waitToMarkRead {
for ci in items {
await apiMarkChatItemRead(chat.chatInfo, ci)
}
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
await apiMarkChatItemRead(chat.chatInfo, chatItem)
}
}
markRead(
chatItems: range.flatMap { m.reversedChatItems[$0] }
?? [chatItem]
)
}
}
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
let im = ItemsModel.shared
let items = range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci } else { nil }
} else {
return nil
}
}
return if items.isEmpty { nil } else { items }
}
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
private func markRead(chatItems: Array<ChatItem>.SubSequence) {
let unreadItems = chatItems.filter { $0.isRcvNew }
if unreadItems.isEmpty { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if m.chatId == chat.chatInfo.id {
Task(operation: op)
Task {
for unreadItem in unreadItems {
await apiMarkChatItemRead(chat.chatInfo, unreadItem)
}
}
}
}
}
@@ -787,81 +680,53 @@ struct ChatView: View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
let t = if memCount == 1 && member.memberRole > .member {
Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
} else {
Text(memberNames(member, prevMember, memCount))
}
t
Text(memberNames(member, prevMember, memCount))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await m.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if chatView.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await chatView.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
.padding(.bottom, 5)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
.padding(.bottom, 5)
chatItemWithMenu(ci, range, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
} else {
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
if chat.chatInfo.chatType == .group {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
} else {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading)
}
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
}
.padding(.bottom, 5)
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
@@ -884,9 +749,11 @@ struct ChatView: View {
chatItem: ci,
maxWidth: maxWidth,
revealed: .constant(revealed),
allowMenu: $allowMenu
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
.modifier(ChatItemClipped(ci))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -896,21 +763,29 @@ struct ChatView: View {
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal, moderate: false)
deleteMessage(.cidmInternal)
}
if let di = deletingItem, di.meta.deletable && !di.localNote {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
deleteMessage(.cidmBroadcast, moderate: false)
Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast)
}
}
}
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages(chat, deletingItems, moderate: false)
deleteMessages()
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear {
if ci.content.msgContent?.isVoice == true {
allowMenu = true
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
@@ -1015,7 +890,7 @@ struct ChatView: View {
if !live || !ci.meta.isLive {
deleteButton(ci)
}
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
moderateButton(ci, groupInfo)
}
} else if ci.meta.itemDeleted != nil {
@@ -1039,10 +914,6 @@ struct ChatView: View {
} else {
EmptyView()
}
if selectedChatItems == nil && ci.canBeDeletedForSelf {
Divider()
selectButton(ci)
}
}
var replyButton: Button<some View> {
@@ -1215,21 +1086,6 @@ struct ChatView: View {
}
}
private func selectButton(_ ci: ChatItem) -> Button<some View> {
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
selectUnselectChatItem(select: true, ci)
}
}
} label: {
Label(
NSLocalizedString("Select", comment: "chat item action"),
systemImage: "checkmark.circle"
)
}
}
private func viewInfoButton(_ ci: ChatItem) -> Button<some View> {
Button {
Task {
@@ -1240,7 +1096,7 @@ struct ChatView: View {
chatItemInfo = ciInfo
}
if case let .group(gInfo) = chat.chatInfo {
await m.loadGroupMembers(gInfo)
await chatView.loadGroupMembers(gInfo)
}
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
@@ -1299,7 +1155,7 @@ struct ChatView: View {
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
itemIds.append(m.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -1340,7 +1196,7 @@ struct ChatView: View {
),
primaryButton: .destructive(Text("Delete")) {
deletingItem = ci
deleteMessage(.cidmBroadcast, moderate: true)
deleteMessage(.cidmBroadcast)
},
secondaryButton: .cancel()
))
@@ -1391,66 +1247,72 @@ struct ChatView: View {
}
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
}
private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
private func deleteMessages() {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
var deletedItems: [ChatItem] = []
for itemId in itemIds {
do {
let (di, _) = try await apiDeleteChatItem(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemId: itemId,
mode: .cidmInternal
)
deletedItems.append(di)
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
await MainActor.run {
for di in deletedItems {
m.removeChatItem(chatInfo, di)
}
}
} else {
itemIds.append(ci.id)
}
} else {
itemIds.append(ci.id)
}
if select {
if let sel = selectedChatItems {
selectedChatItems = sel.union(itemIds)
}
} else {
itemIds.forEach { selectedChatItems?.remove($0) }
}
}
private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) {
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
let r = if case .cidmBroadcast = mode,
moderate,
let (groupInfo, _) = di.memberToModerate(chat.chatInfo) {
try await apiDeleteMemberChatItems(
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
groupId: groupInfo.apiId,
itemIds: [di.id]
groupMemberId: groupMember.groupMemberId,
itemId: di.id
)
} else {
try await apiDeleteChatItems(
(deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemIds: [di.id],
itemId: di.id,
mode: mode
)
}
if let itemDeletion = r.first {
await MainActor.run {
deletingItem = nil
if let toItem = itemDeletion.toChatItem {
_ = m.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = m.upsertChatItem(chat.chatInfo, toItem)
} else {
m.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
@@ -1459,68 +1321,6 @@ struct ChatView: View {
}
}
}
private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme
var ciId: Int64
@Binding var selectedChatItems: Set<Int64>?
@State var checked: Bool = false
var body: some View {
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
.resizable()
.foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel))
.frame(width: 24, height: 24)
.onAppear {
checked = selectedChatItems?.contains(ciId) == true
}
.onChange(of: selectedChatItems) { selected in
checked = selected?.contains(ciId) == true
}
}
}
}
}
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
do {
let deletedItems = if case .cidmBroadcast = mode,
moderate,
case .group = chat.chatInfo {
try await apiDeleteMemberChatItems(
groupId: chatInfo.apiId,
itemIds: itemIds
)
} else {
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemIds: itemIds,
mode: mode
)
}
await MainActor.run {
for di in deletedItems {
if let toItem = di.toChatItem {
_ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
}
}
await onSuccess()
} catch {
logger.error("ChatView.deleteMessages error: \(error.localizedDescription)")
}
}
}
}
@@ -1598,7 +1398,7 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
ItemsModel.shared.reversedChatItems = [
chatModel.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.frame(height: 54)
.frame(height: 50)
.background(theme.appColors.sentMessage)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in
UIImage(base64Encoded: image)
if let data = Data(base64Encoded: dropImagePrefix(image)) {
return UIImage(data: data)
}
return nil
}
if imgs.count == 0 {
ProgressView()
@@ -46,8 +49,8 @@ struct ComposeImageView: View {
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
@@ -10,6 +10,35 @@ import SwiftUI
import LinkPresentation
import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview?
@@ -34,13 +63,14 @@ struct ComposeLinkView: View {
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
@@ -284,10 +284,8 @@ struct ComposeView: View {
var body: some View {
VStack(spacing: 0) {
Divider()
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
ContextInvitingContactMemberView()
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
@@ -295,13 +293,10 @@ struct ComposeView: View {
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
if simplexLinkProhibited {
msgNotAllowedView("SimpleX links not allowed", icon: "link")
Divider()
} else if fileProhibited {
msgNotAllowedView("Files and media not allowed", icon: "doc")
Divider()
} else if voiceProhibited {
msgNotAllowedView("Voice messages not allowed", icon: "mic")
Divider()
}
contextItemView()
switch (composeState.editing, composeState.preview) {
@@ -364,6 +359,7 @@ struct ComposeView: View {
: theme.colors.primary
)
.padding(.trailing, 12)
.background(theme.colors.background)
.disabled(!chat.userCanSend)
if chat.userIsObserver {
@@ -381,7 +377,6 @@ struct ComposeView: View {
}
}
}
.background(.thinMaterial)
.onChange(of: composeState.message) { msg in
if composeState.linkPreviewAllowed {
if msg.count > 0 {
@@ -630,7 +625,6 @@ struct ComposeView: View {
cancelPreview: cancelLinkPreview,
cancelEnabled: !composeState.inProgress
)
Divider()
case let .mediaPreviews(mediaPreviews: media):
ComposeImageView(
images: media.map { (img, _) in img },
@@ -639,7 +633,6 @@ struct ComposeView: View {
chosenMedia = []
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
case let .voicePreview(recordingFileName, _):
ComposeVoiceView(
recordingFileName: recordingFileName,
@@ -652,7 +645,6 @@ struct ComposeView: View {
cancelEnabled: !composeState.editing && !composeState.inProgress,
stopPlayback: $stopPlayback
)
Divider()
case let .filePreview(fileName, _):
ComposeFileView(
fileName: fileName,
@@ -660,7 +652,6 @@ struct ComposeView: View {
composeState = composeState.copy(preview: .noPreview)
},
cancelEnabled: !composeState.editing && !composeState.inProgress)
Divider()
}
}
@@ -670,9 +661,10 @@ struct ComposeView: View {
Text(reason).italic()
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.top, 8)
}
@ViewBuilder private func contextItemView() -> some View {
@@ -686,7 +678,6 @@ struct ComposeView: View {
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
@@ -694,7 +685,6 @@ struct ComposeView: View {
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
Divider()
case let .forwardingItem(chatItem: forwardedItem, _):
ContextItemView(
chat: chat,
@@ -703,7 +693,6 @@ struct ComposeView: View {
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
showSender: false
)
Divider()
}
}
@@ -860,7 +849,6 @@ struct ComposeView: View {
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
@@ -1120,6 +1108,10 @@ struct ComposeView: View {
}
}
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@@ -51,8 +51,8 @@ struct ComposeVoiceView: View {
.padding(.vertical, 1)
.frame(height: ComposeVoiceView.previewHeight)
.background(theme.appColors.sentMessage)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
private func recordingMode() -> some View {
@@ -18,9 +18,10 @@ struct ContextInvitingContactMemberView: View {
Text("Send direct message to connect")
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
.background(theme.appColors.sentMessage)
.padding(.top, 8)
}
}
@@ -43,9 +43,10 @@ struct ContextItemView: View {
.tint(theme.colors.primary)
}
.padding(12)
.frame(minHeight: 54)
.frame(minHeight: 50)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme))
.padding(.top, 8)
}
private func msgContentView(lines: Int) -> some View {
@@ -44,7 +44,6 @@ struct SendMessageView: View {
var body: some View {
ZStack {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
HStack(alignment: .bottom) {
ZStack(alignment: .leading) {
if case .voicePreview = composeState.preview {
@@ -67,6 +66,7 @@ struct SendMessageView: View {
.fixedSize(horizontal: false, vertical: true)
}
}
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
@@ -84,9 +84,10 @@ struct SendMessageView: View {
}
}
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
.overlay(
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
}
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
@@ -257,9 +258,6 @@ struct SendMessageView: View {
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
}
.disabled(disabled)
@@ -313,9 +311,6 @@ struct SendMessageView: View {
}
} label: {
Image(systemName: "mic")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -122,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
.onChange(of: selectedContacts) { _ in
@@ -40,7 +40,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -158,7 +158,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
@@ -207,7 +207,6 @@ struct GroupChatInfoView: View {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
@@ -233,7 +232,8 @@ struct GroupChatInfoView: View {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
.font(.caption)
.foregroundColor(theme.colors.secondary)
@@ -266,16 +266,6 @@ struct GroupChatInfoView: View {
}
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else {
return member.memberStatus.shortText
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -113,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
.onChange(of: groupLinkMemberRole) { _ in
@@ -37,7 +37,7 @@ struct GroupMemberInfoView: View {
case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -76,152 +76,156 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View {
ZStack {
let member = groupMember.wrapped
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
VStack {
let member = groupMember.wrapped
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if member.memberActive {
Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
if member.memberActive {
Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
}
if let contactLink = member.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactLink = member.contactLink {
Section {
SimpleXLinkQRCode(uri: contactLink)
Button {
showShareSheet(items: [simplexChatLink(contactLink)])
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
connectViaAddressButton(contactLink)
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else {
connectViaAddressButton(contactLink)
infoRow("Role", member.memberRole.text)
}
} header: {
Text("Address")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
.foregroundColor(theme.colors.secondary)
}
}
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
if let connStats = connectionStats {
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
alert = .abortSwitchAddressAlert
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
}
}
if groupInfo.membership.memberRole >= .admin {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
Button ("Debug delivery") {
Task {
do {
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
await MainActor.run { alert = .queueInfo(info: info) }
} catch let e {
logger.error("apiContactQueueInfo error: \(responseError(e))")
let a = getErrorAlert(e, "Error")
await MainActor.run { alert = .error(title: a.title, error: a.message) }
}
}
}
}
}
}
}
.navigationBarHidden(true)
.onAppear {
if #unavailable(iOS 16) {
// this condition prevents re-setting picker
if !justOpened { return }
}
justOpened = false
DispatchQueue.main.async {
newRole = member.memberRole
do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
.navigationBarHidden(true)
.onAppear {
if #unavailable(iOS 16) {
// this condition prevents re-setting picker
if !justOpened { return }
}
justOpened = false
DispatchQueue.main.async {
newRole = member.memberRole
do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
}
}
}
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
.onChange(of: member.memberRole) { role in
newRole = role
}
}
.onChange(of: member.memberRole) { role in
newRole = role
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
@@ -237,7 +241,7 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info)
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
+1 -4
View File
@@ -55,7 +55,6 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
// 1. Style
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
@@ -129,9 +128,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
@@ -182,7 +180,6 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
@@ -1,130 +0,0 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
(deleteEnabled, deleteForEveryoneEnabled, moderateEnabled, _, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && !onlyOwnGroupItems && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -9,41 +9,25 @@
import SwiftUI
import SimpleXChat
typealias DynamicSizes = (
rowHeight: CGFloat,
profileImageSize: CGFloat,
mediaSize: CGFloat,
incognitoSize: CGFloat,
chatInfoSize: CGFloat,
unreadCorner: CGFloat,
unreadPadding: CGFloat
)
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
.xSmall: (68, 55, 33, 22, 18, 9, 3),
.small: (72, 57, 34, 22, 18, 9, 3),
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
private let rowHeights: [DynamicTypeSize: CGFloat] = [
.xSmall: 68,
.small: 72,
.medium: 76,
.large: 80,
.xLarge: 88,
.xxLarge: 94,
.xxxLarge: 104,
.accessibility1: 90,
.accessibility2: 100,
.accessibility3: 120,
.accessibility4: 130,
.accessibility5: 140
]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject var chat: Chat
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@@ -54,8 +38,6 @@ struct ChatListNavLink: View {
@State private var inProgress = false
@State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSizes[userFont]?.rowHeight ?? 80 }
var body: some View {
Group {
switch chat.chatInfo {
@@ -88,7 +70,7 @@ struct ChatListNavLink: View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showDeleteContactActionSheet = true
@@ -118,7 +100,7 @@ struct ChatListNavLink: View {
clearChatButton()
}
Button {
if contact.sndReady || !contact.active {
if contact.ready || !contact.active {
showDeleteContactActionSheet = true
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
@@ -128,11 +110,11 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.sndReady && contact.active {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
@@ -157,7 +139,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -177,7 +159,7 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
@@ -196,7 +178,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
@@ -223,7 +205,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -339,7 +321,7 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -367,7 +349,7 @@ struct ChatListNavLink: View {
}
.tint(theme.colors.primary)
}
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.appSheet(isPresented: $showContactConnectionInfo) {
Group {
if case let .contactConnection(contactConnection) = chat.chatInfo {
@@ -487,7 +469,7 @@ struct ChatListNavLink: View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
.frame(height: dynamicRowHeight)
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(json)
@@ -582,11 +564,18 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
}
}
struct ErrorAlert {
var title: LocalizedStringKey
var message: LocalizedStringKey
}
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatResponse,
let alert = getNetworkErrorAlert(r) {
return alert
} else {
switch error as? ChatResponse {
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
default:
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
}
}
@@ -21,7 +21,6 @@ struct ChatListView: View {
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
@@ -163,10 +162,6 @@ struct ChatListView: View {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
}
stopAudioPlayer()
}
.onChange(of: chatModel.currentUser?.userId) { _ in
stopAudioPlayer()
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(theme.colors.secondary)
@@ -222,11 +217,6 @@ struct ChatListView: View {
}
}
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
@@ -277,25 +267,31 @@ struct ChatListView: View {
struct SubsStatusIndicator: View {
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false
@State private var sess: ServerSessions = ServerSessions.newServerSessions
@State private var timer: Timer? = nil
@State private var timerCounter = 0
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
// Constants for the intervals
let initialInterval: TimeInterval = 1.0
let regularInterval: TimeInterval = 3.0
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
var body: some View {
Button {
showServersSummary = true
} label: {
HStack(spacing: 4) {
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: sess)
}
}
}
.onAppear {
startTimer()
startInitialTimer()
}
.onDisappear {
stopTimer()
@@ -305,24 +301,35 @@ struct SubsStatusIndicator: View {
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getSubsTotal()
private func startInitialTimer() {
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
getServersSummary()
timerCounter += 1
// Switch to the regular timer after the initial phase
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
switchToRegularTimer()
}
}
}
func switchToRegularTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
getServersSummary()
}
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func getSubsTotal() {
private func getServersSummary() {
do {
(subs, hasSess) = try getAgentSubsTotal()
let summ = try getAgentServersSummary()
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
} catch let error {
logger.error("getSubsTotal error: \(responseError(error))")
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
@@ -12,24 +12,18 @@ import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@State var deleting: Bool = false
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@State private var activeContentPreview: ActiveContentPreview? = nil
@State private var showFullscreenGallery: Bool = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View {
let cItem = chat.chatItems.last
return HStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
ChatInfoImage(chat: chat, size: 63)
chatPreviewImageOverlayIcon()
.padding([.bottom, .trailing], 1)
}
@@ -49,38 +43,11 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
let chat = activeContentPreview?.chat ?? chat
let ci = activeContentPreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatMessagePreview(cItem)
chatStatusImage()
.padding(.top, dynamicChatInfoSize * 1.44)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Spacer()
@@ -90,33 +57,6 @@ struct ChatPreviewView: View {
.padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
// Stop voice when deleting the chat
if contains, let ci = activeContentPreview?.ci {
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
}
}
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if case .voice = activeContentPreview?.mc, playing == nil {
activeContentPreview = nil
} else if activeContentPreview == nil {
if case .image = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .video = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
} else if case .voice = activeContentPreview?.mc {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeContentPreview = nil
}
} else if !showFullscreenGallery {
activeContentPreview = nil
}
}
}
@@ -173,50 +113,39 @@ struct ChatPreviewView: View {
.kerning(-2)
}
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8)
.padding(.trailing, hasFilePreview ? 38 : 36)
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 8)
.padding(.trailing, 36)
if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy)
} else {
t
}
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.padding(.trailing, 1)
.foregroundColor(theme.colors.secondary.opacity(0.65))
} else {
Color.clear.frame(width: 0)
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
}
}
}
@@ -243,7 +172,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
@@ -267,18 +196,18 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
chatPreviewLayout(messageDraft(draft), draft: true)
} else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(theme.colors.primary)
} else if !contact.sndReady && contact.activeConn != nil {
} else if !contact.ready && contact.activeConn != nil {
if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message")
} else if contact.active {
@@ -296,54 +225,6 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.foregroundColor(Color.white)
.font(.system(size: 15, weight: .black))
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
@@ -372,87 +253,51 @@ struct ChatPreviewView: View {
}
@ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
case let .direct(contact):
if contact.active && contact.activeConn != nil {
switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.frame(width: 17, height: 17)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
case .group:
if progressByTimeout {
ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
default:
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
}
}
}
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
if incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.frame(width: 22, height: 22)
.foregroundColor(secondaryColor)
} else {
EmptyView()
}
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
.padding(.vertical, size / 6)
.padding(.leading, 8)
}
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.padding(.vertical, size / 7)
.padding(.leading, 5)
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
private struct ActiveContentPreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
enum CCInfoAlert: Identifiable {
case deleteInvitationAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: String {
switch self {
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
} success: {
dismiss()
}
case let .error(title, error): return mkAlert(title: title, message: error)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
@@ -13,7 +13,6 @@ struct ContactConnectionView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
@@ -63,7 +62,7 @@ struct ContactConnectionView: View {
ZStack(alignment: .topTrailing) {
Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize)
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
@@ -12,13 +12,12 @@ import SimpleXChat
struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var contactRequest: UserContactRequest
@ObservedObject var chat: Chat
var body: some View {
HStack(spacing: 8) {
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
ChatInfoImage(chat: chat, size: 63)
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -11,7 +11,6 @@ import SimpleXChat
struct ServersSummaryView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var serversSummary: PresentedServersSummary? = nil
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
@State private var selectedServerType: PresentedServerType = .smp
@@ -37,7 +36,6 @@ struct ServersSummaryView: View {
viewBody()
.navigationTitle("Servers info")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
shareButton()
@@ -59,21 +57,11 @@ struct ServersSummaryView: View {
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getServersSummary()
}
getServersSummary()
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
private func stopTimer() {
func stopTimer() {
timer?.invalidate()
timer = nil
}
@@ -102,8 +90,8 @@ struct ServersSummaryView: View {
Group {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
Picker("User selection", selection: $selectedUserCategory) {
Text("All profiles").tag(PresentedUserCategory.allUsers)
Text("Current profile").tag(PresentedUserCategory.currentUser)
Text("All users").tag(PresentedUserCategory.allUsers)
Text("Current user").tag(PresentedUserCategory.currentUser)
}
.pickerStyle(.segmented)
}
@@ -194,22 +182,19 @@ struct ServersSummaryView: View {
}
} else {
Text("No info, try to reload")
.foregroundColor(theme.colors.secondary)
.background(theme.colors.background)
}
}
private func smpSubsSection(_ totals: SMPTotals) -> some View {
Section {
infoRow("Active connections", numOrDash(totals.subs.ssActive))
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total))
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
}
}
}
@@ -279,7 +264,6 @@ struct ServersSummaryView: View {
)
.navigationBarTitle("SMP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.smpServer))
@@ -287,9 +271,9 @@ struct ServersSummaryView: View {
if let subs = srvSumm.subs {
Spacer()
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
}
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
} else if let sess = srvSumm.sessions {
Spacer()
Image(systemName: "arrow.up.circle")
@@ -348,7 +332,6 @@ struct ServersSummaryView: View {
)
.navigationBarTitle("XFTP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.xftpServer))
@@ -377,7 +360,7 @@ struct ServersSummaryView: View {
Button {
alert = SomeAlert(
alert: Alert(
title: Text("Reset all statistics?"),
title: Text("Reset all servers statistics?"),
message: Text("Servers statistics will be reset - this cannot be undone!"),
primaryButton: .destructive(Text("Reset")) {
Task {
@@ -403,16 +386,24 @@ struct ServersSummaryView: View {
Text("Reset all statistics")
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
}
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
@@ -426,18 +417,18 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var hasSess: Bool
var sess: ServerSessions
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
Text("\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -452,12 +443,12 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
? (
subs.ssActive == 0
? (
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
: ( // ssActive > 0
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
sess.ssConnected == 0
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
)
)
: noConnColorAndPercent
@@ -479,7 +470,6 @@ struct SMPServerSummaryView: View {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
@@ -503,16 +493,16 @@ struct SMPServerSummaryView: View {
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
Section {
infoRow("Active connections", numOrDash(subs.ssActive))
infoRow("Connections subscribed", numOrDash(subs.ssActive))
infoRow("Pending", numOrDash(subs.ssPending))
infoRow("Total", numOrDash(subs.total))
reconnectButton()
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
Text("Message subscriptions")
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
}
}
}
@@ -573,7 +563,6 @@ struct SMPStatsView: View {
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
@@ -601,37 +590,33 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
Text("Send errors")
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs))
indentedInfoRow("other", numOrDash(stats._sentOtherErrs))
}
Section("Received messages") {
infoRow("Received total", numOrDash(stats._recvMsgs))
Text("Receive errors")
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates))
indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs))
indentedInfoRow("other errors", numOrDash(stats._recvErrs))
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
Text("Acknowledgement errors")
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs))
}
Section("Connections") {
Section {
infoRow("Created", numOrDash(stats._connCreated))
infoRow("Secured", numOrDash(stats._connCreated))
infoRow("Completed", numOrDash(stats._connCompleted))
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
}
Section {
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
} header: {
Text("Connection notifications")
Text("Connections")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).")
}
@@ -641,19 +626,29 @@ struct DetailedSMPStatsView: View {
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
HStack {
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2)
Spacer()
Group {
if value == 0 && value2 == 0 {
Text(verbatim: "-")
Text("-")
} else {
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
}
}
.foregroundStyle(.secondary)
}
}
private func indentedInfoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(title)
.padding(.leading, 24)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
struct XFTPServerSummaryView: View {
var summary: XFTPServerSummary
var statsStartedAt: Date
@@ -667,7 +662,6 @@ struct XFTPServerSummaryView: View {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
@@ -698,7 +692,6 @@ struct XFTPStatsView: View {
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
@@ -732,8 +725,8 @@ struct DetailedXFTPStatsView: View {
infoRow("Size", prettySize(stats._downloadsSize))
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
Text("Download errors")
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
indentedInfoRow("other", numOrDash(stats._downloadErrs))
} header: {
Text("Downloaded files")
} footer: {
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
case let .migrationError(mtrError):
titleText("Incompatible database version")
fileNameText(dbFile)
Text("Error: ") + Text(mtrErrorDescription(mtrError))
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
}
case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error")
@@ -105,6 +105,15 @@ struct DatabaseErrorView: View {
Text("Migrations: \(ms.joined(separator: ", "))")
}
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
switch err {
case let .noDown(dbMigrations):
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
case let .different(appMigration, dbMigration):
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
}
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
}
@@ -16,10 +16,18 @@ struct ChatInfoImage: View {
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var body: some View {
var iconName: String
switch chat.chatInfo {
case .direct: iconName = "person.crop.circle.fill"
case .group: iconName = "person.2.circle.fill"
case .local: iconName = "folder.circle.fill"
case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill"
}
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: chatIconName(chat.chatInfo),
iconName: iconName,
size: size,
color: iconColor
)
@@ -1,68 +0,0 @@
//
// ChatItemClipShape.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 04/07/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
/// Modifier, which provides clipping mask for ``ChatItemWithMenu`` view
/// and it's previews: (drag interaction, context menu, etc.)
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
init() {
clipShape = ClipShape(
maxCornerRadius: 18
)
}
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View {
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
.clipShape(clipShape)
}
}
@@ -23,10 +23,8 @@ struct ChatViewBackground: ViewModifier {
var image = context.resolve(image)
let rect = CGRectMake(0, 0, size.width, size.height)
func repeatDraw(_ imageScale: CGFloat) {
// Prevent range bounds crash and dividing by zero
if size.height == 0 || size.width == 0 || image.size.height == 0 || image.size.width == 0 { return }
image.shading = .color(tint)
let scale = imageScale * 2.5 // scale wallpaper for iOS
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
for h in 0 ... Int(size.height / image.size.height / scale) {
for w in 0 ... Int(size.width / image.size.width / scale) {
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
@@ -9,6 +9,8 @@
import SwiftUI
import SimpleXChat
let defaultProfileImageCorner = 22.5
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
@@ -19,7 +21,9 @@ struct ProfileImage: View {
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
if let uiImage = UIImage(base64Encoded: imageStr) {
if let image = imageStr,
let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
} else {
let c = color.asAnotherColorFromSecondaryVariant(theme)
@@ -32,6 +36,26 @@ struct ProfileImage: View {
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
extension Color {
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
return self
@@ -0,0 +1,26 @@
//
// VideoUtils.swift
// SimpleX (iOS)
//
// Created by Avently on 25.12.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import AVFoundation
import Foundation
import SimpleXChat
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
@@ -17,37 +17,3 @@ extension View {
}
}
}
extension Notification.Name {
static let chatViewWillBeginScrolling = Notification.Name("chatWillBeginScrolling")
}
struct PrivacyBlur: ViewModifier {
var enabled: Bool = true
@Binding var blurred: Bool
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var blurRadius: Int = 0
func body(content: Content) -> some View {
if blurRadius > 0 {
// parallel ifs are necessary here because otherwise some views flicker,
// e.g. when playing video
content
.blur(radius: blurred && enabled ? CGFloat(blurRadius) * 0.5 : 0)
.overlay {
if (blurred && enabled) {
Color.clear.contentShape(Rectangle())
.onTapGesture {
blurred = false
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
if !blurred {
blurred = true
}
}
} else {
content
}
}
}
@@ -64,7 +64,7 @@ struct LocalAuthView: View {
deleteAppDatabaseAndFiles()
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
ItemsModel.shared.reversedChatItems = []
m.reversedChatItems = []
m.chats = []
m.users = []
_ = kcAppPassword.set(password)
@@ -662,7 +662,7 @@ private struct PassphraseConfirmationView: View {
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
}
}
}
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
case let .migrationError(mtrError):
("Incompatible database version",
nil,
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
nil)
}
default: ("Error", nil, "Unknown error", nil)
@@ -194,7 +194,6 @@ struct AddGroupView: View {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
m.groupMembers = groupMembers.map { GMember.init($0) }
m.populateGroupMembersIndexes()
}
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
case badInvitationError
case badVersionError(version: String?)
case desktopDisconnectedError
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
case .desktopDisconnectedError:
Alert(title: Text("Connection terminated"))
case let .error(title, error):
mkAlert(title: title, message: error)
Alert(title: Text(title), message: Text(error))
}
}
.interactiveDismissDisabled(m.activeRemoteCtrl)
@@ -32,7 +32,6 @@ extension AppSettings {
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
if let val = privacyMediaBlurRadius { def.setValue(val, forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) }
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
@@ -63,7 +62,6 @@ extension AppSettings {
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
c.privacyMediaBlurRadius = def.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS)
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -76,7 +74,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
@@ -143,8 +143,7 @@ struct AppearanceSettings: View {
Text("Themes")
.foregroundColor(theme.colors.secondary)
}
.onChange(of: profileImageCornerRadius) { cornerRadius in
profileImageCornerRadiusGroupDefault.set(cornerRadius)
.onChange(of: profileImageCornerRadius) { _ in
saveThemeToDatabase(nil)
}
.onChange(of: colorMode) { mode in
@@ -306,16 +305,13 @@ struct ChatThemePreview: View {
let view = VStack {
if withMessages {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)), revealed: Binding.constant(false))
.frame(alignment: .trailing)
}
} else {
@@ -751,7 +747,7 @@ struct ThemeDestinationPicker: View {
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
Picker("Apply to", selection: $themeUserDest) {
Picker("Apply to mode", selection: $themeUserDest) {
ForEach(values, id: \.0) { (_, text) in
Text(text)
}
@@ -32,6 +32,7 @@ struct NetworkAndServers: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@@ -61,6 +62,8 @@ struct NetworkAndServers: View {
Text("XFTP servers")
}
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
@@ -22,7 +22,6 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var privacyMediaBlurRadius: Int = 0
@State private var contactReceipts = false
@State private var contactReceiptsReset = false
@State private var contactReceiptsOverrides = 0
@@ -70,9 +69,6 @@ struct PrivacySettings: View {
Section {
settingsRow("network", color: theme.colors.secondary) {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
}
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
@@ -117,22 +113,6 @@ struct PrivacySettings: View {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("circle.rectangle.filled.pattern.diagonalline", color: theme.colors.secondary) {
Picker("Blur media", selection: $privacyMediaBlurRadius) {
let values = [0, 12, 24, 48] + ([0, 12, 24, 48].contains(privacyMediaBlurRadius) ? [] : [privacyMediaBlurRadius])
ForEach(values, id: \.self) { radius in
let text: String = switch radius {
case 0: NSLocalizedString("Off", comment: "blur media")
case 12: NSLocalizedString("Soft", comment: "blur media")
case 24: NSLocalizedString("Medium", comment: "blur media")
case 48: NSLocalizedString("Strong", comment: "blur media")
default: "\(radius)"
}
Text(text)
}
}
}
.frame(height: 36)
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
@@ -368,7 +348,6 @@ struct SimplexLockView: View {
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
@AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false
@State private var performLAToggleReset = false
@State private var performLAModeReset = false
@State private var performLASelfDestructReset = false
@@ -440,12 +419,6 @@ struct SimplexLockView: View {
}
}
if performLA {
Section("Share to SimpleX") {
Toggle("Allow sharing", isOn: $allowShareExtension)
}
}
if performLA && laMode == .passcode {
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
Toggle(isOn: $selfDestruct) {
@@ -470,7 +443,6 @@ struct SimplexLockView: View {
}
}
.onChange(of: performLA) { performLAToggle in
performLAGroupDefault.set(performLAToggle)
prefLANoticeShown = true
if performLAToggleReset {
performLAToggleReset = false
@@ -15,6 +15,7 @@ struct ProtocolServerView: View {
let serverProtocol: ServerProtocol
@Binding var server: ServerCfg
@State var serverToEdit: ServerCfg
@State var serverEnabled: Bool
@State private var showTestFailure = false
@State private var testing = false
@State private var testFailure: ProtocolTestFailure?
@@ -112,10 +113,10 @@ struct ProtocolServerView: View {
Spacer()
showTestStatus(server: serverToEdit)
}
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
.disabled(useForNewDisabled)
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
Toggle("Use for new connections", isOn: $serverEnabled)
.onChange(of: serverEnabled) { enabled in
serverToEdit.enabled = enabled ? .enabled : .disabled
}
}
}
}
@@ -175,12 +176,17 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
}
}
func serverHostname(_ srv: String) -> String {
parseServerAddress(srv)?.hostnames.first ?? srv
}
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServerView(
serverProtocol: .smp,
server: Binding.constant(ServerCfg.sampleData.custom),
serverToEdit: ServerCfg.sampleData.custom
serverToEdit: ServerCfg.sampleData.custom,
serverEnabled: true
)
}
}
@@ -18,9 +18,8 @@ struct ProtocolServersView: View {
@Environment(\.editMode) private var editMode
let serverProtocol: ServerProtocol
@State private var currServers: [ServerCfg] = []
@State private var presetServers: [ServerCfg] = []
@State private var configuredServers: [ServerCfg] = []
@State private var otherServers: [ServerCfg] = []
@State private var presetServers: [String] = []
@State private var servers: [ServerCfg] = []
@State private var selectedServer: String? = nil
@State private var showAddServer = false
@State private var showScanProtoServer = false
@@ -54,53 +53,31 @@ struct ProtocolServersView: View {
private func protocolServersView() -> some View {
List {
if !configuredServers.isEmpty {
Section {
ForEach($configuredServers) { srv in
protocolServerView(srv)
}
.onMove { indexSet, offset in
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
configuredServers.remove(atOffsets: indexSet)
}
} header: {
Text("Configured \(proto) servers")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
}
if !otherServers.isEmpty {
Section {
ForEach($otherServers) { srv in
protocolServerView(srv)
}
.onMove { indexSet, offset in
otherServers.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
otherServers.remove(atOffsets: indexSet)
}
} header: {
Text("Other \(proto) servers")
.foregroundColor(theme.colors.secondary)
}
}
Section {
Button("Add server") {
ForEach($servers) { srv in
protocolServerView(srv)
}
.onMove { indexSet, offset in
servers.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
servers.remove(atOffsets: indexSet)
}
Button("Add server…") {
showAddServer = true
}
} header: {
Text("\(proto) servers")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
.lineLimit(10)
}
Section {
Button("Reset") { partitionServers(currServers) }
.disabled(Set(allServers) == Set(currServers) || testing)
Button("Reset") { servers = currServers }
.disabled(servers == currServers || testing)
Button("Test servers", action: testServers)
.disabled(testing || allServersDisabled)
Button("Save servers", action: saveServers)
@@ -109,17 +86,17 @@ struct ProtocolServersView: View {
}
}
.toolbar { EditButton() }
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
Button("Enter server manually") {
otherServers.append(ServerCfg.empty)
selectedServer = allServers.last?.id
servers.append(ServerCfg.empty)
selectedServer = servers.last?.id
}
Button("Scan server QR code") { showScanProtoServer = true }
Button("Add preset servers", action: addAllPresets)
.disabled(hasAllPresets())
}
.sheet(isPresented: $showScanProtoServer) {
ScanProtocolServer(servers: $otherServers)
ScanProtocolServer(servers: $servers)
.modifier(ThemedBackground(grouped: true))
}
.modifier(BackButton(disabled: Binding.constant(false)) {
@@ -156,39 +133,27 @@ struct ProtocolServersView: View {
}
.onAppear {
// this condition is needed to prevent re-setting the servers when exiting single server view
if justOpened {
do {
let r = try getUserProtoServers(serverProtocol)
currServers = r.protoServers
presetServers = r.presetServers
partitionServers(currServers)
} catch let error {
alert = .error(
title: "Error loading \(proto) servers",
error: "Error: \(responseError(error))"
)
}
justOpened = false
} else {
partitionServers(allServers)
if !justOpened { return }
do {
let r = try getUserProtoServers(serverProtocol)
currServers = r.protoServers
presetServers = r.presetServers
servers = currServers
} catch let error {
alert = .error(
title: "Error loading \(proto) servers",
error: "Error: \(responseError(error))"
)
}
justOpened = false
}
}
private func partitionServers(_ servers: [ServerCfg]) {
configuredServers = servers.filter { $0.preset || $0.enabled }
otherServers = servers.filter { !($0.preset || $0.enabled) }
}
private var allServers: [ServerCfg] {
configuredServers + otherServers
}
private var saveDisabled: Bool {
allServers.isEmpty ||
Set(allServers) == Set(currServers) ||
servers.isEmpty ||
servers == currServers ||
testing ||
!allServers.allSatisfy { srv in
!servers.allSatisfy { srv in
if let address = parseServerAddress(srv.server) {
return uniqueAddress(srv, address)
}
@@ -198,7 +163,7 @@ struct ProtocolServersView: View {
}
private var allServersDisabled: Bool {
allServers.allSatisfy { !$0.enabled }
servers.allSatisfy { $0.enabled != .enabled }
}
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
@@ -207,7 +172,8 @@ struct ProtocolServersView: View {
ProtocolServerView(
serverProtocol: serverProtocol,
server: server,
serverToEdit: srv
serverToEdit: srv,
serverEnabled: srv.enabled == .enabled
)
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
.modifier(ThemedBackground(grouped: true))
@@ -221,7 +187,7 @@ struct ProtocolServersView: View {
invalidServer()
} else if !uniqueAddress(srv, address) {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
} else if !srv.enabled {
} else if srv.enabled != .enabled {
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
} else {
showTestStatus(server: srv)
@@ -234,7 +200,7 @@ struct ProtocolServersView: View {
.padding(.trailing, 4)
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
if srv.enabled {
if srv.enabled == .enabled {
v
} else {
v.foregroundColor(theme.colors.secondary)
@@ -261,7 +227,7 @@ struct ProtocolServersView: View {
}
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
allServers.allSatisfy { srv in
servers.allSatisfy { srv in
address.hostnames.allSatisfy { host in
srv.id == s.id || !srv.server.contains(host)
}
@@ -275,13 +241,13 @@ struct ProtocolServersView: View {
private func addAllPresets() {
for srv in presetServers {
if !hasPreset(srv) {
configuredServers.append(srv)
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: .enabled))
}
}
}
private func hasPreset(_ srv: ServerCfg) -> Bool {
allServers.contains(where: { $0.server == srv.server })
private func hasPreset(_ srv: String) -> Bool {
servers.contains(where: { $0.server == srv })
}
private func testServers() {
@@ -299,31 +265,19 @@ struct ProtocolServersView: View {
}
private func resetTestStatus() {
for i in 0..<configuredServers.count {
if configuredServers[i].enabled {
configuredServers[i].tested = nil
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
otherServers[i].tested = nil
for i in 0..<servers.count {
if servers[i].enabled == .enabled {
servers[i].tested = nil
}
}
}
private func runServersTest() async -> [String: ProtocolTestFailure] {
var fs: [String: ProtocolTestFailure] = [:]
for i in 0..<configuredServers.count {
if configuredServers[i].enabled {
if let f = await testServerConnection(server: $configuredServers[i]) {
fs[serverHostname(configuredServers[i].server)] = f
}
}
}
for i in 0..<otherServers.count {
if otherServers[i].enabled {
if let f = await testServerConnection(server: $otherServers[i]) {
fs[serverHostname(otherServers[i].server)] = f
for i in 0..<servers.count {
if servers[i].enabled == .enabled {
if let f = await testServerConnection(server: $servers[i]) {
fs[serverHostname(servers[i].server)] = f
}
}
}
@@ -333,9 +287,9 @@ struct ProtocolServersView: View {
func saveServers() {
Task {
do {
try await setUserProtoServers(serverProtocol, servers: allServers)
try await setUserProtoServers(serverProtocol, servers: servers)
await MainActor.run {
currServers = allServers
currServers = servers
editMode?.wrappedValue = .inactive
}
} catch let error {
@@ -40,7 +40,7 @@ struct ScanProtocolServer: View {
switch resp {
case let .success(r):
if parseServerAddress(r.string) != nil {
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: .enabled))
dismiss()
} else {
showAddressError = true
@@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
let DEFAULT_LA_MODE = "localAuthenticationMode"
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
@@ -28,13 +28,12 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
@@ -88,7 +87,6 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
DEFAULT_EXPERIMENTAL_CALLS: false,
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
DEFAULT_DEVELOPER_TOOLS: false,
@@ -165,9 +163,6 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def
func setGroupDefaults() {
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
performLAGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA))
privacyLinkPreviewsGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS))
profileImageCornerRadiusGroupDefault.set(UserDefaults.standard.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS))
}
public class StringDefault {
@@ -30,7 +30,7 @@ struct UserAddressView: View {
case deleteAddress
case profileAddress(on: Bool)
case shareOnCreate
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -185,7 +185,7 @@ struct UserAddressView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
}
@@ -30,7 +30,7 @@ struct UserProfilesView: View {
case hiddenProfilesNotice
case muteProfileAlert
case activateUserError(error: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
switch self {
@@ -172,7 +172,7 @@ struct UserProfilesView: View {
message: Text(err)
)
case let .error(title, error):
return mkAlert(title: title, message: error)
return Alert(title: Text(title), message: Text(error))
}
}
}
@@ -367,8 +367,8 @@
<source>Add servers by scanning QR codes.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add to another device" xml:space="preserve">
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "bg",
"toolInfo" : {
"toolBuildNumber" : "15F31d",
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.4"
"toolVersion" : "15.0"
},
"version" : "1.0"
}
@@ -386,8 +386,8 @@
<source>Add servers by scanning QR codes.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add to another device" xml:space="preserve">
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "cs",
"toolInfo" : {
"toolBuildNumber" : "15F31d",
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.4"
"toolVersion" : "15.0"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "de",
"toolInfo" : {
"toolBuildNumber" : "15F31d",
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.4"
"toolVersion" : "15.0"
},
"version" : "1.0"
}
@@ -336,8 +336,8 @@ Available in v5.1</source>
<source>Add servers by scanning QR codes.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<trans-unit id="Add server" xml:space="preserve">
<source>Add server</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Add to another device" xml:space="preserve">
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "en",
"toolInfo" : {
"toolBuildNumber" : "15F31d",
"toolBuildNumber" : "15A240d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.4"
"toolVersion" : "15.0"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,6 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -1,7 +0,0 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -1,7 +0,0 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/

Some files were not shown because too many files have changed in this diff Show More