mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a0921c093 | |||
| 3740805125 | |||
| b0e0b0beb8 | |||
| c823a4fa6c | |||
| 3c694e2841 | |||
| c159c2ede3 | |||
| 1d0d7bbd01 | |||
| 82c4d77c73 | |||
| 25938af62f | |||
| edfcced1fa | |||
| 5bff5a855e | |||
| 46a60d979b | |||
| f6ef57534f | |||
| 1e4479f736 | |||
| 789c762c81 | |||
| e9baeba31f | |||
| 2d5bbcdd61 | |||
| c3f67aff69 | |||
| 7cb3a499b2 | |||
| 32e7fd72d3 | |||
| 915bbed400 | |||
| 2ae5a8bffd | |||
| cd1550a14d | |||
| c72c461306 | |||
| 38fa4c231f | |||
| cb683d0706 | |||
| 84ae39b012 | |||
| 6a12f2dec8 | |||
| d1f704d160 | |||
| 9871ebb3b1 | |||
| 02c404593c | |||
| 1d9c5b7a0b | |||
| e20a4e54c3 | |||
| 1f8c69ec23 | |||
| 35c37263b8 | |||
| 9b70599cc5 | |||
| a4a11f99d2 | |||
| 7c8955fcdb | |||
| f922064f5c | |||
| 110839627f | |||
| c90b71120f | |||
| 3ec029e489 | |||
| f4c799aced | |||
| d970470702 | |||
| 9ee74bd36e | |||
| 0a4ff2e35f | |||
| a3550df893 | |||
| 3778698a6e | |||
| cf7a16e857 | |||
| bef1597fa1 | |||
| f06118835d | |||
| fa9e50a904 | |||
| 932a65630c | |||
| b649b64999 | |||
| e94ead3a4f | |||
| 42d7c20a47 | |||
| cf8fd2ea11 | |||
| 5006742577 | |||
| 38d46891a1 | |||
| 6a7e573b42 | |||
| b31a74567d | |||
| b3f0e71ded | |||
| ed50623ef5 | |||
| 53368713a0 | |||
| 5739b94808 | |||
| 0782408682 | |||
| 41576f80e7 | |||
| 2503a86f07 | |||
| dc713268e8 | |||
| d0c52e43a2 | |||
| 2e8e6cef7e | |||
| 239c815f3e | |||
| 1a6245fe51 | |||
| b3d15f97f0 | |||
| c0b8cfb3e2 | |||
| 818161c6ed | |||
| 4c53620dfa | |||
| ea5afb28d3 | |||
| 060760675d | |||
| 75101ac885 | |||
| 159f66e812 | |||
| 6d19b48979 | |||
| ce73a15787 | |||
| e6545a1747 | |||
| 601b081cab | |||
| d31dedf132 | |||
| 7441ed9892 | |||
| f6ee6338c4 | |||
| 5a42c0c1d2 | |||
| 240131a023 | |||
| 37e275c3ca | |||
| f893ad15de | |||
| a0763b3a43 | |||
| e6ba82c8ff | |||
| a8bdf1555f | |||
| d278b3dbcd | |||
| ac4b1e9406 | |||
| b04f159970 | |||
| 55331289d3 | |||
| b2b1519aea | |||
| e769abf14a | |||
| 9ca7031f82 | |||
| 7a418918d6 | |||
| 8f1302e1c6 | |||
| ee7c5d2aac | |||
| bf697c722a | |||
| f30dfa0be7 | |||
| 9c73ef9769 | |||
| e38db7fb44 | |||
| cb76c8079c | |||
| 8fbba16f53 | |||
| 5384e2826d | |||
| 0975079a93 | |||
| 74e2b7582e | |||
| 413a8f6b76 | |||
| 619cff91b2 | |||
| 2ee16f9398 | |||
| bfab2d9fb6 | |||
| 229ea80499 | |||
| 95de471754 | |||
| cbc86cd81e | |||
| 19cab39ee8 | |||
| 6fa3695ad6 | |||
| 6e6afdbd25 | |||
| 93e88c3953 | |||
| 676b533393 | |||
| e1fb0ac0b1 | |||
| 8bda64a5c1 | |||
| ab058d7222 | |||
| 4f9c53f561 | |||
| 5257c6f9ca | |||
| 6729b1fb4f | |||
| ac5351a752 | |||
| 2ff4619ca4 | |||
| 493ad14b39 | |||
| 7f08f87ee4 | |||
| ce1b66cef2 | |||
| 3e18fdc34d | |||
| c09075d71e | |||
| 41b4d7851a | |||
| 92bec5eabb | |||
| 24587ecc92 | |||
| 6865515f43 | |||
| 5ee6f40e75 | |||
| 637189cc2d | |||
| 13bfc9e92b | |||
| 032c5d3a5b | |||
| a53333be20 | |||
| a966f6b19d | |||
| 70d577260b | |||
| ea12982788 | |||
| 6fca6c22c5 | |||
| e343dd017b | |||
| 70a94d772a | |||
| 2689d1e27b | |||
| 4a9b54fbaf | |||
| f10a0ce58e | |||
| 6d488ba489 | |||
| 95776e0951 | |||
| bf9cdf3053 | |||
| fa73e63a79 | |||
| bfab76ed90 | |||
| 905295ee5f | |||
| a1e707ac1b | |||
| fea51b75e9 | |||
| ff8bbf11e7 | |||
| 391e9d57f2 | |||
| dae9f8575d | |||
| 065d9be614 | |||
| 4251762553 | |||
| c7017b3efc | |||
| b4ae3b9431 | |||
| 7138b09bae | |||
| bf59402856 | |||
| c6e5d6e2b8 | |||
| 0847b725b3 | |||
| 0d6f43a5ac | |||
| 5a014ba410 | |||
| 99888f9d5f | |||
| 670bf34ff5 | |||
| 3e873fcb32 | |||
| 23f24b1677 | |||
| 17526fa385 | |||
| 70204e071d | |||
| b348979b32 | |||
| 71ad8f2fd1 | |||
| 2dff94cbb4 | |||
| a73abfe642 | |||
| 859fa0bc22 | |||
| 41c4f13939 | |||
| f84ac713d7 | |||
| f41c04735b | |||
| a8da9b9cd9 | |||
| 64a0f509f7 | |||
| c4f8a50f0d | |||
| 93a4c0854e | |||
| 49c29c74df | |||
| e6ee5df158 | |||
| f4be0278b6 | |||
| a9d2535292 | |||
| 3e623684bc | |||
| fd90b47194 | |||
| acd3467d10 | |||
| 71ce598355 | |||
| 63393eaf0b | |||
| f90de83215 | |||
| 1c10209a31 | |||
| 5d7abf31ce | |||
| 44c0861fe4 | |||
| 1e6dc8002c |
@@ -18,7 +18,7 @@
|
|||||||
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
2. ↔️ [Connect to the team](#connect-to-the-team), [join user groups](#join-user-groups) and [follow our updates](#follow-our-updates).
|
||||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
|
||||||
|
|
||||||
[Learn more about SimpleX Chat](#contents).
|
[Learn more about SimpleX Chat](#contents).
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ We would love to have you join the development! You can help us with:
|
|||||||
- contributing to SimpleX Chat knowledge-base.
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
|
|
||||||
## Help us with donations
|
## Please support us with your donations
|
||||||
|
|
||||||
Huge thank you to everybody who donated to SimpleX Chat!
|
Huge thank you to everybody who donated to SimpleX Chat!
|
||||||
|
|
||||||
@@ -233,6 +233,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
Recent and important updates:
|
Recent and important updates:
|
||||||
|
|
||||||
|
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
|
||||||
|
|
||||||
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
|
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
|
||||||
|
|
||||||
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
|
||||||
|
|||||||
@@ -15,31 +15,12 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||||
application.registerForRemoteNotifications()
|
application.registerForRemoteNotifications()
|
||||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||||
removePasscodesIfReinstalled()
|
removePasscodesIfReinstalled()
|
||||||
prepareForLaunch()
|
prepareForLaunch()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
private func trackKeyboard() {
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
@objc func keyboardWillShow(_ notification: Notification) {
|
|
||||||
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
||||||
ChatModel.shared.keyboardHeight = keyboardFrame.cgRectValue.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
@objc func keyboardWillHide(_ notification: Notification) {
|
|
||||||
ChatModel.shared.keyboardHeight = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pasteboardChanged() {
|
@objc func pasteboardChanged() {
|
||||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,86 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
|||||||
items.append(item)
|
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() }
|
||||||
|
}
|
||||||
|
var itemAdded = false {
|
||||||
|
willSet { publisher.send() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishes directly to `objectWillChange` publisher,
|
||||||
|
// this will cause reversedChatItems to be rendered without throttling
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var showLoadingProgress = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
publisher
|
||||||
|
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.objectWillChange.send() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||||
|
let navigationTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 250_000000)
|
||||||
|
await MainActor.run {
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
let progressTimeout = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 1500_000000)
|
||||||
|
await MainActor.run { showLoadingProgress = true }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
if let chat = ChatModel.shared.getChat(chatId) {
|
||||||
|
await MainActor.run { self.isLoading = true }
|
||||||
|
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||||
|
await loadChat(chat: chat)
|
||||||
|
navigationTimeout.cancel()
|
||||||
|
progressTimeout.cancel()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isLoading = false
|
||||||
|
self.showLoadingProgress = false
|
||||||
|
willNavigate()
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkModel: ObservableObject {
|
||||||
|
// map of connections network statuses, key is agent connection id
|
||||||
|
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||||
|
|
||||||
|
static let shared = NetworkModel()
|
||||||
|
|
||||||
|
private init() { }
|
||||||
|
|
||||||
|
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
||||||
|
if let conn = contact.activeConn {
|
||||||
|
networkStatuses[conn.agentConnId] ?? .unknown
|
||||||
|
} else {
|
||||||
|
.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class ChatModel: ObservableObject {
|
final class ChatModel: ObservableObject {
|
||||||
@Published var onboardingStage: OnboardingStage?
|
@Published var onboardingStage: OnboardingStage?
|
||||||
@Published var setDeliveryReceipts = false
|
@Published var setDeliveryReceipts = false
|
||||||
@@ -65,14 +145,13 @@ final class ChatModel: ObservableObject {
|
|||||||
// list of chat "previews"
|
// list of chat "previews"
|
||||||
@Published var chats: [Chat] = []
|
@Published var chats: [Chat] = []
|
||||||
@Published var deletedChats: Set<String> = []
|
@Published var deletedChats: Set<String> = []
|
||||||
// map of connections network statuses, key is agent connection id
|
|
||||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
|
||||||
// current chat
|
// current chat
|
||||||
@Published var chatId: String?
|
@Published var chatId: String?
|
||||||
@Published var reversedChatItems: [ChatItem] = []
|
|
||||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||||
@Published var chatToTop: String?
|
@Published var chatToTop: String?
|
||||||
@Published var groupMembers: [GMember] = []
|
@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
|
// items in the terminal view
|
||||||
@Published var showingTerminal = false
|
@Published var showingTerminal = false
|
||||||
@Published var terminalItems: [TerminalItem] = []
|
@Published var terminalItems: [TerminalItem] = []
|
||||||
@@ -104,8 +183,6 @@ final class ChatModel: ObservableObject {
|
|||||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||||
@Published var draft: ComposeState?
|
@Published var draft: ComposeState?
|
||||||
@Published var draftChatId: String?
|
@Published var draftChatId: String?
|
||||||
// tracks keyboard height via subscription in AppDelegate
|
|
||||||
@Published var keyboardHeight: CGFloat = 0
|
|
||||||
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
|
||||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||||
|
|
||||||
@@ -115,6 +192,8 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
static let shared = ChatModel()
|
static let shared = ChatModel()
|
||||||
|
|
||||||
|
let im = ItemsModel.shared
|
||||||
|
|
||||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||||
|
|
||||||
let ntfEnableLocal = true
|
let ntfEnableLocal = true
|
||||||
@@ -180,18 +259,47 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func populateGroupMembersIndexes() {
|
||||||
|
groupMembersIndexes.removeAll()
|
||||||
|
for (i, member) in groupMembers.enumerated() {
|
||||||
|
groupMembersIndexes[member.groupMemberId] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getChatIndex(_ id: String) -> Int? {
|
private func getChatIndex(_ id: String) -> Int? {
|
||||||
chats.firstIndex(where: { $0.id == id })
|
chats.firstIndex(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func addChat(_ chat: Chat, at position: Int = 0) {
|
func addChat(_ chat: Chat) {
|
||||||
withAnimation {
|
if chatId == nil {
|
||||||
chats.insert(chat, at: position)
|
withAnimation { addChat_(chat, at: 0) }
|
||||||
|
} else {
|
||||||
|
addChat_(chat, at: 0)
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addChat_(_ chat: Chat, at position: Int = 0) {
|
||||||
|
chats.insert(chat, at: position)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||||
@@ -265,10 +373,11 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addChat(Chat(c), at: i)
|
addChat_(Chat(c), at: i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||||
|
popChatCollector.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// func addGroup(_ group: SimpleXChat.Group) {
|
// func addGroup(_ group: SimpleXChat.Group) {
|
||||||
@@ -276,6 +385,12 @@ final class ChatModel: ObservableObject {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
|
// mark chat non deleted
|
||||||
|
if case let .direct(contact) = cInfo, contact.chatDeleted {
|
||||||
|
var updatedContact = contact
|
||||||
|
updatedContact.chatDeleted = false
|
||||||
|
updateContact(updatedContact)
|
||||||
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let i = getChatIndex(cInfo.id) {
|
if let i = getChatIndex(cInfo.id) {
|
||||||
chats[i].chatItems = switch cInfo {
|
chats[i].chatItems = switch cInfo {
|
||||||
@@ -293,18 +408,9 @@ final class ChatModel: ObservableObject {
|
|||||||
[cItem]
|
[cItem]
|
||||||
}
|
}
|
||||||
if case .rcvNew = cItem.meta.itemStatus {
|
if case .rcvNew = cItem.meta.itemStatus {
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||||
increaseUnreadCounter(user: currentUser!)
|
|
||||||
}
|
|
||||||
if i > 0 {
|
|
||||||
if chatId == nil {
|
|
||||||
withAnimation { popChat_(i) }
|
|
||||||
} else if chatId == cInfo.id {
|
|
||||||
chatToTop = cInfo.id
|
|
||||||
} else {
|
|
||||||
popChat_(i)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
popChatCollector.throttlePopChat(cInfo.id, currentPosition: i)
|
||||||
} else {
|
} else {
|
||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
}
|
}
|
||||||
@@ -319,7 +425,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var res: Bool
|
var res: Bool
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
if let pItem = chat.chatItems.last {
|
if let pItem = chat.chatItems.last {
|
||||||
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
||||||
chat.chatItems = [cItem]
|
chat.chatItems = [cItem]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -330,6 +436,9 @@ final class ChatModel: ObservableObject {
|
|||||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||||
res = true
|
res = true
|
||||||
}
|
}
|
||||||
|
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||||
}
|
}
|
||||||
@@ -346,7 +455,8 @@ final class ChatModel: ObservableObject {
|
|||||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||||
ci.meta.itemStatus = status
|
ci.meta.itemStatus = status
|
||||||
}
|
}
|
||||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||||
|
im.itemAdded = true
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -370,17 +480,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||||
reversedChatItems[i] = cItem
|
im.reversedChatItems[i] = cItem
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||||
if cItem.isRcvNew {
|
if cItem.isRcvNew {
|
||||||
decreaseUnreadCounter(cInfo)
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||||
}
|
}
|
||||||
// update previews
|
// update previews
|
||||||
if let chat = getChat(cInfo.id) {
|
if let chat = getChat(cInfo.id) {
|
||||||
@@ -392,23 +502,24 @@ final class ChatModel: ObservableObject {
|
|||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
if let i = getChatItemIndex(cItem) {
|
if let i = getChatItemIndex(cItem) {
|
||||||
_ = withAnimation {
|
_ = withAnimation {
|
||||||
self.reversedChatItems.remove(at: i)
|
im.reversedChatItems.remove(at: i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||||
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||||
if previous {
|
if previous {
|
||||||
while i < reversedChatItems.count - 1 {
|
while i < im.reversedChatItems.count - 1 {
|
||||||
i += 1
|
i += 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while i > 0 {
|
while i > 0 {
|
||||||
i -= 1
|
i -= 1
|
||||||
if let res = map(reversedChatItems[i]) { return res }
|
if let res = map(im.reversedChatItems[i]) { return res }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -439,7 +550,8 @@ final class ChatModel: ObservableObject {
|
|||||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
reversedChatItems.insert(cItem, at: 0)
|
im.reversedChatItems.insert(cItem, at: 0)
|
||||||
|
im.itemAdded = true
|
||||||
}
|
}
|
||||||
return cItem
|
return cItem
|
||||||
}
|
}
|
||||||
@@ -447,15 +559,15 @@ final class ChatModel: ObservableObject {
|
|||||||
func removeLiveDummy(animated: Bool = true) {
|
func removeLiveDummy(animated: Bool = true) {
|
||||||
if hasLiveDummy {
|
if hasLiveDummy {
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
withAnimation { _ = im.reversedChatItems.removeFirst() }
|
||||||
} else {
|
} else {
|
||||||
_ = reversedChatItems.removeFirst()
|
_ = im.reversedChatItems.removeFirst()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hasLiveDummy: Bool {
|
private var hasLiveDummy: Bool {
|
||||||
reversedChatItems.first?.isLiveDummy == true
|
im.reversedChatItems.first?.isLiveDummy == true
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||||
@@ -472,7 +584,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||||
var j = i
|
var j = i
|
||||||
while j < reversedChatItems.count {
|
while j < im.reversedChatItems.count {
|
||||||
markChatItemRead_(j)
|
markChatItemRead_(j)
|
||||||
j += 1
|
j += 1
|
||||||
}
|
}
|
||||||
@@ -486,7 +598,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
var j = i - 1
|
var j = i - 1
|
||||||
while j >= 0 {
|
while j >= 0 {
|
||||||
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
|
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||||
unreadBelow += 1
|
unreadBelow += 1
|
||||||
}
|
}
|
||||||
j -= 1
|
j -= 1
|
||||||
@@ -521,53 +633,146 @@ final class ChatModel: ObservableObject {
|
|||||||
// clear current chat
|
// clear current chat
|
||||||
if chatId == cInfo.id {
|
if chatId == cInfo.id {
|
||||||
chatItemStatuses = [:]
|
chatItemStatuses = [:]
|
||||||
reversedChatItems = []
|
im.reversedChatItems = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
if chatId == cInfo.id,
|
||||||
if reversedChatItems[i].isRcvNew {
|
let itemIndex = getChatItemIndex(cItem),
|
||||||
// update current chat
|
im.reversedChatItems[itemIndex].isRcvNew {
|
||||||
markChatItemRead_(i)
|
await MainActor.run {
|
||||||
// update preview
|
withTransaction(Transaction()) {
|
||||||
decreaseUnreadCounter(cInfo)
|
// update current chat
|
||||||
|
markChatItemRead_(itemIndex)
|
||||||
|
// update preview
|
||||||
|
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let unreadCollector = UnreadCollector()
|
||||||
|
|
||||||
|
class UnreadCollector {
|
||||||
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var unreadCounts: [ChatId: Int] = [:]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.debounce(for: 1, scheduler: DispatchQueue.main)
|
||||||
|
.sink {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
for (chatId, count) in self.unreadCounts {
|
||||||
|
if let i = m.getChatIndex(chatId) {
|
||||||
|
m.changeUnreadCounter(i, by: count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.unreadCounts = [:]
|
||||||
|
}
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
|
||||||
|
}
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let popChatCollector = PopChatCollector()
|
||||||
|
|
||||||
|
class PopChatCollector {
|
||||||
|
private let subject = PassthroughSubject<Void, Never>()
|
||||||
|
private var bag = Set<AnyCancellable>()
|
||||||
|
private var chatsToPop: [ChatId: Date] = [:]
|
||||||
|
private let popTsComparator = KeyPathComparator<Chat>(\.popTs, order: .reverse)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
subject
|
||||||
|
.throttle(for: 2, scheduler: DispatchQueue.main, latest: true)
|
||||||
|
.sink { self.popCollectedChats() }
|
||||||
|
.store(in: &bag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func throttlePopChat(_ chatId: ChatId, currentPosition: Int) {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
if currentPosition > 0 && m.chatId == chatId {
|
||||||
|
m.chatToTop = chatId
|
||||||
|
}
|
||||||
|
if currentPosition > 0 || !chatsToPop.isEmpty {
|
||||||
|
chatsToPop[chatId] = Date.now
|
||||||
|
subject.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
chatsToPop = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func popCollectedChats() {
|
||||||
|
let m = ChatModel.shared
|
||||||
|
var ixs: IndexSet = []
|
||||||
|
var chs: [Chat] = []
|
||||||
|
// collect chats that received updates
|
||||||
|
for (chatId, popTs) in self.chatsToPop {
|
||||||
|
// Currently opened chat is excluded, removing it from the list would navigate out of it
|
||||||
|
// It will be popped to top later when user exits from the list.
|
||||||
|
if m.chatId != chatId, let i = m.getChatIndex(chatId) {
|
||||||
|
ixs.insert(i)
|
||||||
|
let ch = m.chats[i]
|
||||||
|
ch.popTs = popTs
|
||||||
|
chs.append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeInsert = {
|
||||||
|
m.chats.remove(atOffsets: ixs)
|
||||||
|
// sort chats by pop timestamp in descending order
|
||||||
|
m.chats.insert(contentsOf: chs.sorted(using: self.popTsComparator), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.chatId == nil {
|
||||||
|
withAnimation { removeInsert() }
|
||||||
|
} else {
|
||||||
|
removeInsert()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.chatsToPop = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func markChatItemRead_(_ i: Int) {
|
private func markChatItemRead_(_ i: Int) {
|
||||||
let meta = reversedChatItems[i].meta
|
let meta = im.reversedChatItems[i].meta
|
||||||
if case .rcvNew = meta.itemStatus {
|
if case .rcvNew = meta.itemStatus {
|
||||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
im.reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||||
reversedChatItems[i].viewTimestamp = .now
|
im.reversedChatItems[i].viewTimestamp = .now
|
||||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||||
if let i = getChatIndex(cInfo.id) {
|
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
changeUnreadCounter(user: currentUser!, by: count)
|
||||||
decreaseUnreadCounter(user: currentUser!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func increaseUnreadCounter(user: any UserLike) {
|
func increaseUnreadCounter(user: any UserLike) {
|
||||||
changeUnreadCounter(user: user, by: 1)
|
changeUnreadCounter(user: user, by: 1)
|
||||||
NtfManager.shared.incNtfBadgeCount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||||
changeUnreadCounter(user: user, by: -by)
|
changeUnreadCounter(user: user, by: -by)
|
||||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||||
users[i].unreadCount += by
|
users[i].unreadCount += by
|
||||||
}
|
}
|
||||||
|
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUnreadCountForAllUsers() -> Int {
|
func totalUnreadCountForAllUsers() -> Int {
|
||||||
@@ -581,8 +786,8 @@ final class ChatModel: ObservableObject {
|
|||||||
var ns: [String] = []
|
var ns: [String] = []
|
||||||
if let ciCategory = chatItem.mergeCategory,
|
if let ciCategory = chatItem.mergeCategory,
|
||||||
var i = getChatItemIndex(chatItem) {
|
var i = getChatItemIndex(chatItem) {
|
||||||
while i < reversedChatItems.count {
|
while i < im.reversedChatItems.count {
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ci.mergeCategory != ciCategory { break }
|
if ci.mergeCategory != ciCategory { break }
|
||||||
if let m = ci.memberConnected {
|
if let m = ci.memberConnected {
|
||||||
ns.append(m.displayName)
|
ns.append(m.displayName)
|
||||||
@@ -597,7 +802,7 @@ final class ChatModel: ObservableObject {
|
|||||||
// returns the index of the passed item and the next item (it has smaller index)
|
// returns the index of the passed item and the next item (it has smaller index)
|
||||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||||
if let i = getChatItemIndex(ci) {
|
if let i = getChatItemIndex(ci) {
|
||||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
|
||||||
} else {
|
} else {
|
||||||
(nil, nil)
|
(nil, nil)
|
||||||
}
|
}
|
||||||
@@ -607,10 +812,10 @@ final class ChatModel: ObservableObject {
|
|||||||
// and the previous visible item with another merge category
|
// and the previous visible item with another merge category
|
||||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||||
guard var i = ciIndex else { return (nil, nil) }
|
guard var i = ciIndex else { return (nil, nil) }
|
||||||
let fst = reversedChatItems.count - 1
|
let fst = im.reversedChatItems.count - 1
|
||||||
while i < fst {
|
while i < fst {
|
||||||
i = i + 1
|
i = i + 1
|
||||||
let ci = reversedChatItems[i]
|
let ci = im.reversedChatItems[i]
|
||||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||||
return (i - 1, ci)
|
return (i - 1, ci)
|
||||||
}
|
}
|
||||||
@@ -623,7 +828,7 @@ final class ChatModel: ObservableObject {
|
|||||||
var prevMember: GroupMember? = nil
|
var prevMember: GroupMember? = nil
|
||||||
var memberIds: Set<Int64> = []
|
var memberIds: Set<Int64> = []
|
||||||
for i in range {
|
for i in range {
|
||||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||||
memberIds.insert(m.groupMemberId)
|
memberIds.insert(m.groupMemberId)
|
||||||
}
|
}
|
||||||
@@ -633,6 +838,7 @@ final class ChatModel: ObservableObject {
|
|||||||
|
|
||||||
func popChat(_ id: String) {
|
func popChat(_ id: String) {
|
||||||
if let i = getChatIndex(id) {
|
if let i = getChatIndex(id) {
|
||||||
|
// no animation here, for it not to look like it just moved when leaving the chat
|
||||||
popChat_(i)
|
popChat_(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,14 +873,17 @@ final class ChatModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
// update current chat
|
// update current chat
|
||||||
if chatId == groupInfo.id {
|
if chatId == groupInfo.id {
|
||||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||||
withAnimation(.default) {
|
withAnimation(.default) {
|
||||||
self.groupMembers[i].wrapped = member
|
self.groupMembers[i].wrapped = member
|
||||||
self.groupMembers[i].created = Date.now
|
self.groupMembers[i].created = Date.now
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
withAnimation { groupMembers.append(GMember(member)) }
|
withAnimation {
|
||||||
|
groupMembers.append(GMember(member))
|
||||||
|
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -695,37 +904,29 @@ final class ChatModel: ObservableObject {
|
|||||||
var i = 0
|
var i = 0
|
||||||
var totalBelow = 0
|
var totalBelow = 0
|
||||||
var unreadBelow = 0
|
var unreadBelow = 0
|
||||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
|
||||||
totalBelow += 1
|
totalBelow += 1
|
||||||
if reversedChatItems[i].isRcvNew {
|
if im.reversedChatItems[i].isRcvNew {
|
||||||
unreadBelow += 1
|
unreadBelow += 1
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
return UnreadChatItemCounts(
|
||||||
|
// TODO these thresholds account for the fact that items are still "visible" while
|
||||||
|
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
|
||||||
|
isNearBottom: totalBelow < 15,
|
||||||
|
isReallyNearBottom: totalBelow < 2,
|
||||||
|
unreadBelow: unreadBelow
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||||
let maxIx = reversedChatItems.count - 1
|
let maxIx = im.reversedChatItems.count - 1
|
||||||
var i = 0
|
var i = 0
|
||||||
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
|
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
|
||||||
while i < maxIx && !inView(i) { i += 1 }
|
while i < maxIx && !inView(i) { i += 1 }
|
||||||
while i < maxIx && inView(i) { i += 1 }
|
while i < maxIx && inView(i) { i += 1 }
|
||||||
return reversedChatItems[min(i - 1, maxIx)]
|
return im.reversedChatItems[min(i - 1, maxIx)]
|
||||||
}
|
|
||||||
|
|
||||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contactNetworkStatus(_ contact: Contact) -> NetworkStatus {
|
|
||||||
if let conn = contact.activeConn {
|
|
||||||
networkStatuses[conn.agentConnId] ?? .unknown
|
|
||||||
} else {
|
|
||||||
.unknown
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,15 +941,17 @@ struct NTFContactRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct UnreadChatItemCounts: Equatable {
|
struct UnreadChatItemCounts: Equatable {
|
||||||
var totalBelow: Int
|
var isNearBottom: Bool
|
||||||
|
var isReallyNearBottom: Bool
|
||||||
var unreadBelow: Int
|
var unreadBelow: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Chat: ObservableObject, Identifiable {
|
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||||
@Published var chatInfo: ChatInfo
|
@Published var chatInfo: ChatInfo
|
||||||
@Published var chatItems: [ChatItem]
|
@Published var chatItems: [ChatItem]
|
||||||
@Published var chatStats: ChatStats
|
@Published var chatStats: ChatStats
|
||||||
var created = Date.now
|
var created = Date.now
|
||||||
|
fileprivate var popTs: Date?
|
||||||
|
|
||||||
init(_ cData: ChatData) {
|
init(_ cData: ChatData) {
|
||||||
self.chatInfo = cData.chatInfo
|
self.chatInfo = cData.chatInfo
|
||||||
@@ -795,24 +998,6 @@ final class Chat: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
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: [])
|
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chatModel.chatId = content.targetContentIdentifier
|
if let chatId = content.targetContentIdentifier {
|
||||||
|
ItemsModel.shared.loadOpenChat(chatId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
@@ -238,12 +240,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
|||||||
ntfBadgeCountGroupDefault.set(count)
|
ntfBadgeCountGroupDefault.set(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decNtfBadgeCount(by count: Int = 1) {
|
func changeNtfBadgeCount(by count: Int = 1) {
|
||||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
|
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||||
}
|
|
||||||
|
|
||||||
func incNtfBadgeCount(by count: Int = 1) {
|
|
||||||
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
||||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
|
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
|
||||||
if case let .activeUser(user) = r { return user }
|
if case let .activeUser(user) = r { return user }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
|||||||
}
|
}
|
||||||
|
|
||||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||||
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
|
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||||
switch r {
|
switch r {
|
||||||
case .chatStarted: return true
|
case .chatStarted: return true
|
||||||
case .chatRunning: return false
|
case .chatRunning: return false
|
||||||
@@ -225,6 +225,15 @@ 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 {
|
func apiStopChat() async throws {
|
||||||
let r = await chatSendCmd(.apiStopChat)
|
let r = await chatSendCmd(.apiStopChat)
|
||||||
switch r {
|
switch r {
|
||||||
@@ -270,8 +279,10 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
let r = await chatSendCmd(.apiExportArchive(config: config))
|
||||||
|
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
|
||||||
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||||
@@ -309,8 +320,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
|||||||
|
|
||||||
let loadItemsPerPage = 50
|
let loadItemsPerPage = 50
|
||||||
|
|
||||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
@@ -321,15 +332,20 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadChat(chat: Chat, search: String = "") {
|
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||||
do {
|
do {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let im = ItemsModel.shared
|
||||||
m.chatItemStatuses = [:]
|
m.chatItemStatuses = [:]
|
||||||
m.reversedChatItems = []
|
if clearItems {
|
||||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
await MainActor.run { im.reversedChatItems = [] }
|
||||||
m.updateChatInfo(chat.chatInfo)
|
}
|
||||||
m.reversedChatItems = chat.chatItems.reversed()
|
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||||
|
await MainActor.run {
|
||||||
|
im.reversedChatItems = chat.chatItems.reversed()
|
||||||
|
m.updateChatInfo(chat.chatInfo)
|
||||||
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("loadChat error: \(responseError(error))")
|
logger.error("loadChat error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -397,7 +413,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
|||||||
logger.error("send message error: \(String(describing: r))")
|
logger.error("send message error: \(String(describing: r))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Error sending message",
|
title: "Error sending message",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +421,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
|
|||||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Error creating message",
|
title: "Error creating message",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,15 +437,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
|
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
|
||||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
|
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
|
||||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +705,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
|||||||
return ((.contact, connection), nil)
|
return ((.contact, connection), nil)
|
||||||
case let .contactAlreadyExists(_, contact):
|
case let .contactAlreadyExists(_, contact):
|
||||||
if let c = m.getContactChat(contact.contactId) {
|
if let c = m.getContactChat(contact.contactId) {
|
||||||
await MainActor.run { m.chatId = c.id }
|
ItemsModel.shared.loadOpenChat(c.id)
|
||||||
}
|
}
|
||||||
let alert = contactAlreadyExistsAlert(contact)
|
let alert = contactAlreadyExistsAlert(contact)
|
||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
@@ -699,7 +715,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."
|
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||||
)
|
)
|
||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
|
||||||
let alert = mkAlert(
|
let alert = mkAlert(
|
||||||
title: "Connection error (AUTH)",
|
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."
|
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."
|
||||||
@@ -732,7 +748,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
|||||||
} else {
|
} else {
|
||||||
return mkAlert(
|
return mkAlert(
|
||||||
title: "Connection error",
|
title: "Connection error",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,22 +765,38 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
|
|||||||
return (nil, alert)
|
return (nil, alert)
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
|
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
|
||||||
let chatId = type.rawValue + id.description
|
let chatId = type.rawValue + id.description
|
||||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||||
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
||||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
|
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||||
if case .direct = type, case .contactDeleted = r { return }
|
if case .direct = type, case .contactDeleted = r { return }
|
||||||
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||||
if case .group = type, case .groupDeletedUser = r { return }
|
if case .group = type, case .groupDeletedUser = r { return }
|
||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
|
||||||
|
let type: ChatType = .direct
|
||||||
|
let chatId = type.rawValue + id.description
|
||||||
|
if case .full = chatDeleteMode {
|
||||||
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
if case .full = chatDeleteMode {
|
||||||
|
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||||
|
if case let .contactDeleted(_, contact) = r { return contact }
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
|
||||||
do {
|
do {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
|
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
@@ -774,6 +806,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
|
||||||
|
do {
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||||
|
await MainActor.run {
|
||||||
|
switch chatDeleteMode {
|
||||||
|
case .full:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
case .entity:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
ChatModel.shared.addChat(Chat(
|
||||||
|
chatInfo: .direct(contact: ct),
|
||||||
|
chatItems: chat.chatItems
|
||||||
|
))
|
||||||
|
case .messages:
|
||||||
|
ChatModel.shared.removeChat(cInfo.id)
|
||||||
|
ChatModel.shared.addChat(Chat(
|
||||||
|
chatInfo: .direct(contact: ct),
|
||||||
|
chatItems: []
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
|
||||||
|
return mkAlert(
|
||||||
|
title: "Error deleting chat!",
|
||||||
|
message: "Error: \(responseError(error))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
||||||
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||||
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
||||||
@@ -898,7 +963,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
|||||||
let am = AlertManager.shared
|
let am = AlertManager.shared
|
||||||
|
|
||||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||||
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
|
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Connection error (AUTH)",
|
title: "Connection error (AUTH)",
|
||||||
message: "Sender may have deleted the connection request."
|
message: "Sender may have deleted the connection request."
|
||||||
@@ -909,7 +974,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
|||||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Error accepting contact request",
|
title: "Error accepting contact request",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -935,7 +1000,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
|||||||
return (fileTransferMeta, nil)
|
return (fileTransferMeta, nil)
|
||||||
} else {
|
} else {
|
||||||
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
||||||
return (nil, String(describing: r))
|
return (nil, responseError(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +1010,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
|
|||||||
return (rcvFileTransfer, nil)
|
return (rcvFileTransfer, nil)
|
||||||
} else {
|
} else {
|
||||||
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
||||||
return (nil, String(describing: r))
|
return (nil, responseError(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,7 +1090,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
|
|||||||
if !auto {
|
if !auto {
|
||||||
am.showAlertMsg(
|
am.showAlertMsg(
|
||||||
title: "Error receiving file",
|
title: "Error receiving file",
|
||||||
message: "Error: \(String(describing: r))"
|
message: "Error: \(responseError(r))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1092,18 +1157,9 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||||
switch r {
|
if let alert = getNetworkErrorAlert(r) {
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
return mkAlert(title: alert.title, message: alert.message)
|
||||||
return mkAlert(
|
} else {
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1111,7 +1167,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
|||||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
await MainActor.run {
|
||||||
|
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||||
|
NetworkModel.shared.setContactNetworkStatus(contact, .connected)
|
||||||
|
}
|
||||||
|
if contact.sndReady {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(chat.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1207,7 +1273,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
|||||||
do {
|
do {
|
||||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1314,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
|||||||
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
||||||
switch r {
|
switch r {
|
||||||
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
|
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
|
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
|
||||||
default: throw r
|
default: throw r
|
||||||
}
|
}
|
||||||
@@ -1354,6 +1420,14 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
|||||||
throw r
|
throw r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
|
||||||
|
let userId = try currentUserId("getAgentSubsTotal")
|
||||||
|
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId))
|
||||||
|
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
|
||||||
|
logger.error("getAgentSubsTotal error: \(String(describing: r))")
|
||||||
|
throw r
|
||||||
|
}
|
||||||
|
|
||||||
func getAgentServersSummary() throws -> PresentedServersSummary {
|
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||||
let userId = try currentUserId("getAgentServersSummary")
|
let userId = try currentUserId("getAgentServersSummary")
|
||||||
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||||
@@ -1437,15 +1511,16 @@ func startChat(refreshInvitations: Bool = true) throws {
|
|||||||
logger.debug("startChat")
|
logger.debug("startChat")
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
try setNetworkConfig(getNetCfg())
|
try setNetworkConfig(getNetCfg())
|
||||||
let justStarted = try apiStartChat()
|
let chatRunning = try apiCheckChatRunning()
|
||||||
m.users = try listUsers()
|
m.users = try listUsers()
|
||||||
if justStarted {
|
if !chatRunning {
|
||||||
try getUserChatData()
|
try getUserChatData()
|
||||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||||
if (refreshInvitations) {
|
if (refreshInvitations) {
|
||||||
try refreshCallInvitations()
|
try refreshCallInvitations()
|
||||||
}
|
}
|
||||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||||
|
_ = try apiStartChat()
|
||||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||||
// when it is called before startChat
|
// when it is called before startChat
|
||||||
if let token = m.deviceToken {
|
if let token = m.deviceToken {
|
||||||
@@ -1517,6 +1592,7 @@ func getUserChatData() throws {
|
|||||||
m.chatItemTTL = try getChatItemTTL()
|
m.chatItemTTL = try getChatItemTTL()
|
||||||
let chats = try apiGetChats()
|
let chats = try apiGetChats()
|
||||||
m.chats = chats.map { Chat.init($0) }
|
m.chats = chats.map { Chat.init($0) }
|
||||||
|
m.popChatCollector.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getUserChatDataAsync() async throws {
|
private func getUserChatDataAsync() async throws {
|
||||||
@@ -1529,11 +1605,13 @@ private func getUserChatDataAsync() async throws {
|
|||||||
m.userAddress = userAddress
|
m.userAddress = userAddress
|
||||||
m.chatItemTTL = chatItemTTL
|
m.chatItemTTL = chatItemTTL
|
||||||
m.chats = chats.map { Chat.init($0) }
|
m.chats = chats.map { Chat.init($0) }
|
||||||
|
m.popChatCollector.clear()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.userAddress = nil
|
m.userAddress = nil
|
||||||
m.chats = []
|
m.chats = []
|
||||||
|
m.popChatCollector.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1583,6 +1661,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
await TerminalItems.shared.add(.resp(.now, res))
|
await TerminalItems.shared.add(.resp(.now, res))
|
||||||
}
|
}
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let n = NetworkModel.shared
|
||||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||||
switch res {
|
switch res {
|
||||||
case let .contactDeletedByContact(user, contact):
|
case let .contactDeletedByContact(user, contact):
|
||||||
@@ -1605,7 +1684,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
NtfManager.shared.notifyContactConnected(user, contact)
|
NtfManager.shared.notifyContactConnected(user, contact)
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.setContactNetworkStatus(contact, .connected)
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
}
|
}
|
||||||
case let .contactConnecting(user, contact):
|
case let .contactConnecting(user, contact):
|
||||||
if active(user) && contact.directOrUsed {
|
if active(user) && contact.directOrUsed {
|
||||||
@@ -1617,6 +1696,19 @@ 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 {
|
||||||
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
|
}
|
||||||
case let .receivedContactRequest(user, contactRequest):
|
case let .receivedContactRequest(user, contactRequest):
|
||||||
if active(user) {
|
if active(user) {
|
||||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||||
@@ -1649,7 +1741,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
if active(user) && m.hasChat(mergedContact.id) {
|
if active(user) && m.hasChat(mergedContact.id) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if m.chatId == mergedContact.id {
|
if m.chatId == mergedContact.id {
|
||||||
m.chatId = intoContact.id
|
ItemsModel.shared.loadOpenChat(mergedContact.id)
|
||||||
}
|
}
|
||||||
m.removeChat(mergedContact.id)
|
m.removeChat(mergedContact.id)
|
||||||
}
|
}
|
||||||
@@ -1657,27 +1749,27 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
case let .networkStatus(status, connections):
|
case let .networkStatus(status, connections):
|
||||||
// dispatch queue to synchronize access
|
// dispatch queue to synchronize access
|
||||||
networkStatusesLock.sync {
|
networkStatusesLock.sync {
|
||||||
var ns = m.networkStatuses
|
var ns = n.networkStatuses
|
||||||
// slow loop is on the background thread
|
// slow loop is on the background thread
|
||||||
for cId in connections {
|
for cId in connections {
|
||||||
ns[cId] = status
|
ns[cId] = status
|
||||||
}
|
}
|
||||||
// fast model update is on the main thread
|
// fast model update is on the main thread
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
m.networkStatuses = ns
|
n.networkStatuses = ns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .networkStatuses(_, statuses): ()
|
case let .networkStatuses(_, statuses): ()
|
||||||
// dispatch queue to synchronize access
|
// dispatch queue to synchronize access
|
||||||
networkStatusesLock.sync {
|
networkStatusesLock.sync {
|
||||||
var ns = m.networkStatuses
|
var ns = n.networkStatuses
|
||||||
// slow loop is on the background thread
|
// slow loop is on the background thread
|
||||||
for s in statuses {
|
for s in statuses {
|
||||||
ns[s.agentConnId] = s.networkStatus
|
ns[s.agentConnId] = s.networkStatus
|
||||||
}
|
}
|
||||||
// fast model update is on the main thread
|
// fast model update is on the main thread
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
m.networkStatuses = ns
|
n.networkStatuses = ns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .newChatItem(user, aChatItem):
|
case let .newChatItem(user, aChatItem):
|
||||||
@@ -1720,21 +1812,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
case let .chatItemsDeleted(user, items, _):
|
||||||
if !active(user) {
|
if !active(user) {
|
||||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
for item in items {
|
||||||
await MainActor.run {
|
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
|
||||||
m.decreaseUnreadCounter(user: user)
|
await MainActor.run {
|
||||||
|
m.decreaseUnreadCounter(user: user)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let toChatItem = toChatItem {
|
for item in items {
|
||||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
if let toChatItem = item.toChatItem {
|
||||||
} else {
|
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
} else {
|
||||||
|
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||||
@@ -1814,7 +1910,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
}
|
}
|
||||||
if let contact = memberContact {
|
if let contact = memberContact {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.setContactNetworkStatus(contact, .connected)
|
n.setContactNetworkStatus(contact, .connected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .groupUpdated(user, toGroup):
|
case let .groupUpdated(user, toGroup):
|
||||||
@@ -1995,6 +2091,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
m.remoteCtrlSession = nil
|
m.remoteCtrlSession = nil
|
||||||
dismissAllSheets() {
|
dismissAllSheets() {
|
||||||
switch rcStopReason {
|
switch rcStopReason {
|
||||||
|
case .disconnected:
|
||||||
|
()
|
||||||
case .connectionFailed(.errorAgent(.RCP(.identity))):
|
case .connectionFailed(.errorAgent(.RCP(.identity))):
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title: "Connection with desktop stopped",
|
title: "Connection with desktop stopped",
|
||||||
@@ -2037,12 +2135,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
|||||||
|
|
||||||
func switchToLocalSession() {
|
func switchToLocalSession() {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
|
let n = NetworkModel.shared
|
||||||
m.remoteCtrlSession = nil
|
m.remoteCtrlSession = nil
|
||||||
do {
|
do {
|
||||||
m.users = try listUsers()
|
m.users = try listUsers()
|
||||||
try getUserChatData()
|
try getUserChatData()
|
||||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.debug("error updating chat data: \(responseError(error))")
|
logger.debug("error updating chat data: \(responseError(error))")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ 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() {
|
func suspendChat() {
|
||||||
suspendLockQueue.sync {
|
suspendLockQueue.sync {
|
||||||
_suspendChat(timeout: appSuspendTimeout)
|
_suspendChat(timeout: appSuspendTimeout)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct SimpleXApp: App {
|
|||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { phase in
|
||||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||||
|
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case .background:
|
case .background:
|
||||||
// --- authentication
|
// --- authentication
|
||||||
@@ -135,7 +136,7 @@ struct SimpleXApp: App {
|
|||||||
chatModel.updateChats(with: chats)
|
chatModel.updateChats(with: chats)
|
||||||
if let id = chatModel.chatId,
|
if let id = chatModel.chatId,
|
||||||
let chat = chatModel.getChat(id) {
|
let chat = chatModel.getChat(id) {
|
||||||
loadChat(chat: chat)
|
Task { await loadChat(chat: chat, clearItems: false) }
|
||||||
}
|
}
|
||||||
if let ncr = chatModel.ntfContactRequest {
|
if let ncr = chatModel.ntfContactRequest {
|
||||||
chatModel.ntfContactRequest = nil
|
chatModel.ntfContactRequest = nil
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ extension ThemeWallpaper {
|
|||||||
public func importFromString() -> ThemeWallpaper {
|
public func importFromString() -> ThemeWallpaper {
|
||||||
if preset == nil, let image {
|
if preset == nil, let image {
|
||||||
// Need to save image from string and to save its path
|
// Need to save image from string and to save its path
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
if let parsed = UIImage(base64Encoded: image),
|
||||||
let parsed = UIImage(data: data),
|
|
||||||
let filename = saveWallpaperFile(image: parsed) {
|
let filename = saveWallpaperFile(image: parsed) {
|
||||||
var copy = self
|
var copy = self
|
||||||
copy.image = nil
|
copy.image = nil
|
||||||
|
|||||||
@@ -28,15 +28,17 @@ struct ChatInfoToolbar: View {
|
|||||||
color: Color(uiColor: .tertiaryLabel)
|
color: Color(uiColor: .tertiaryLabel)
|
||||||
)
|
)
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
VStack {
|
let t = Text(cInfo.displayName).font(.headline)
|
||||||
let t = Text(cInfo.displayName).font(.headline)
|
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
VStack(spacing: 0) {
|
||||||
Text(cInfo.fullName).font(.subheadline)
|
v
|
||||||
.lineLimit(1)
|
Text(cInfo.fullName).font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.top, -2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.foregroundColor(theme.colors.onBackground)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
|
|||||||
@@ -92,19 +92,23 @@ struct ChatInfoView: View {
|
|||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
|
@ObservedObject var networkModel = NetworkModel.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State var contact: Contact
|
@State var contact: Contact
|
||||||
@Binding var connectionStats: ConnectionStats?
|
|
||||||
@Binding var customUserProfile: Profile?
|
|
||||||
@State var localAlias: String
|
@State var localAlias: String
|
||||||
@Binding var connectionCode: String?
|
var onSearch: () -> Void
|
||||||
|
@State private var connectionStats: ConnectionStats? = nil
|
||||||
|
@State private var customUserProfile: Profile? = nil
|
||||||
|
@State private var connectionCode: String? = nil
|
||||||
@FocusState private var aliasTextFieldFocused: Bool
|
@FocusState private var aliasTextFieldFocused: Bool
|
||||||
@State private var alert: ChatInfoViewAlert? = nil
|
@State private var alert: ChatInfoViewAlert? = nil
|
||||||
@State private var showDeleteContactActionSheet = false
|
@State private var actionSheet: SomeActionSheet? = nil
|
||||||
|
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||||
|
@State private var showConnectContactViaAddressDialog = false
|
||||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||||
@State private var sendReceiptsUserDefault = true
|
@State private var sendReceiptsUserDefault = true
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
|
||||||
enum ChatInfoViewAlert: Identifiable {
|
enum ChatInfoViewAlert: Identifiable {
|
||||||
case clearChatAlert
|
case clearChatAlert
|
||||||
case networkStatusAlert
|
case networkStatusAlert
|
||||||
@@ -112,7 +116,8 @@ struct ChatInfoView: View {
|
|||||||
case abortSwitchAddressAlert
|
case abortSwitchAddressAlert
|
||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case queueInfo(info: String)
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case someAlert(alert: SomeAlert)
|
||||||
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -122,11 +127,12 @@ struct ChatInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
case let .queueInfo(info): return "queueInfo \(info)"
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
|
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
List {
|
List {
|
||||||
@@ -136,12 +142,29 @@ struct ChatInfoView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
aliasTextFieldFocused = false
|
aliasTextFieldFocused = false
|
||||||
}
|
}
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
localAliasTextEdit()
|
localAliasTextEdit()
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
GeometryReader { g in
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
searchButton(width: buttonWidth)
|
||||||
|
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
muteButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: infoViewActionButtonHeight)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||||
|
|
||||||
if let customUserProfile = customUserProfile {
|
if let customUserProfile = customUserProfile {
|
||||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||||
@@ -153,32 +176,38 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if let code = connectionCode { verifyCodeButton(code) }
|
Group {
|
||||||
contactPreferencesButton()
|
if let code = connectionCode { verifyCodeButton(code) }
|
||||||
sendReceiptsOption()
|
contactPreferencesButton()
|
||||||
if let connStats = connectionStats,
|
sendReceiptsOption()
|
||||||
connStats.ratchetSyncAllowed {
|
if let connStats = connectionStats,
|
||||||
synchronizeConnectionButton()
|
connStats.ratchetSyncAllowed {
|
||||||
|
synchronizeConnectionButton()
|
||||||
|
}
|
||||||
|
// } else if developerTools {
|
||||||
|
// synchronizeConnectionButtonForce()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
.disabled(!contact.ready || !contact.active)
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ChatWallpaperEditorSheet(chat: chat)
|
ChatWallpaperEditorSheet(chat: chat)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Chat theme", systemImage: "photo")
|
Label("Chat theme", systemImage: "photo")
|
||||||
}
|
}
|
||||||
// } else if developerTools {
|
// } else if developerTools {
|
||||||
// synchronizeConnectionButtonForce()
|
// synchronizeConnectionButtonForce()
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
.disabled(!contact.ready || !contact.active)
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
|
||||||
if let conn = contact.activeConn {
|
if let conn = contact.activeConn {
|
||||||
Section {
|
Section {
|
||||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let contactLink = contact.contactLink {
|
if let contactLink = contact.contactLink {
|
||||||
Section {
|
Section {
|
||||||
SimpleXLinkQRCode(uri: contactLink)
|
SimpleXLinkQRCode(uri: contactLink)
|
||||||
@@ -195,7 +224,7 @@ struct ChatInfoView: View {
|
|||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contact.ready && contact.active {
|
if contact.ready && contact.active {
|
||||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||||
networkStatusRow()
|
networkStatusRow()
|
||||||
@@ -224,12 +253,12 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
clearChatButton()
|
clearChatButton()
|
||||||
deleteContactButton()
|
deleteContactButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
if developerTools {
|
if developerTools {
|
||||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||||
@@ -258,6 +287,24 @@ struct ChatInfoView: View {
|
|||||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||||
}
|
}
|
||||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||||
|
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||||
|
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
|
||||||
|
await MainActor.run {
|
||||||
|
connectionStats = stats
|
||||||
|
customUserProfile = profile
|
||||||
|
connectionCode = code
|
||||||
|
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
|
||||||
|
chat.chatInfo = .direct(contact: ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert(item: $alert) { alertItem in
|
.alert(item: $alert) { alertItem in
|
||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
@@ -267,37 +314,26 @@ struct ChatInfoView: View {
|
|||||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||||
case let .queueInfo(info): return queueInfoAlert(info)
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
|
case let .someAlert(a): return a.alert
|
||||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
if contact.ready && contact.active {
|
.sheet(item: $sheet) {
|
||||||
return ActionSheet(
|
if #available(iOS 16.0, *) {
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
$0.content
|
||||||
buttons: [
|
.presentationDetents([.fraction(0.4)])
|
||||||
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
|
|
||||||
.destructive(Text("Delete")) { deleteContact(notify: false) },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return ActionSheet(
|
$0.content
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
|
||||||
buttons: [
|
|
||||||
.destructive(Text("Delete")) { deleteContact() },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contactInfoHeader() -> some View {
|
private func contactInfoHeader() -> some View {
|
||||||
VStack {
|
VStack(spacing: 8) {
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.padding(.top, 12)
|
.padding(.vertical, 12)
|
||||||
.padding()
|
|
||||||
if contact.verified {
|
if contact.verified {
|
||||||
(
|
(
|
||||||
Text(Image(systemName: "checkmark.shield"))
|
Text(Image(systemName: "checkmark.shield"))
|
||||||
@@ -326,7 +362,7 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func localAliasTextEdit() -> some View {
|
private func localAliasTextEdit() -> some View {
|
||||||
TextField("Set contact name…", text: $localAlias)
|
TextField("Set contact name…", text: $localAlias)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
@@ -343,7 +379,7 @@ struct ChatInfoView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setContactAlias() {
|
private func setContactAlias() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -358,6 +394,25 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func searchButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
|
||||||
|
dismiss()
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func muteButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(
|
||||||
|
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||||
|
width: width
|
||||||
|
) {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
}
|
||||||
|
.disabled(!contact.ready || !contact.active)
|
||||||
|
}
|
||||||
|
|
||||||
private func verifyCodeButton(_ code: String) -> some View {
|
private func verifyCodeButton(_ code: String) -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VerifyCodeView(
|
VerifyCodeView(
|
||||||
@@ -387,7 +442,7 @@ struct ChatInfoView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func contactPreferencesButton() -> some View {
|
private func contactPreferencesButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ContactPreferencesView(
|
ContactPreferencesView(
|
||||||
@@ -402,7 +457,7 @@ struct ChatInfoView: View {
|
|||||||
Label("Contact preferences", systemImage: "switch.2")
|
Label("Contact preferences", systemImage: "switch.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendReceiptsOption() -> some View {
|
private func sendReceiptsOption() -> some View {
|
||||||
Picker(selection: $sendReceipts) {
|
Picker(selection: $sendReceipts) {
|
||||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||||
@@ -416,13 +471,13 @@ struct ChatInfoView: View {
|
|||||||
setSendReceipts()
|
setSendReceipts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setSendReceipts() {
|
private func setSendReceipts() {
|
||||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||||
chatSettings.sendRcpts = sendReceipts.bool()
|
chatSettings.sendRcpts = sendReceipts.bool()
|
||||||
updateChatSettings(chat, chatSettings: chatSettings)
|
updateChatSettings(chat, chatSettings: chatSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func synchronizeConnectionButton() -> some View {
|
private func synchronizeConnectionButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
syncContactConnection(force: false)
|
syncContactConnection(force: false)
|
||||||
@@ -431,7 +486,7 @@ struct ChatInfoView: View {
|
|||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func synchronizeConnectionButtonForce() -> some View {
|
private func synchronizeConnectionButtonForce() -> some View {
|
||||||
Button {
|
Button {
|
||||||
alert = .syncConnectionForceAlert
|
alert = .syncConnectionForceAlert
|
||||||
@@ -440,7 +495,7 @@ struct ChatInfoView: View {
|
|||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func networkStatusRow() -> some View {
|
private func networkStatusRow() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Network status")
|
Text("Network status")
|
||||||
@@ -448,28 +503,35 @@ struct ChatInfoView: View {
|
|||||||
.foregroundColor(theme.colors.primary)
|
.foregroundColor(theme.colors.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(chatModel.contactNetworkStatus(contact).statusString)
|
Text(networkModel.contactNetworkStatus(contact).statusString)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
serverImage()
|
serverImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func serverImage() -> some View {
|
private func serverImage() -> some View {
|
||||||
let status = chatModel.contactNetworkStatus(contact)
|
let status = networkModel.contactNetworkStatus(contact)
|
||||||
return Image(systemName: status.imageName)
|
return Image(systemName: status.imageName)
|
||||||
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteContactButton() -> some View {
|
private func deleteContactButton() -> some View {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteContactActionSheet = true
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: true,
|
||||||
|
showAlert: { alert = .someAlert(alert: $0) },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete contact", systemImage: "trash")
|
Label("Delete contact", systemImage: "person.badge.minus")
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearChatButton() -> some View {
|
private func clearChatButton() -> some View {
|
||||||
Button() {
|
Button() {
|
||||||
alert = .clearChatAlert
|
alert = .clearChatAlert
|
||||||
@@ -478,26 +540,7 @@ struct ChatInfoView: View {
|
|||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteContact(notify: Bool? = nil) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
|
|
||||||
await MainActor.run {
|
|
||||||
dismiss()
|
|
||||||
chatModel.chatId = nil
|
|
||||||
chatModel.removeChat(chat.chatInfo.id)
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
|
||||||
let a = getErrorAlert(error, "Error deleting contact")
|
|
||||||
await MainActor.run {
|
|
||||||
alert = .error(title: a.title, error: a.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearChatAlert() -> Alert {
|
private func clearChatAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Clear conversation?"),
|
title: Text("Clear conversation?"),
|
||||||
@@ -511,14 +554,14 @@ struct ChatInfoView: View {
|
|||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func networkStatusAlert() -> Alert {
|
private func networkStatusAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Network status"),
|
title: Text("Network status"),
|
||||||
message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
|
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func switchContactAddress() {
|
private func switchContactAddress() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -537,7 +580,7 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func abortSwitchContactAddress() {
|
private func abortSwitchContactAddress() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -555,7 +598,7 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncContactConnection(force: Bool) {
|
private func syncContactConnection(force: Bool) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -576,6 +619,153 @@ struct ChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AudioCallButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CallButton(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
image: "phone.fill",
|
||||||
|
title: "call",
|
||||||
|
mediaType: .audio,
|
||||||
|
width: width,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CallButton(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
image: "video.fill",
|
||||||
|
title: "video",
|
||||||
|
mediaType: .video,
|
||||||
|
width: width,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CallButton: View {
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var image: String
|
||||||
|
var title: LocalizedStringKey
|
||||||
|
var mediaType: CallMediaType
|
||||||
|
var width: CGFloat
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
|
||||||
|
|
||||||
|
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
|
||||||
|
if canCall {
|
||||||
|
CallController.shared.startCall(contact, mediaType)
|
||||||
|
} else if contact.nextSendGrpInv {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Send message to enable calls."
|
||||||
|
),
|
||||||
|
id: "can't call contact, send message"
|
||||||
|
))
|
||||||
|
} else if !contact.active {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Contact is deleted."
|
||||||
|
),
|
||||||
|
id: "can't call contact, contact deleted"
|
||||||
|
))
|
||||||
|
} else if !contact.ready {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call contact",
|
||||||
|
message: "Connecting to contact, please wait or check later!"
|
||||||
|
),
|
||||||
|
id: "can't call contact, contact not ready"
|
||||||
|
))
|
||||||
|
} else if !chat.chatInfo.featureEnabled(.calls) {
|
||||||
|
switch chat.chatInfo.showEnableCallsAlert {
|
||||||
|
case .userEnable:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: Alert(
|
||||||
|
title: Text("Allow calls?"),
|
||||||
|
message: Text("You need to allow your contact to call to be able to call them."),
|
||||||
|
primaryButton: .default(Text("Allow")) {
|
||||||
|
allowFeatureToContact(contact, .calls)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
),
|
||||||
|
id: "allow calls"
|
||||||
|
))
|
||||||
|
case .askContact:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Calls prohibited!",
|
||||||
|
message: "Please ask your contact to enable calls."
|
||||||
|
),
|
||||||
|
id: "calls prohibited, ask contact"
|
||||||
|
))
|
||||||
|
case .other:
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Calls prohibited!",
|
||||||
|
message: "Please check yours and your contact preferences."
|
||||||
|
)
|
||||||
|
, id: "calls prohibited, other"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert(SomeAlert(
|
||||||
|
alert: mkAlert(title: "Can't call contact"),
|
||||||
|
id: "can't call contact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(ChatModel.shared.activeCall != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let infoViewActionButtonHeight: CGFloat = 60
|
||||||
|
|
||||||
|
struct InfoViewButton: View {
|
||||||
|
var image: String
|
||||||
|
var title: LocalizedStringKey
|
||||||
|
var disabledLook: Bool = false
|
||||||
|
var width: CGFloat
|
||||||
|
var action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.cornerRadius(10.0)
|
||||||
|
.frame(width: width, height: infoViewActionButtonHeight)
|
||||||
|
.disabled(disabledLook)
|
||||||
|
.onTapGesture(perform: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatWallpaperEditorSheet: View {
|
struct ChatWallpaperEditorSheet: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@@ -761,15 +951,222 @@ func queueInfoAlert(_ info: String) -> Alert {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteContactDialog(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
dismissToChatList: Bool,
|
||||||
|
showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||||
|
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||||
|
) {
|
||||||
|
if contact.sndReady && contact.active && !contact.chatDeleted {
|
||||||
|
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
|
||||||
|
} else if contact.sndReady && contact.active && contact.chatDeleted {
|
||||||
|
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||||
|
} else { // !(contact.sndReady && contact.active)
|
||||||
|
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactOrConversationDialog(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||||
|
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Delete contact?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Only delete conversation")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.destructive(Text("Delete contact")) {
|
||||||
|
showSheetContent(SomeSheet(
|
||||||
|
content: { AnyView(
|
||||||
|
DeleteActiveContactDialog(
|
||||||
|
chat: chat,
|
||||||
|
contact: contact,
|
||||||
|
dismissToChatList: dismissToChatList,
|
||||||
|
showAlert: showAlert
|
||||||
|
)
|
||||||
|
) },
|
||||||
|
id: "DeleteActiveContactDialog"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteContactOrConversationDialog"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactMaybeErrorAlert(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
chatDeleteMode: ChatDeleteMode,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
|
||||||
|
if let alert = alert_ {
|
||||||
|
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
|
||||||
|
} else {
|
||||||
|
if dismissToChatList {
|
||||||
|
await MainActor.run {
|
||||||
|
ChatModel.shared.chatId = nil
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||||
|
AlertManager.shared.showAlert(deleteConversationNotice(contact))
|
||||||
|
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||||
|
AlertManager.shared.showAlert(deleteContactNotice(contact))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||||
|
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
|
||||||
|
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||||
|
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteConversationNotice(_ contact: Contact) -> Alert {
|
||||||
|
return Alert(
|
||||||
|
title: Text("Conversation deleted!"),
|
||||||
|
message: Text("You can send messages to \(contact.displayName) from Archived contacts."),
|
||||||
|
primaryButton: .default(Text("Don't show again")) {
|
||||||
|
showDeleteConversationNoticeDefault.set(false)
|
||||||
|
},
|
||||||
|
secondaryButton: .default(Text("Ok"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactNotice(_ contact: Contact) -> Alert {
|
||||||
|
return Alert(
|
||||||
|
title: Text("Contact deleted!"),
|
||||||
|
message: Text("You can still view conversation with \(contact.displayName) in the list of chats."),
|
||||||
|
primaryButton: .default(Text("Don't show again")) {
|
||||||
|
showDeleteContactNoticeDefault.set(false)
|
||||||
|
},
|
||||||
|
secondaryButton: .default(Text("Ok"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContactDeleteMode {
|
||||||
|
case full
|
||||||
|
case entity
|
||||||
|
|
||||||
|
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
|
||||||
|
switch self {
|
||||||
|
case .full: .full(notify: notify)
|
||||||
|
case .entity: .entity(notify: notify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeleteActiveContactDialog: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
var chat: Chat
|
||||||
|
var contact: Contact
|
||||||
|
var dismissToChatList: Bool
|
||||||
|
var showAlert: (SomeAlert) -> Void
|
||||||
|
@State private var keepConversation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Toggle("Keep conversation", isOn: $keepConversation)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
|
||||||
|
} label: {
|
||||||
|
Text("Delete without notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
|
||||||
|
} label: {
|
||||||
|
Text("Delete and notify contact")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Contact will be deleted - this cannot be undone!")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contactDeleteMode: ContactDeleteMode {
|
||||||
|
keepConversation ? .entity : .full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteContactWithoutConversation(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Confirm contact deletion?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Delete and notify contact")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: true), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.destructive(Text("Delete without notification")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteContactWithoutConversation"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteNotReadyContact(
|
||||||
|
_ chat: Chat,
|
||||||
|
_ contact: Contact,
|
||||||
|
_ dismissToChatList: Bool,
|
||||||
|
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||||
|
_ showActionSheet: @escaping (SomeActionSheet) -> Void
|
||||||
|
) {
|
||||||
|
showActionSheet(SomeActionSheet(
|
||||||
|
actionSheet: ActionSheet(
|
||||||
|
title: Text("Confirm contact deletion?"),
|
||||||
|
buttons: [
|
||||||
|
.destructive(Text("Confirm")) {
|
||||||
|
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .full(notify: false), dismissToChatList, showAlert)
|
||||||
|
},
|
||||||
|
.cancel()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
id: "deleteNotReadyContact"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatInfoView_Previews: PreviewProvider {
|
struct ChatInfoView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ChatInfoView(
|
ChatInfoView(
|
||||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||||
contact: Contact.sampleData,
|
contact: Contact.sampleData,
|
||||||
connectionStats: Binding.constant(nil),
|
|
||||||
customUserProfile: Binding.constant(nil),
|
|
||||||
localAlias: "",
|
localAlias: "",
|
||||||
connectionCode: Binding.constant(nil)
|
onSearch: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
class AnimatedImageView: UIView {
|
class AnimatedImageView: UIView {
|
||||||
var image: UIImage? = nil
|
var image: UIImage? = nil
|
||||||
var imageView: UIImageView? = nil
|
var imageView: UIImageView? = nil
|
||||||
|
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
|
|||||||
fatalError("Not implemented")
|
fatalError("Not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(image: UIImage) {
|
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||||
self.init()
|
self.init()
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.cMode = contentMode
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
|
|||||||
if let subview = self.subviews.first as? UIImageView {
|
if let subview = self.subviews.first as? UIImageView {
|
||||||
if image.imageData != subview.gifImage?.imageData {
|
if image.imageData != subview.gifImage?.imageData {
|
||||||
imageView = UIImageView(gifImage: image)
|
imageView = UIImageView(gifImage: image)
|
||||||
imageView!.contentMode = .scaleAspectFit
|
imageView!.contentMode = contentMode
|
||||||
self.addSubview(imageView!)
|
self.addSubview(imageView!)
|
||||||
subview.removeFromSuperview()
|
subview.removeFromSuperview()
|
||||||
}
|
}
|
||||||
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
|
|||||||
|
|
||||||
struct SwiftyGif: UIViewRepresentable {
|
struct SwiftyGif: UIViewRepresentable {
|
||||||
private let image: UIImage
|
private let image: UIImage
|
||||||
|
private let contentMode: UIView.ContentMode
|
||||||
|
|
||||||
init(image: UIImage) {
|
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||||
self.image = image
|
self.image = image
|
||||||
|
self.contentMode = contentMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> AnimatedImageView {
|
func makeUIView(context: Context) -> AnimatedImageView {
|
||||||
AnimatedImageView(image: image)
|
AnimatedImageView(image: image, contentMode: contentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct CIChatFeatureView: View {
|
struct CIChatFeatureView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@ObservedObject var im = ItemsModel.shared
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@@ -53,8 +54,8 @@ struct CIChatFeatureView: View {
|
|||||||
var fs: [FeatureInfo] = []
|
var fs: [FeatureInfo] = []
|
||||||
var icons: Set<String> = []
|
var icons: Set<String> = []
|
||||||
if var i = m.getChatItemIndex(chatItem) {
|
if var i = m.getChatItemIndex(chatItem) {
|
||||||
while i < m.reversedChatItems.count,
|
while i < im.reversedChatItems.count,
|
||||||
let f = featureInfo(m.reversedChatItems[i]) {
|
let f = featureInfo(im.reversedChatItems[i]) {
|
||||||
if !icons.contains(f.icon) {
|
if !icons.contains(f.icon) {
|
||||||
fs.insert(f, at: 0)
|
fs.insert(f, at: 0)
|
||||||
icons.insert(f.icon)
|
icons.insert(f.icon)
|
||||||
|
|||||||
@@ -14,39 +14,45 @@ struct CIFileView: View {
|
|||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let file: CIFile?
|
let file: CIFile?
|
||||||
let edited: Bool
|
let edited: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let metaReserve = edited
|
if smallViewSize != nil {
|
||||||
? " "
|
fileIndicator()
|
||||||
: " "
|
.onTapGesture(perform: fileAction)
|
||||||
Button(action: fileAction) {
|
} else {
|
||||||
HStack(alignment: .bottom, spacing: 6) {
|
let metaReserve = edited
|
||||||
fileIndicator()
|
? " "
|
||||||
.padding(.top, 5)
|
: " "
|
||||||
.padding(.bottom, 3)
|
Button(action: fileAction) {
|
||||||
if let file = file {
|
HStack(alignment: .bottom, spacing: 6) {
|
||||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
fileIndicator()
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.padding(.top, 5)
|
||||||
Text(file.fileName)
|
.padding(.bottom, 3)
|
||||||
.lineLimit(1)
|
if let file = file {
|
||||||
.multilineTextAlignment(.leading)
|
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||||
.foregroundColor(theme.colors.onBackground)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(prettyFileSize + metaReserve)
|
Text(file.fileName)
|
||||||
.font(.caption)
|
.lineLimit(1)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.multilineTextAlignment(.leading)
|
.foregroundColor(theme.colors.onBackground)
|
||||||
.foregroundColor(theme.colors.secondary)
|
Text(prettyFileSize + metaReserve)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(metaReserve)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text(metaReserve)
|
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.disabled(!itemInteractive)
|
||||||
.padding(.bottom, 6)
|
|
||||||
.padding(.leading, 10)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.disabled(!itemInteractive)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var itemInteractive: Bool {
|
private var itemInteractive: Bool {
|
||||||
@@ -195,21 +201,22 @@ struct CIFileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||||
ZStack(alignment: .center) {
|
let size = smallViewSize ?? 30
|
||||||
|
return ZStack(alignment: .center) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
if let innerIcon = innerIcon,
|
if let innerIcon = innerIcon,
|
||||||
let innerIconSize = innerIconSize {
|
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||||
Image(systemName: innerIcon)
|
Image(systemName: innerIcon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(maxHeight: 16)
|
.frame(maxHeight: 16)
|
||||||
.frame(width: innerIconSize, height: innerIconSize)
|
.frame(width: innerIconSize, height: innerIconSize)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.top, 12)
|
.padding(.top, size / 2.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ struct CIGroupInvitationView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, theme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||||
.onChange(of: inProgress) { inProgress in
|
.onChange(of: inProgress) { inProgress in
|
||||||
|
|||||||
@@ -15,22 +15,33 @@ struct CIImageView: View {
|
|||||||
var preview: UIImage?
|
var preview: UIImage?
|
||||||
let maxWidth: CGFloat
|
let maxWidth: CGFloat
|
||||||
var imgWidth: CGFloat?
|
var imgWidth: CGFloat?
|
||||||
@State private var showFullScreenImage = false
|
var smallView: Bool = false
|
||||||
|
@Binding var showFullScreenImage: Bool
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let uiImage = getLoadedImage(file) {
|
if let uiImage = getLoadedImage(file) {
|
||||||
imageView(uiImage)
|
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||||
}
|
}
|
||||||
|
.if(!smallView) { view in
|
||||||
|
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
}
|
||||||
.onTapGesture { showFullScreenImage = true }
|
.onTapGesture { showFullScreenImage = true }
|
||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenImage = false
|
showFullScreenImage = false
|
||||||
}
|
}
|
||||||
} else if let preview {
|
} else if let preview {
|
||||||
imageView(preview)
|
Group {
|
||||||
|
if smallView {
|
||||||
|
smallViewImageView(preview)
|
||||||
|
} else {
|
||||||
|
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||||
|
}
|
||||||
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
if let file = file {
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
@@ -83,6 +94,9 @@ struct CIImageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
showFullScreenImage = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func imageView(_ img: UIImage) -> some View {
|
private func imageView(_ img: UIImage) -> some View {
|
||||||
@@ -98,7 +112,26 @@ struct CIImageView: View {
|
|||||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
}
|
}
|
||||||
loadingIndicator()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,4 +178,12 @@ struct CIImageView: View {
|
|||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||||
|
switch fileStatus {
|
||||||
|
case .rcvInvitation: true
|
||||||
|
case .rcvAborted: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ struct CIInvalidJSONView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture { showJSON = true }
|
.onTapGesture { showJSON = true }
|
||||||
.appSheet(isPresented: $showJSON) {
|
.appSheet(isPresented: $showJSON) {
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import SimpleXChat
|
|||||||
struct CILinkView: View {
|
struct CILinkView: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview
|
let linkPreview: LinkPreview
|
||||||
|
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .center, spacing: 6) {
|
VStack(alignment: .center, spacing: 6) {
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
|
.modifier(PrivacyBlur(blurred: $blurred))
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(linkPreview.title)
|
Text(linkPreview.title)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View {
|
|||||||
case syncNotSupportedContactAlert
|
case syncNotSupportedContactAlert
|
||||||
case syncNotSupportedMemberAlert
|
case syncNotSupportedMemberAlert
|
||||||
case decryptionErrorAlert
|
case decryptionErrorAlert
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View {
|
|||||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
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 .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,6 @@ struct CIRcvDecryptionError: View {
|
|||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +151,6 @@ struct CIRcvDecryptionError: View {
|
|||||||
.onTapGesture(perform: { onClick() })
|
.onTapGesture(perform: { onClick() })
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,45 +20,44 @@ struct CIVideoView: View {
|
|||||||
@State private var videoPlaying: Bool = false
|
@State private var videoPlaying: Bool = false
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
private var videoWidth: CGFloat?
|
private var videoWidth: CGFloat?
|
||||||
|
private let smallView: Bool
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
@State private var fullPlayer: AVPlayer?
|
@State private var fullPlayer: AVPlayer?
|
||||||
@State private var url: URL?
|
@State private var url: URL?
|
||||||
@State private var urlDecrypted: URL?
|
@State private var urlDecrypted: URL?
|
||||||
@State private var decryptionInProgress: Bool = false
|
@State private var decryptionInProgress: Bool = false
|
||||||
@State private var showFullScreenPlayer = false
|
@Binding private var showFullScreenPlayer: Bool
|
||||||
@State private var timeObserver: Any? = nil
|
@State private var timeObserver: Any? = nil
|
||||||
@State private var fullScreenTimeObserver: Any? = nil
|
@State private var fullScreenTimeObserver: Any? = nil
|
||||||
@State private var publisher: AnyCancellable? = 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?) {
|
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.preview = preview
|
self.preview = preview
|
||||||
self._duration = State(initialValue: duration)
|
self._duration = State(initialValue: duration)
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self.videoWidth = videoWidth
|
self.videoWidth = videoWidth
|
||||||
if let url = getLoadedVideo(chatItem.file) {
|
self.smallView = smallView
|
||||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
self._showFullScreenPlayer = showFullscreenPlayer
|
||||||
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 {
|
var body: some View {
|
||||||
let file = chatItem.file
|
let file = chatItem.file
|
||||||
ZStack {
|
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
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 {
|
||||||
videoView(player, decrypted, file, preview, duration)
|
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 {
|
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||||
videoViewEncrypted(file, defaultPreview, duration)
|
videoViewEncrypted(file, defaultPreview, duration)
|
||||||
} else if let preview {
|
} else if let preview, let file {
|
||||||
imageView(preview)
|
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let file = file {
|
|
||||||
switch file.fileStatus {
|
switch file.fileStatus {
|
||||||
case .rcvInvitation, .rcvAborted:
|
case .rcvInvitation, .rcvAborted:
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
@@ -82,21 +81,64 @@ struct CIVideoView: View {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
durationProgress()
|
if !smallView {
|
||||||
|
durationProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let file = file, showDownloadButton(file.fileStatus) {
|
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||||
Button {
|
if !smallView {
|
||||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
Button {
|
||||||
} label: {
|
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||||
|
} label: {
|
||||||
|
playPauseIcon("play.fill")
|
||||||
|
}
|
||||||
|
} else if !file.showStatusIconInSmallView {
|
||||||
playPauseIcon("play.fill")
|
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 showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
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 {
|
||||||
switch fileStatus {
|
switch fileStatus {
|
||||||
case .rcvInvitation: true
|
case .rcvInvitation: true
|
||||||
case .rcvAborted: true
|
case .rcvAborted: true
|
||||||
@@ -109,11 +151,6 @@ struct CIVideoView: View {
|
|||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||||
imageView(defaultPreview)
|
imageView(defaultPreview)
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
|
||||||
if let decrypted = urlDecrypted {
|
|
||||||
fullScreenPlayer(decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
decrypt(file: file) {
|
decrypt(file: file) {
|
||||||
showFullScreenPlayer = urlDecrypted != nil
|
showFullScreenPlayer = urlDecrypted != nil
|
||||||
@@ -122,20 +159,22 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !decryptionInProgress {
|
if !blurred {
|
||||||
Button {
|
if !decryptionInProgress {
|
||||||
decrypt(file: file) {
|
Button {
|
||||||
if urlDecrypted != nil {
|
decrypt(file: file) {
|
||||||
videoPlaying = true
|
if urlDecrypted != nil {
|
||||||
player?.play()
|
videoPlaying = true
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||||
}
|
}
|
||||||
} label: {
|
.disabled(!canBePlayed)
|
||||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
} else {
|
||||||
|
videoDecryptionProgress()
|
||||||
}
|
}
|
||||||
.disabled(!canBePlayed)
|
|
||||||
} else {
|
|
||||||
videoDecryptionProgress()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +193,7 @@ struct CIVideoView: View {
|
|||||||
videoPlaying = false
|
videoPlaying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||||
fullScreenPlayer(url)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
switch player.timeControlStatus {
|
switch player.timeControlStatus {
|
||||||
case .playing:
|
case .playing:
|
||||||
@@ -172,7 +209,7 @@ struct CIVideoView: View {
|
|||||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||||
showFullScreenPlayer = false
|
showFullScreenPlayer = false
|
||||||
}
|
}
|
||||||
if !videoPlaying {
|
if !videoPlaying && !blurred {
|
||||||
Button {
|
Button {
|
||||||
m.stopPreviousRecPlay = url
|
m.stopPreviousRecPlay = url
|
||||||
player.play()
|
player.play()
|
||||||
@@ -194,14 +231,53 @@ 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 {
|
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, smallView ? 0 : 4)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
@@ -209,9 +285,9 @@ struct CIVideoView: View {
|
|||||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||||
.tint(color)
|
.tint(color)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
|
||||||
.background(Color.black.opacity(0.35))
|
.background(Color.black.opacity(0.35))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
@@ -247,7 +323,23 @@ struct CIVideoView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: w)
|
.frame(width: w)
|
||||||
fileStatusIcon()
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +414,7 @@ struct CIVideoView: View {
|
|||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(padding)
|
.padding(smallView ? 0 : padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressView() -> some View {
|
private func progressView() -> some View {
|
||||||
@@ -330,7 +422,7 @@ struct CIVideoView: View {
|
|||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.padding(11)
|
.padding(smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||||
@@ -342,7 +434,7 @@ struct CIVideoView: View {
|
|||||||
)
|
)
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
.padding([.trailing, .top], 11)
|
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO encrypt: where file size is checked?
|
// TODO encrypt: where file size is checked?
|
||||||
@@ -382,7 +474,8 @@ struct CIVideoView: View {
|
|||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||||
m.stopPreviousRecPlay = url
|
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
|
||||||
|
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
|
||||||
if let player = fullPlayer {
|
if let player = fullPlayer {
|
||||||
player.play()
|
player.play()
|
||||||
var played = false
|
var played = false
|
||||||
@@ -419,10 +512,12 @@ struct CIVideoView: View {
|
|||||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let decrypted = urlDecrypted {
|
if let decrypted = urlDecrypted {
|
||||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
if !smallView {
|
||||||
|
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||||
|
}
|
||||||
fullPlayer = AVPlayer(url: decrypted)
|
fullPlayer = AVPlayer(url: decrypted)
|
||||||
}
|
}
|
||||||
decryptionInProgress = true
|
decryptionInProgress = false
|
||||||
completed?()
|
completed?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,26 @@ struct CIVoiceView: View {
|
|||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
@State var audioPlayer: AudioPlayer? = nil
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
@Binding var playbackTime: TimeInterval?
|
@State var playbackTime: TimeInterval? = nil
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var smallViewSize: CGFloat?
|
||||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if chatItem.chatDir.sent {
|
if smallViewSize != nil {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
player()
|
||||||
|
playerTime()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
|
playbackSlider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if chatItem.chatDir.sent {
|
||||||
VStack (alignment: .trailing, spacing: 6) {
|
VStack (alignment: .trailing, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||||
@@ -54,7 +65,13 @@ struct CIVoiceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func player() -> some View {
|
private func player() -> some View {
|
||||||
VoiceMessagePlayer(
|
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
|
||||||
|
voiceMessageSizeBasedOnSquareSize(sz) / 56
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
return VoiceMessagePlayer(
|
||||||
|
chat: chat,
|
||||||
chatItem: chatItem,
|
chatItem: chatItem,
|
||||||
recordingFile: recordingFile,
|
recordingFile: recordingFile,
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
@@ -63,7 +80,8 @@ struct CIVoiceView: View {
|
|||||||
audioPlayer: $audioPlayer,
|
audioPlayer: $audioPlayer,
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime,
|
playbackTime: $playbackTime,
|
||||||
allowMenu: $allowMenu
|
allowMenu: $allowMenu,
|
||||||
|
sizeMultiplier: sizeMultiplier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +137,7 @@ struct VoiceMessagePlayerTime: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct VoiceMessagePlayer: View {
|
struct VoiceMessagePlayer: View {
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
@@ -130,7 +149,9 @@ struct VoiceMessagePlayer: View {
|
|||||||
@Binding var audioPlayer: AudioPlayer?
|
@Binding var audioPlayer: AudioPlayer?
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
@Binding var playbackState: VoiceMessagePlaybackState
|
||||||
@Binding var playbackTime: TimeInterval?
|
@Binding var playbackTime: TimeInterval?
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
var sizeMultiplier: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -190,49 +211,113 @@ struct VoiceMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.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) }
|
seek = { to in audioPlayer?.seek(to) }
|
||||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
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?.onFinishPlayback = {
|
audioPlayer?.onFinishPlayback = {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
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
|
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||||
audioPlayer?.stop()
|
stopPlayback()
|
||||||
playbackState = .noPlayback
|
|
||||||
playbackTime = TimeInterval(0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: playbackState) { state in
|
.onChange(of: playbackState) { state in
|
||||||
allowMenu = state == .paused || state == .noPlayback
|
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 {
|
@ViewBuilder private func playbackButton() -> some View {
|
||||||
switch playbackState {
|
if sizeMultiplier != 1 {
|
||||||
case .noPlayback:
|
switch playbackState {
|
||||||
Button {
|
case .noPlayback:
|
||||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
|
||||||
startPlayback(recordingSource)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
playPauseIcon("play.fill", theme.colors.primary)
|
playPauseIcon("play.fill", theme.colors.primary)
|
||||||
}
|
.onTapGesture {
|
||||||
case .playing:
|
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||||
Button {
|
startPlayback(recordingSource)
|
||||||
audioPlayer?.pause()
|
}
|
||||||
playbackState = .paused
|
}
|
||||||
} label: {
|
case .playing:
|
||||||
playPauseIcon("pause.fill", theme.colors.primary)
|
playPauseIcon("pause.fill", theme.colors.primary)
|
||||||
}
|
.onTapGesture {
|
||||||
case .paused:
|
audioPlayer?.pause()
|
||||||
Button {
|
playbackState = .paused
|
||||||
audioPlayer?.play()
|
notifyStateChange()
|
||||||
playbackState = .playing
|
}
|
||||||
} label: {
|
case .paused:
|
||||||
playPauseIcon("play.fill", theme.colors.primary)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,28 +327,49 @@ struct VoiceMessagePlayer: View {
|
|||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
if recordingTime > 0 {
|
if recordingTime > 0 {
|
||||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||||
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||||
Button {
|
Group {
|
||||||
Task {
|
if sizeMultiplier != 1 {
|
||||||
if let user = chatModel.currentUser {
|
playPauseIcon(icon, theme.colors.primary)
|
||||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
}
|
||||||
playPauseIcon(icon, theme.colors.primary)
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,33 +394,96 @@ struct VoiceMessagePlayer: View {
|
|||||||
Image(systemName: image)
|
Image(systemName: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
|
||||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadingIcon() -> some View {
|
private func loadingIcon() -> some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||||
.frame(width: 56, height: 56)
|
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
let audioPath = getAppFilePath(recordingSource.filePath)
|
||||||
|
let chatId = chatModel.chatId
|
||||||
|
let userId = chatModel.currentUser?.userId
|
||||||
|
chatModel.stopPreviousRecPlay = audioPath
|
||||||
audioPlayer = AudioPlayer(
|
audioPlayer = AudioPlayer(
|
||||||
onTimer: { playbackTime = $0 },
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
onFinishPlayback: {
|
onFinishPlayback: {
|
||||||
playbackState = .noPlayback
|
playbackState = .noPlayback
|
||||||
playbackTime = TimeInterval(0)
|
playbackTime = TimeInterval(0)
|
||||||
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||||
playbackState = .playing
|
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 {
|
struct CIVoiceView_Previews: PreviewProvider {
|
||||||
@@ -339,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
|||||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||||
duration: 30,
|
duration: 30,
|
||||||
audioPlayer: .constant(nil),
|
|
||||||
playbackState: .constant(.playing),
|
|
||||||
playbackTime: .constant(TimeInterval(20)),
|
|
||||||
allowMenu: Binding.constant(true)
|
allowMenu: Binding.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: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||||
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(), 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), 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))
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, 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))
|
||||||
}
|
}
|
||||||
.previewLayout(.fixed(width: 360, height: 360))
|
.previewLayout(.fixed(width: 360, height: 360))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ struct DeletedItemView: View {
|
|||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, theme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,23 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct FramedCIVoiceView: View {
|
struct FramedCIVoiceView: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
var chatItem: ChatItem
|
var chatItem: ChatItem
|
||||||
let recordingFile: CIFile?
|
let recordingFile: CIFile?
|
||||||
let duration: Int
|
let duration: Int
|
||||||
|
|
||||||
|
@State var audioPlayer: AudioPlayer? = nil
|
||||||
|
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||||
|
@State var playbackTime: TimeInterval? = nil
|
||||||
|
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
|
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
|
||||||
@Binding var playbackTime: TimeInterval?
|
|
||||||
|
|
||||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VoiceMessagePlayer(
|
VoiceMessagePlayer(
|
||||||
|
chat: chat,
|
||||||
chatItem: chatItem,
|
chatItem: chatItem,
|
||||||
recordingFile: recordingFile,
|
recordingFile: recordingFile,
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
@@ -36,7 +38,8 @@ struct FramedCIVoiceView: View {
|
|||||||
audioPlayer: $audioPlayer,
|
audioPlayer: $audioPlayer,
|
||||||
playbackState: $playbackState,
|
playbackState: $playbackState,
|
||||||
playbackTime: $playbackTime,
|
playbackTime: $playbackTime,
|
||||||
allowMenu: $allowMenu
|
allowMenu: $allowMenu,
|
||||||
|
sizeMultiplier: 1
|
||||||
)
|
)
|
||||||
VoiceMessagePlayerTime(
|
VoiceMessagePlayerTime(
|
||||||
recordingTime: TimeInterval(duration),
|
recordingTime: TimeInterval(duration),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -70,7 +70,6 @@ struct CIMsgError: View {
|
|||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
.onTapGesture(perform: onTap)
|
.onTapGesture(perform: onTap)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ struct MarkedDeletedItemView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(chatItem, theme))
|
.background(chatItemFrameColor(chatItem, theme))
|
||||||
.cornerRadius(18)
|
|
||||||
.textSelection(.disabled)
|
.textSelection(.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +35,8 @@ struct MarkedDeletedItemView: View {
|
|||||||
var blockedByAdmin = 0
|
var blockedByAdmin = 0
|
||||||
var deleted = 0
|
var deleted = 0
|
||||||
var moderatedBy: Set<String> = []
|
var moderatedBy: Set<String> = []
|
||||||
while i < m.reversedChatItems.count,
|
while i < ItemsModel.shared.reversedChatItems.count,
|
||||||
let ci = .some(m.reversedChatItems[i]),
|
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
|
||||||
ci.mergeCategory == ciCategory,
|
ci.mergeCategory == ciCategory,
|
||||||
let itemDeleted = ci.meta.itemDeleted {
|
let itemDeleted = ci.meta.itemDeleted {
|
||||||
switch itemDeleted {
|
switch itemDeleted {
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ struct ChatItemForwardingView: View {
|
|||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@FocusState private var searchFocused
|
@FocusState private var searchFocused
|
||||||
@State private var alert: SomeAlert?
|
@State private var alert: SomeAlert?
|
||||||
@State private var hasSimplexLink_: Bool?
|
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||||
private let chatsToForwardTo = filterChatsToForwardTo()
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
@@ -67,50 +66,6 @@ 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 {
|
private func emptyList() -> some View {
|
||||||
Text("No filtered chats")
|
Text("No filtered chats")
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
@@ -118,7 +73,11 @@ struct ChatItemForwardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||||
let prohibited = prohibitedByPref(chat)
|
let prohibited = chat.prohibitedByPref(
|
||||||
|
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
|
||||||
|
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
|
||||||
|
isVoice: ci.content.msgContent?.isVoice ?? false
|
||||||
|
)
|
||||||
Button {
|
Button {
|
||||||
if prohibited {
|
if prohibited {
|
||||||
alert = SomeAlert(
|
alert = SomeAlert(
|
||||||
@@ -138,7 +97,7 @@ struct ChatItemForwardingView: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||||
chatModel.chatId = chat.id
|
ItemsModel.shared.loadOpenChat(chat.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -162,27 +121,6 @@ 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 {
|
#Preview {
|
||||||
ChatItemForwardingView(
|
ChatItemForwardingView(
|
||||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||||
@@ -190,3 +128,4 @@ private func canForwardToChat(_ chat: Chat) -> Bool {
|
|||||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||||
).environmentObject(CurrentColors.toAppTheme())
|
).environmentObject(CurrentColors.toAppTheme())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ struct ChatItemInfoView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(chatItemFrameColor(ci, theme))
|
.background(chatItemFrameColor(ci, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if itemVersion.msgContent.text != "" {
|
if itemVersion.msgContent.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -302,7 +302,7 @@ struct ChatItemInfoView: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(quotedMsgFrameColor(qi, theme))
|
.background(quotedMsgFrameColor(qi, theme))
|
||||||
.cornerRadius(18)
|
.modifier(ChatItemClipped())
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if qi.text != "" {
|
if qi.text != "" {
|
||||||
Button {
|
Button {
|
||||||
@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
chatModel.chatId = forwardedFromItem.chatInfo.id
|
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,7 +415,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
let mss = membersStatuses(memberDeliveryStatuses)
|
let mss = membersStatuses(memberDeliveryStatuses)
|
||||||
if !mss.isEmpty {
|
if !mss.isEmpty {
|
||||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||||
@@ -428,7 +428,7 @@ struct ChatItemInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
|
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
|
||||||
memberDeliveryStatuses.compactMap({ mds in
|
memberDeliveryStatuses.compactMap({ mds in
|
||||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||||
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
||||||
@@ -438,9 +438,9 @@ struct ChatItemInfoView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
|
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
||||||
HStack{
|
HStack{
|
||||||
ProfileImage(imageStr: member.image, size: 30)
|
MemberProfileImage(member, size: 30)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
Text(member.chatViewName)
|
Text(member.chatViewName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -450,23 +450,19 @@ struct ChatItemInfoView: View {
|
|||||||
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||||
}
|
}
|
||||||
let v = Group {
|
let v = Group {
|
||||||
if let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) {
|
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||||
switch status {
|
switch status {
|
||||||
case .sndRcvd:
|
case .rcvd:
|
||||||
ZStack(alignment: .trailing) {
|
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)
|
Image(systemName: icon)
|
||||||
.foregroundColor(statusColor)
|
.foregroundColor(statusColor.opacity(0.67))
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundColor(statusColor.opacity(0.67))
|
||||||
}
|
}
|
||||||
} else {
|
default:
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: icon)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(statusColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,28 +16,20 @@ struct ChatItemView: View {
|
|||||||
var maxWidth: CGFloat = .infinity
|
var maxWidth: CGFloat = .infinity
|
||||||
@Binding var revealed: Bool
|
@Binding var revealed: Bool
|
||||||
@Binding var allowMenu: Bool
|
@Binding var allowMenu: Bool
|
||||||
@Binding var audioPlayer: AudioPlayer?
|
|
||||||
@Binding var playbackState: VoiceMessagePlaybackState
|
|
||||||
@Binding var playbackTime: TimeInterval?
|
|
||||||
init(
|
init(
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
chatItem: ChatItem,
|
chatItem: ChatItem,
|
||||||
showMember: Bool = false,
|
showMember: Bool = false,
|
||||||
maxWidth: CGFloat = .infinity,
|
maxWidth: CGFloat = .infinity,
|
||||||
revealed: Binding<Bool>,
|
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.chat = chat
|
||||||
self.chatItem = chatItem
|
self.chatItem = chatItem
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
_revealed = revealed
|
_revealed = revealed
|
||||||
_allowMenu = allowMenu
|
_allowMenu = allowMenu
|
||||||
_audioPlayer = audioPlayer
|
|
||||||
_playbackState = playbackState
|
|
||||||
_playbackTime = playbackTime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -48,7 +40,7 @@ struct ChatItemView: View {
|
|||||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||||
EmojiItemView(chat: chat, chatItem: ci)
|
EmojiItemView(chat: chat, chatItem: ci)
|
||||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
|
||||||
} else if ci.content.msgContent == nil {
|
} else if ci.content.msgContent == nil {
|
||||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||||
} else {
|
} else {
|
||||||
@@ -68,9 +60,7 @@ struct ChatItemView: View {
|
|||||||
default: nil
|
default: nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.map { dropImagePrefix($0) }
|
.flatMap { UIImage(base64Encoded: $0) }
|
||||||
.flatMap { Data(base64Encoded: $0) }
|
|
||||||
.flatMap { UIImage(data: $0) }
|
|
||||||
let adjustedMaxWidth = {
|
let adjustedMaxWidth = {
|
||||||
if let preview, preview.size.width <= preview.size.height {
|
if let preview, preview.size.width <= preview.size.height {
|
||||||
maxWidth * 0.75
|
maxWidth * 0.75
|
||||||
@@ -86,10 +76,7 @@ struct ChatItemView: View {
|
|||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
imgWidth: adjustedMaxWidth,
|
imgWidth: adjustedMaxWidth,
|
||||||
videoWidth: adjustedMaxWidth,
|
videoWidth: adjustedMaxWidth,
|
||||||
allowMenu: $allowMenu,
|
allowMenu: $allowMenu
|
||||||
audioPlayer: $audioPlayer,
|
|
||||||
playbackState: $playbackState,
|
|
||||||
playbackTime: $playbackTime
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,9 +32,8 @@ struct ComposeFileView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.frame(height: 50)
|
.frame(height: 54)
|
||||||
.background(theme.appColors.sentMessage)
|
.background(theme.appColors.sentMessage)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ struct ComposeImageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
let imgs: [UIImage] = images.compactMap { image in
|
let imgs: [UIImage] = images.compactMap { image in
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(image)) {
|
UIImage(base64Encoded: image)
|
||||||
return UIImage(data: data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if imgs.count == 0 {
|
if imgs.count == 0 {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -49,8 +46,8 @@ struct ComposeImageView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(theme.appColors.sentMessage)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,35 +10,6 @@ import SwiftUI
|
|||||||
import LinkPresentation
|
import LinkPresentation
|
||||||
import SimpleXChat
|
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 {
|
struct ComposeLinkView: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
let linkPreview: LinkPreview?
|
let linkPreview: LinkPreview?
|
||||||
@@ -63,14 +34,13 @@ struct ComposeLinkView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(theme.appColors.sentMessage)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
@@ -85,7 +55,7 @@ struct ComposeLinkView: View {
|
|||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,11 +281,14 @@ struct ComposeView: View {
|
|||||||
@State private var stopPlayback: Bool = false
|
@State private var stopPlayback: Bool = false
|
||||||
|
|
||||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||||
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||||
ContextInvitingContactMemberView()
|
ContextInvitingContactMemberView()
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
// preference checks should match checks in forwarding list
|
// preference checks should match checks in forwarding list
|
||||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||||
@@ -293,10 +296,13 @@ struct ComposeView: View {
|
|||||||
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||||
if simplexLinkProhibited {
|
if simplexLinkProhibited {
|
||||||
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
||||||
|
Divider()
|
||||||
} else if fileProhibited {
|
} else if fileProhibited {
|
||||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||||
|
Divider()
|
||||||
} else if voiceProhibited {
|
} else if voiceProhibited {
|
||||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
contextItemView()
|
contextItemView()
|
||||||
switch (composeState.editing, composeState.preview) {
|
switch (composeState.editing, composeState.preview) {
|
||||||
@@ -359,7 +365,6 @@ struct ComposeView: View {
|
|||||||
: theme.colors.primary
|
: theme.colors.primary
|
||||||
)
|
)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.background(theme.colors.background)
|
|
||||||
.disabled(!chat.userCanSend)
|
.disabled(!chat.userCanSend)
|
||||||
|
|
||||||
if chat.userIsObserver {
|
if chat.userIsObserver {
|
||||||
@@ -377,6 +382,11 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background {
|
||||||
|
Color.clear
|
||||||
|
.overlay(ToolbarMaterial.material(toolbarMaterial))
|
||||||
|
.ignoresSafeArea(.all, edges: .bottom)
|
||||||
|
}
|
||||||
.onChange(of: composeState.message) { msg in
|
.onChange(of: composeState.message) { msg in
|
||||||
if composeState.linkPreviewAllowed {
|
if composeState.linkPreviewAllowed {
|
||||||
if msg.count > 0 {
|
if msg.count > 0 {
|
||||||
@@ -625,6 +635,7 @@ struct ComposeView: View {
|
|||||||
cancelPreview: cancelLinkPreview,
|
cancelPreview: cancelLinkPreview,
|
||||||
cancelEnabled: !composeState.inProgress
|
cancelEnabled: !composeState.inProgress
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .mediaPreviews(mediaPreviews: media):
|
case let .mediaPreviews(mediaPreviews: media):
|
||||||
ComposeImageView(
|
ComposeImageView(
|
||||||
images: media.map { (img, _) in img },
|
images: media.map { (img, _) in img },
|
||||||
@@ -633,6 +644,7 @@ struct ComposeView: View {
|
|||||||
chosenMedia = []
|
chosenMedia = []
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||||
|
Divider()
|
||||||
case let .voicePreview(recordingFileName, _):
|
case let .voicePreview(recordingFileName, _):
|
||||||
ComposeVoiceView(
|
ComposeVoiceView(
|
||||||
recordingFileName: recordingFileName,
|
recordingFileName: recordingFileName,
|
||||||
@@ -645,6 +657,7 @@ struct ComposeView: View {
|
|||||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||||
stopPlayback: $stopPlayback
|
stopPlayback: $stopPlayback
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .filePreview(fileName, _):
|
case let .filePreview(fileName, _):
|
||||||
ComposeFileView(
|
ComposeFileView(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -652,6 +665,7 @@ struct ComposeView: View {
|
|||||||
composeState = composeState.copy(preview: .noPreview)
|
composeState = composeState.copy(preview: .noPreview)
|
||||||
},
|
},
|
||||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,10 +675,9 @@ struct ComposeView: View {
|
|||||||
Text(reason).italic()
|
Text(reason).italic()
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
.background(.thinMaterial)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contextItemView() -> some View {
|
@ViewBuilder private func contextItemView() -> some View {
|
||||||
@@ -678,6 +691,7 @@ struct ComposeView: View {
|
|||||||
contextIcon: "arrowshape.turn.up.left",
|
contextIcon: "arrowshape.turn.up.left",
|
||||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .editingItem(chatItem: editingItem):
|
case let .editingItem(chatItem: editingItem):
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@@ -685,6 +699,7 @@ struct ComposeView: View {
|
|||||||
contextIcon: "pencil",
|
contextIcon: "pencil",
|
||||||
cancelContextItem: { clearState() }
|
cancelContextItem: { clearState() }
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
case let .forwardingItem(chatItem: forwardedItem, _):
|
case let .forwardingItem(chatItem: forwardedItem, _):
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@@ -693,6 +708,7 @@ struct ComposeView: View {
|
|||||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||||
showSender: false
|
showSender: false
|
||||||
)
|
)
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,6 +865,7 @@ struct ComposeView: View {
|
|||||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||||
let (image, data) = imageData
|
let (image, data) = imageData
|
||||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
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 await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1108,10 +1125,6 @@ struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
|
|
||||||
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeView_Previews: PreviewProvider {
|
struct ComposeView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ struct ComposeVoiceView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.frame(height: ComposeVoiceView.previewHeight)
|
.frame(height: ComposeVoiceView.previewHeight)
|
||||||
.background(theme.appColors.sentMessage)
|
.background(theme.appColors.sentMessage)
|
||||||
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recordingMode() -> some View {
|
private func recordingMode() -> some View {
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ struct ContextInvitingContactMemberView: View {
|
|||||||
Text("Send direct message to connect")
|
Text("Send direct message to connect")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(theme.appColors.sentMessage)
|
.background(.thinMaterial)
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ struct ContextItemView: View {
|
|||||||
.tint(theme.colors.primary)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(minHeight: 50)
|
.frame(minHeight: 54)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(chatItemFrameColor(contextItem, theme))
|
.background(chatItemFrameColor(contextItem, theme))
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func msgContentView(lines: Int) -> some View {
|
private func msgContentView(lines: Int) -> some View {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct SendMessageView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||||
HStack(alignment: .bottom) {
|
HStack(alignment: .bottom) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
if case .voicePreview = composeState.preview {
|
if case .voicePreview = composeState.preview {
|
||||||
@@ -66,7 +67,6 @@ struct SendMessageView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if progressByTimeout {
|
if progressByTimeout {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(1.4)
|
.scaleEffect(1.4)
|
||||||
@@ -84,10 +84,9 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.overlay(
|
.background(theme.colors.background)
|
||||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
.clipShape(composeShape)
|
||||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||||
.onChange(of: composeState.inProgress) { inProgress in
|
.onChange(of: composeState.inProgress) { inProgress in
|
||||||
@@ -258,6 +257,9 @@ struct SendMessageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Image(systemName: "mic.fill")
|
Image(systemName: "mic.fill")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
.foregroundColor(theme.colors.primary)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
@@ -311,6 +313,9 @@ struct SendMessageView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "mic")
|
Image(systemName: "mic")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.disabled(composeState.inProgress)
|
.disabled(composeState.inProgress)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
private enum AddGroupMembersAlert: Identifiable {
|
private enum AddGroupMembersAlert: Identifiable {
|
||||||
case prohibitedToInviteIncognito
|
case prohibitedToInviteIncognito
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -47,14 +47,13 @@ struct AddGroupMembersViewCommon: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
NavigationView {
|
addGroupMembersView()
|
||||||
addGroupMembersView()
|
.navigationBarBackButtonHidden()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addGroupMembersView()
|
addGroupMembersView()
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,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")
|
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):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedContacts) { _ in
|
.onChange(of: selectedContacts) { _ in
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ struct GroupChatInfoView: View {
|
|||||||
@Environment(\.dismiss) var dismiss: DismissAction
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var groupInfo: GroupInfo
|
@Binding var groupInfo: GroupInfo
|
||||||
|
var onSearch: () -> Void
|
||||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||||
@State private var groupLink: String?
|
@State private var groupLink: String?
|
||||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||||
@State private var showAddMembersSheet: Bool = false
|
@State private var groupLinkNavLinkActive: Bool = false
|
||||||
|
@State private var addMembersNavLinkActive: Bool = false
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@State private var connectionCode: String?
|
@State private var connectionCode: String?
|
||||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||||
@@ -40,7 +42,7 @@ struct GroupChatInfoView: View {
|
|||||||
case blockForAllAlert(mem: GroupMember)
|
case blockForAllAlert(mem: GroupMember)
|
||||||
case unblockForAllAlert(mem: GroupMember)
|
case unblockForAllAlert(mem: GroupMember)
|
||||||
case removeMemberAlert(mem: GroupMember)
|
case removeMemberAlert(mem: GroupMember)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -68,6 +70,15 @@ struct GroupChatInfoView: View {
|
|||||||
List {
|
List {
|
||||||
groupInfoHeader()
|
groupInfoHeader()
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
|
infoActionButtons()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: infoViewActionButtonHeight)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if groupInfo.canEdit {
|
if groupInfo.canEdit {
|
||||||
@@ -158,7 +169,7 @@ struct GroupChatInfoView: View {
|
|||||||
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||||
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -174,7 +185,6 @@ struct GroupChatInfoView: View {
|
|||||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.keyboardPadding()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func groupInfoHeader() -> some View {
|
private func groupInfoHeader() -> some View {
|
||||||
@@ -198,23 +208,95 @@ struct GroupChatInfoView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func infoActionButtons() -> some View {
|
||||||
|
GeometryReader { g in
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
searchButton(width: buttonWidth)
|
||||||
|
if groupInfo.canAddMembers {
|
||||||
|
addMembersActionButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
muteButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "magnifyingglass", title: "search", width: width) {
|
||||||
|
dismiss()
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View {
|
||||||
|
if chat.chatInfo.incognito {
|
||||||
|
ZStack {
|
||||||
|
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
|
||||||
|
groupLinkNavLinkActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink(isActive: $groupLinkNavLinkActive) {
|
||||||
|
groupLinkDestinationView()
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.frame(width: 1, height: 1)
|
||||||
|
.hidden()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
|
||||||
|
addMembersNavLinkActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink(isActive: $addMembersNavLinkActive) {
|
||||||
|
addMembersDestinationView()
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.frame(width: 1, height: 1)
|
||||||
|
.hidden()
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func muteButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(
|
||||||
|
image: chat.chatInfo.ntfsEnabled ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
|
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute",
|
||||||
|
width: width
|
||||||
|
) {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
}
|
||||||
|
.disabled(!groupInfo.ready)
|
||||||
|
}
|
||||||
|
|
||||||
private func addMembersButton() -> some View {
|
private func addMembersButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
addMembersDestinationView()
|
||||||
.onAppear {
|
|
||||||
searchFocussed = false
|
|
||||||
Task {
|
|
||||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
|
||||||
await MainActor.run {
|
|
||||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Invite members", systemImage: "plus")
|
Label("Invite members", systemImage: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addMembersDestinationView() -> some View {
|
||||||
|
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||||
|
.onAppear {
|
||||||
|
searchFocussed = false
|
||||||
|
Task {
|
||||||
|
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||||
|
await MainActor.run {
|
||||||
|
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
chatModel.populateGroupMembersIndexes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct MemberRowView: View {
|
private struct MemberRowView: View {
|
||||||
var groupInfo: GroupInfo
|
var groupInfo: GroupInfo
|
||||||
@ObservedObject var groupMember: GMember
|
@ObservedObject var groupMember: GMember
|
||||||
@@ -225,15 +307,14 @@ struct GroupChatInfoView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let member = groupMember.wrapped
|
let member = groupMember.wrapped
|
||||||
let v = HStack{
|
let v = HStack{
|
||||||
ProfileImage(imageStr: member.image, size: 38)
|
MemberProfileImage(member, size: 38)
|
||||||
.padding(.trailing, 2)
|
.padding(.trailing, 2)
|
||||||
// TODO server connection status
|
// TODO server connection status
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||||
(member.verified ? memberVerifiedShield + t : t)
|
(member.verified ? memberVerifiedShield + t : t)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
let s = Text(member.memberStatus.shortText)
|
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
|
||||||
(user ? Text ("you: ") + s : s)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
@@ -266,6 +347,16 @@ 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 {
|
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||||
if member.blocked {
|
if member.blocked {
|
||||||
Text("blocked")
|
Text("blocked")
|
||||||
@@ -342,16 +433,7 @@ struct GroupChatInfoView: View {
|
|||||||
|
|
||||||
private func groupLinkButton() -> some View {
|
private func groupLinkButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupLinkView(
|
groupLinkDestinationView()
|
||||||
groupId: groupInfo.groupId,
|
|
||||||
groupLink: $groupLink,
|
|
||||||
groupLinkMemberRole: $groupLinkMemberRole,
|
|
||||||
showTitle: false,
|
|
||||||
creatingGroup: false
|
|
||||||
)
|
|
||||||
.navigationBarTitle("Group link")
|
|
||||||
.modifier(ThemedBackground(grouped: true))
|
|
||||||
.navigationBarTitleDisplayMode(.large)
|
|
||||||
} label: {
|
} label: {
|
||||||
if groupLink == nil {
|
if groupLink == nil {
|
||||||
Label("Create group link", systemImage: "link.badge.plus")
|
Label("Create group link", systemImage: "link.badge.plus")
|
||||||
@@ -361,6 +443,19 @@ struct GroupChatInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func groupLinkDestinationView() -> some View {
|
||||||
|
GroupLinkView(
|
||||||
|
groupId: groupInfo.groupId,
|
||||||
|
groupLink: $groupLink,
|
||||||
|
groupLinkMemberRole: $groupLinkMemberRole,
|
||||||
|
showTitle: false,
|
||||||
|
creatingGroup: false
|
||||||
|
)
|
||||||
|
.navigationBarTitle("Group link")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
}
|
||||||
|
|
||||||
private func editGroupButton() -> some View {
|
private func editGroupButton() -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
GroupProfileView(
|
GroupProfileView(
|
||||||
@@ -567,7 +662,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
GroupChatInfoView(
|
GroupChatInfoView(
|
||||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||||
|
onSearch: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
|
|||||||
|
|
||||||
private enum GroupLinkAlert: Identifiable {
|
private enum GroupLinkAlert: Identifiable {
|
||||||
case deleteLink
|
case deleteLink
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -34,14 +34,13 @@ struct GroupLinkView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if creatingGroup {
|
if creatingGroup {
|
||||||
NavigationView {
|
groupLinkView()
|
||||||
groupLinkView()
|
.navigationBarBackButtonHidden()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button ("Continue") { linkCreatedCb?() }
|
Button ("Continue") { linkCreatedCb?() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
groupLinkView()
|
groupLinkView()
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ struct GroupLinkView: View {
|
|||||||
}, secondaryButton: .cancel()
|
}, secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: groupLinkMemberRole) { _ in
|
.onChange(of: groupLinkMemberRole) { _ in
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ struct GroupMemberInfoView: View {
|
|||||||
case syncConnectionForceAlert
|
case syncConnectionForceAlert
|
||||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||||
case queueInfo(info: String)
|
case queueInfo(info: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case someAlert(alert: SomeAlert)
|
||||||
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -52,6 +53,7 @@ struct GroupMemberInfoView: View {
|
|||||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||||
case let .queueInfo(info): return "queueInfo \(info)"
|
case let .queueInfo(info): return "queueInfo \(info)"
|
||||||
|
case let .someAlert(alert): return "someAlert \(alert.id)"
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +67,11 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
|
||||||
if let chat = chatModel.getContactChat(contactId),
|
if let chat = chatModel.getContactChat(contactId),
|
||||||
chat.chatInfo.contact?.directOrUsed == true {
|
let contact = chat.chatInfo.contact,
|
||||||
return chat
|
contact.directOrUsed == true {
|
||||||
|
return (chat, contact)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -76,157 +79,154 @@ struct GroupMemberInfoView: View {
|
|||||||
|
|
||||||
private func groupMemberInfoView() -> some View {
|
private func groupMemberInfoView() -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
let member = groupMember.wrapped
|
||||||
let member = groupMember.wrapped
|
List {
|
||||||
List {
|
groupMemberInfoHeader(member)
|
||||||
groupMemberInfoHeader(member)
|
.listRowBackground(Color.clear)
|
||||||
.listRowBackground(Color.clear)
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
|
||||||
if member.memberActive {
|
infoActionButtons(member)
|
||||||
Section {
|
.padding(.horizontal)
|
||||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
.frame(maxWidth: .infinity)
|
||||||
knownDirectChatButton(chat)
|
.frame(height: infoViewActionButtonHeight)
|
||||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
.listRowBackground(Color.clear)
|
||||||
if let contactId = member.memberContactId {
|
.listRowSeparator(.hidden)
|
||||||
newDirectChatButton(contactId)
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
|
||||||
createMemberContactButton()
|
if member.memberActive {
|
||||||
}
|
Section {
|
||||||
}
|
if let code = connectionCode { verifyCodeButton(code) }
|
||||||
if let code = connectionCode { verifyCodeButton(code) }
|
if let connStats = connectionStats,
|
||||||
if let connStats = connectionStats,
|
connStats.ratchetSyncAllowed {
|
||||||
connStats.ratchetSyncAllowed {
|
synchronizeConnectionButton()
|
||||||
synchronizeConnectionButton()
|
|
||||||
}
|
|
||||||
// } else if developerTools {
|
|
||||||
// synchronizeConnectionButtonForce()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
// } else if developerTools {
|
||||||
|
// synchronizeConnectionButtonForce()
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let contactLink = member.contactLink {
|
if let contactLink = member.contactLink {
|
||||||
Section {
|
Section {
|
||||||
SimpleXLinkQRCode(uri: contactLink)
|
SimpleXLinkQRCode(uri: contactLink)
|
||||||
Button {
|
Button {
|
||||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share address", systemImage: "square.and.arrow.up")
|
Label("Share address", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
if let contactId = member.memberContactId {
|
if let contactId = member.memberContactId {
|
||||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||||
connectViaAddressButton(contactLink)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
connectViaAddressButton(contactLink)
|
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 {
|
} else {
|
||||||
infoRow("Role", member.memberRole.text)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO invited by - need to get contact by contact id
|
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)")
|
||||||
if let conn = member.activeConn {
|
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)
|
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)
|
infoRow("Connection", connLevelDesc)
|
||||||
}
|
}
|
||||||
}
|
Button ("Debug delivery") {
|
||||||
|
Task {
|
||||||
if let connStats = connectionStats {
|
do {
|
||||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||||
// TODO network connection status
|
await MainActor.run { alert = .queueInfo(info: info) }
|
||||||
Button("Change receiving address") {
|
} catch let e {
|
||||||
alert = .switchAddressAlert
|
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||||
}
|
let a = getErrorAlert(e, "Error")
|
||||||
.disabled(
|
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||||
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 {
|
.navigationBarHidden(true)
|
||||||
if #unavailable(iOS 16) {
|
.onAppear {
|
||||||
// this condition prevents re-setting picker
|
if #unavailable(iOS 16) {
|
||||||
if !justOpened { return }
|
// this condition prevents re-setting picker
|
||||||
}
|
if !justOpened { return }
|
||||||
justOpened = false
|
}
|
||||||
DispatchQueue.main.async {
|
justOpened = false
|
||||||
newRole = member.memberRole
|
DispatchQueue.main.async {
|
||||||
do {
|
newRole = member.memberRole
|
||||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
do {
|
||||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||||
connectionStats = stats
|
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||||
connectionCode = code
|
connectionStats = stats
|
||||||
} catch let error {
|
connectionCode = code
|
||||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
} catch let error {
|
||||||
}
|
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: newRole) { newRole in
|
}
|
||||||
if newRole != member.memberRole {
|
.onChange(of: newRole) { newRole in
|
||||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.alert(item: $alert) { alertItem in
|
.alert(item: $alert) { alertItem in
|
||||||
switch(alertItem) {
|
switch(alertItem) {
|
||||||
@@ -241,7 +241,8 @@ struct GroupMemberInfoView: View {
|
|||||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||||
case let .queueInfo(info): return queueInfoAlert(info)
|
case let .queueInfo(info): return queueInfoAlert(info)
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .someAlert(a): return a.alert
|
||||||
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||||
@@ -253,6 +254,57 @@ struct GroupMemberInfoView: View {
|
|||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func infoActionButtons(_ member: GroupMember) -> some View {
|
||||||
|
GeometryReader { g in
|
||||||
|
let buttonWidth = g.size.width / 4
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
|
||||||
|
knownDirectChatButton(chat, width: buttonWidth)
|
||||||
|
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||||
|
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||||
|
if let contactId = member.memberContactId {
|
||||||
|
newDirectChatButton(contactId, width: buttonWidth)
|
||||||
|
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||||
|
createMemberContactButton(width: buttonWidth)
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||||
|
}
|
||||||
|
} else { // no known contact chat && directMessages are off
|
||||||
|
InfoViewButton(image: "message.fill", title: "message", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't message member")
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
|
||||||
|
}
|
||||||
|
InfoViewButton(image: "video.fill", title: "video", disabledLook: true, width: buttonWidth) { showDirectMessagesProhibitedAlert("Can't call member")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showSendMessageToEnableCallsAlert() {
|
||||||
|
alert = .someAlert(alert: SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: "Can't call member",
|
||||||
|
message: "Send message to enable calls."
|
||||||
|
),
|
||||||
|
id: "can't call member, send message"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
|
||||||
|
alert = .someAlert(alert: SomeAlert(
|
||||||
|
alert: mkAlert(
|
||||||
|
title: title,
|
||||||
|
message: "Direct messages between members are prohibited in this group."
|
||||||
|
),
|
||||||
|
id: "can't message member, direct messages prohibited"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||||
Button {
|
Button {
|
||||||
planAndConnect(
|
planAndConnect(
|
||||||
@@ -267,36 +319,32 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
|
||||||
Button {
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
dismissAllSheets(animated: true)
|
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.chatId = chat.id
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDirectChatButton(_ contactId: Int64) -> some View {
|
|
||||||
Button {
|
|
||||||
do {
|
|
||||||
let chat = try apiGetChat(type: .direct, id: contactId)
|
|
||||||
chatModel.addChat(chat)
|
|
||||||
dismissAllSheets(animated: true)
|
dismissAllSheets(animated: true)
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.chatId = chat.id
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMemberContactButton() -> some View {
|
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||||
Button {
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||||
|
chatModel.addChat(chat)
|
||||||
|
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||||
|
dismissAllSheets(animated: true)
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createMemberContactButton(width: CGFloat) -> some View {
|
||||||
|
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||||
progressIndicator = true
|
progressIndicator = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -304,9 +352,10 @@ struct GroupMemberInfoView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
progressIndicator = false
|
progressIndicator = false
|
||||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||||
dismissAllSheets(animated: true)
|
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||||
chatModel.chatId = memberContact.id
|
dismissAllSheets(animated: true)
|
||||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
}
|
||||||
|
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||||
@@ -317,14 +366,12 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Send direct message", systemImage: "message")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding()
|
.padding()
|
||||||
if mem.verified {
|
if mem.verified {
|
||||||
@@ -586,6 +633,21 @@ struct GroupMemberInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MemberProfileImage(
|
||||||
|
_ mem: GroupMember,
|
||||||
|
size: CGFloat,
|
||||||
|
color: Color = Color(uiColor: .tertiarySystemGroupedBackground),
|
||||||
|
backgroundColor: Color? = nil
|
||||||
|
) -> some View {
|
||||||
|
ProfileImage(
|
||||||
|
imageStr: mem.image,
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
blurred: mem.blocked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Block member?"),
|
title: Text("Block member?"),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Combine
|
|||||||
|
|
||||||
/// A List, which displays it's items in reverse order - from bottom to top
|
/// A List, which displays it's items in reverse order - from bottom to top
|
||||||
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||||
|
|
||||||
let items: Array<Item>
|
let items: Array<Item>
|
||||||
|
|
||||||
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||||
@@ -33,7 +32,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
case let .item(id):
|
case let .item(id):
|
||||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||||
case .bottom:
|
case .bottom:
|
||||||
controller.scroll(to: .zero, position: .top)
|
controller.scroll(to: 0, position: .top)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
controller.update(items: items)
|
controller.update(items: items)
|
||||||
@@ -45,7 +44,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
private enum Section { case main }
|
private enum Section { case main }
|
||||||
private let representer: ReverseList
|
private let representer: ReverseList
|
||||||
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||||
private var itemCount: Int = .zero
|
private var itemCount: Int = 0
|
||||||
private var bag = Set<AnyCancellable>()
|
private var bag = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(representer: ReverseList) {
|
init(representer: ReverseList) {
|
||||||
@@ -53,8 +52,10 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
super.init(style: .plain)
|
super.init(style: .plain)
|
||||||
|
|
||||||
// 1. Style
|
// 1. Style
|
||||||
|
tableView = InvertedTableView()
|
||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.transform = .verticalFlip
|
tableView.transform = .verticalFlip
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
|
||||||
// 2. Register cells
|
// 2. Register cells
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
@@ -79,7 +80,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||||
.margins(.all, .zero)
|
.margins(.all, 0)
|
||||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||||
} else {
|
} else {
|
||||||
if let cell = cell as? HostingCell<Content> {
|
if let cell = cell as? HostingCell<Content> {
|
||||||
@@ -128,8 +129,14 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
from: nil,
|
from: nil,
|
||||||
for: nil
|
for: nil
|
||||||
)
|
)
|
||||||
|
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
tableView.clipsToBounds = false
|
||||||
|
parent?.viewIfLoaded?.clipsToBounds = false
|
||||||
|
}
|
||||||
|
|
||||||
/// Scrolls up
|
/// Scrolls up
|
||||||
func scrollToNextPage() {
|
func scrollToNextPage() {
|
||||||
tableView.setContentOffset(
|
tableView.setContentOffset(
|
||||||
@@ -145,18 +152,23 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
/// Scrolls to Item at index path
|
/// Scrolls to Item at index path
|
||||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||||
if let index {
|
var animated = false
|
||||||
var animated = false
|
if #available(iOS 16.0, *) {
|
||||||
if #available(iOS 16.0, *) {
|
animated = true
|
||||||
animated = true
|
}
|
||||||
}
|
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||||
tableView.scrollToRow(
|
tableView.scrollToRow(
|
||||||
at: IndexPath(row: index, section: .zero),
|
at: IndexPath(row: index, section: 0),
|
||||||
at: position,
|
at: position,
|
||||||
animated: animated
|
animated: animated
|
||||||
)
|
)
|
||||||
Task { representer.scrollState = .atDestination }
|
} else {
|
||||||
|
tableView.setContentOffset(
|
||||||
|
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
Task { representer.scrollState = .atDestination }
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(items: Array<Item>) {
|
func update(items: Array<Item>) {
|
||||||
@@ -166,7 +178,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
dataSource.defaultRowAnimation = .none
|
dataSource.defaultRowAnimation = .none
|
||||||
dataSource.apply(
|
dataSource.apply(
|
||||||
snapshot,
|
snapshot,
|
||||||
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
|
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||||
)
|
)
|
||||||
itemCount = items.count
|
itemCount = items.count
|
||||||
}
|
}
|
||||||
@@ -180,6 +192,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
|||||||
/// Updates content of the cell
|
/// Updates content of the cell
|
||||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||||
func set(content: Hosted, parent: UIViewController) {
|
func set(content: Hosted, parent: UIViewController) {
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
hostingController.rootView = content
|
hostingController.rootView = content
|
||||||
if let hostingView = hostingController.view {
|
if let hostingView = hostingController.view {
|
||||||
hostingView.invalidateIntrinsicContentSize()
|
hostingView.invalidateIntrinsicContentSize()
|
||||||
@@ -269,3 +282,28 @@ func withConditionalAnimation<Result>(
|
|||||||
try body()
|
try body()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InvertedTableView: UITableView {
|
||||||
|
static let inset = CGFloat(100)
|
||||||
|
|
||||||
|
static let insets = UIEdgeInsets(
|
||||||
|
top: inset,
|
||||||
|
left: .zero,
|
||||||
|
bottom: inset,
|
||||||
|
right: .zero
|
||||||
|
)
|
||||||
|
|
||||||
|
override var contentInsetAdjustmentBehavior: UIScrollView.ContentInsetAdjustmentBehavior {
|
||||||
|
get { .never }
|
||||||
|
set { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override var contentInset: UIEdgeInsets {
|
||||||
|
get { Self.insets }
|
||||||
|
set { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override var adjustedContentInset: UIEdgeInsets {
|
||||||
|
Self.insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
let me: Bool
|
||||||
|
let onlyOwnGroupItems: Bool
|
||||||
|
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, 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 && 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moderateEnabled = me && !onlyOwnGroupItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
|
||||||
|
return switch chatInfo {
|
||||||
|
case let .group(groupInfo):
|
||||||
|
groupInfo.membership.memberRole >= .admin
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import SwiftUI
|
|||||||
struct ChatHelp: View {
|
struct ChatHelp: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView { chatHelp() }
|
ScrollView { chatHelp() }
|
||||||
@@ -39,7 +38,7 @@ struct ChatHelp: View {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("Tap button ")
|
Text("Tap button ")
|
||||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
NewChatMenuButton()
|
||||||
Text("above, then choose:")
|
Text("above, then choose:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,35 +9,56 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
typealias DynamicSizes = (
|
||||||
.xSmall: 68,
|
rowHeight: CGFloat,
|
||||||
.small: 72,
|
profileImageSize: CGFloat,
|
||||||
.medium: 76,
|
mediaSize: CGFloat,
|
||||||
.large: 80,
|
incognitoSize: CGFloat,
|
||||||
.xLarge: 88,
|
chatInfoSize: CGFloat,
|
||||||
.xxLarge: 94,
|
unreadCorner: CGFloat,
|
||||||
.xxxLarge: 104,
|
unreadPadding: CGFloat
|
||||||
.accessibility1: 90,
|
)
|
||||||
.accessibility2: 100,
|
|
||||||
.accessibility3: 120,
|
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
|
||||||
.accessibility4: 130,
|
.xSmall: (68, 55, 33, 22, 18, 9, 3),
|
||||||
.accessibility5: 140
|
.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 defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
|
||||||
|
|
||||||
|
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
|
||||||
|
dynamicSizes[font] ?? defaultDynamicSizes
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatListNavLink: View {
|
struct ChatListNavLink: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@State private var showContactRequestDialog = false
|
@State private var showContactRequestDialog = false
|
||||||
@State private var showJoinGroupDialog = false
|
@State private var showJoinGroupDialog = false
|
||||||
@State private var showContactConnectionInfo = false
|
@State private var showContactConnectionInfo = false
|
||||||
@State private var showInvalidJSON = false
|
@State private var showInvalidJSON = false
|
||||||
@State private var showDeleteContactActionSheet = false
|
@State private var alert: SomeAlert? = nil
|
||||||
|
@State private var actionSheet: SomeActionSheet? = nil
|
||||||
|
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||||
@State private var showConnectContactViaAddressDialog = false
|
@State private var showConnectContactViaAddressDialog = false
|
||||||
@State private var inProgress = false
|
@State private var inProgress = false
|
||||||
@State private var progressByTimeout = false
|
@State private var progressByTimeout = false
|
||||||
|
|
||||||
|
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch chat.chatInfo {
|
switch chat.chatInfo {
|
||||||
@@ -65,17 +86,24 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
showDeleteContactActionSheet = true
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: false,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
@@ -86,51 +114,44 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||||
)
|
)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
toggleFavoriteButton()
|
toggleFavoriteButton()
|
||||||
ToggleNtfsButton(chat: chat)
|
toggleNtfsButton(chat: chat)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
if !chat.chatItems.isEmpty {
|
if !chat.chatItems.isEmpty {
|
||||||
clearChatButton()
|
clearChatButton()
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
if contact.ready || !contact.active {
|
deleteContactDialog(
|
||||||
showDeleteContactActionSheet = true
|
chat,
|
||||||
} else {
|
contact,
|
||||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
dismissToChatList: false,
|
||||||
}
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
.alert(item: $alert) { $0.alert }
|
||||||
if contact.ready && contact.active {
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
return ActionSheet(
|
.sheet(item: $sheet) {
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
if #available(iOS 16.0, *) {
|
||||||
buttons: [
|
$0.content
|
||||||
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
|
.presentationDetents([.fraction(0.4)])
|
||||||
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return ActionSheet(
|
$0.content
|
||||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
|
||||||
buttons: [
|
|
||||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
|
||||||
.cancel()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +160,7 @@ struct ChatListNavLink: View {
|
|||||||
switch (groupInfo.membership.memberStatus) {
|
switch (groupInfo.membership.memberStatus) {
|
||||||
case .memInvited:
|
case .memInvited:
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
joinGroupButton()
|
joinGroupButton()
|
||||||
if groupInfo.canDelete {
|
if groupInfo.canDelete {
|
||||||
@@ -159,7 +180,7 @@ struct ChatListNavLink: View {
|
|||||||
.disabled(inProgress)
|
.disabled(inProgress)
|
||||||
case .memAccepted:
|
case .memAccepted:
|
||||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||||
}
|
}
|
||||||
@@ -173,16 +194,16 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||||
disabled: !groupInfo.ready
|
disabled: !groupInfo.ready
|
||||||
)
|
)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
toggleFavoriteButton()
|
toggleFavoriteButton()
|
||||||
ToggleNtfsButton(chat: chat)
|
toggleNtfsButton(chat: chat)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
if !chat.chatItems.isEmpty {
|
if !chat.chatItems.isEmpty {
|
||||||
@@ -200,12 +221,12 @@ struct ChatListNavLink: View {
|
|||||||
|
|
||||||
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||||
NavLinkPlain(
|
NavLinkPlain(
|
||||||
tag: chat.chatInfo.id,
|
chatId: chat.chatInfo.id,
|
||||||
selection: $chatModel.chatId,
|
selection: $chatModel.chatId,
|
||||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||||
disabled: !noteFolder.ready
|
disabled: !noteFolder.ready
|
||||||
)
|
)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
markReadButton()
|
markReadButton()
|
||||||
}
|
}
|
||||||
@@ -223,7 +244,7 @@ struct ChatListNavLink: View {
|
|||||||
await MainActor.run { inProgress = false }
|
await MainActor.run { inProgress = false }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||||
}
|
}
|
||||||
@@ -233,14 +254,14 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await markChatRead(chat) }
|
Task { await markChatRead(chat) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Read", systemImage: "checkmark")
|
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(theme.colors.primary)
|
.tint(theme.colors.primary)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
Task { await markChatUnread(chat) }
|
Task { await markChatUnread(chat) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unread", systemImage: "circlebadge.fill")
|
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(theme.colors.primary)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
@@ -252,24 +273,36 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
toggleChatFavorite(chat, favorite: false)
|
toggleChatFavorite(chat, favorite: false)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Unfav.", systemImage: "star.slash")
|
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
toggleChatFavorite(chat, favorite: true)
|
toggleChatFavorite(chat, favorite: true)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Favorite", systemImage: "star.fill")
|
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
||||||
|
Button {
|
||||||
|
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||||
|
} label: {
|
||||||
|
if chat.chatInfo.ntfsEnabled {
|
||||||
|
SwipeLabel(NSLocalizedString("Mute", comment: "swipe action"), systemImage: "speaker.slash.fill", inverted: oneHandUI)
|
||||||
|
} else {
|
||||||
|
SwipeLabel(NSLocalizedString("Unmute", comment: "swipe action"), systemImage: "speaker.wave.2.fill", inverted: oneHandUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func clearChatButton() -> some View {
|
private func clearChatButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(clearChatAlert())
|
AlertManager.shared.showAlert(clearChatAlert())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Clear", systemImage: "gobackward")
|
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.orange)
|
.tint(Color.orange)
|
||||||
}
|
}
|
||||||
@@ -278,7 +311,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
||||||
} label: {
|
} label: {
|
||||||
Label("Clear", systemImage: "gobackward")
|
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.orange)
|
.tint(Color.orange)
|
||||||
}
|
}
|
||||||
@@ -287,7 +320,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
|
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(Color.yellow)
|
.tint(Color.yellow)
|
||||||
}
|
}
|
||||||
@@ -296,7 +329,7 @@ struct ChatListNavLink: View {
|
|||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
@@ -306,22 +339,23 @@ struct ChatListNavLink: View {
|
|||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||||
} label: { Label("Accept", systemImage: "checkmark") }
|
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
|
||||||
.tint(theme.colors.primary)
|
.tint(theme.colors.primary)
|
||||||
Button {
|
Button {
|
||||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Accept incognito", systemImage: "theatermasks")
|
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.indigo)
|
.tint(.indigo)
|
||||||
Button {
|
Button {
|
||||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Reject", systemImage: "multiply")
|
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { showContactRequestDialog = true }
|
.onTapGesture { showContactRequestDialog = true }
|
||||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||||
@@ -338,18 +372,18 @@ struct ChatListNavLink: View {
|
|||||||
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
||||||
})
|
})
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
deleteLabel
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showContactConnectionInfo = true
|
showContactConnectionInfo = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Name", systemImage: "pencil")
|
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI)
|
||||||
}
|
}
|
||||||
.tint(theme.colors.primary)
|
.tint(theme.colors.primary)
|
||||||
}
|
}
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||||
Group {
|
Group {
|
||||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||||
@@ -359,11 +393,16 @@ struct ChatListNavLink: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
showContactConnectionInfo = true
|
showContactConnectionInfo = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var deleteLabel: some View {
|
||||||
|
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
|
||||||
|
}
|
||||||
|
|
||||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Delete group?"),
|
title: Text("Delete group?"),
|
||||||
@@ -412,28 +451,6 @@ struct ChatListNavLink: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Reject contact request"),
|
|
||||||
message: Text("The sender will NOT be notified"),
|
|
||||||
primaryButton: .destructive(Text("Reject")) {
|
|
||||||
Task { await rejectContactRequest(contactRequest) }
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Contact is not connected yet!"),
|
|
||||||
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
|
|
||||||
primaryButton: .cancel(),
|
|
||||||
secondaryButton: .destructive(Text("Delete Contact")) {
|
|
||||||
removePendingContact(chat, contact)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func groupInvitationAcceptedAlert() -> Alert {
|
private func groupInvitationAcceptedAlert() -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Joining group"),
|
title: Text("Joining group"),
|
||||||
@@ -441,35 +458,11 @@ struct ChatListNavLink: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
|
|
||||||
Alert(
|
|
||||||
title: Text("Delete pending connection"),
|
|
||||||
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
|
|
||||||
primaryButton: .destructive(Text("Delete")) {
|
|
||||||
removePendingContact(chat, contact)
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removePendingContact(_ chat: Chat, _ contact: Contact) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
chatModel.removeChat(contact.id)
|
|
||||||
}
|
|
||||||
} catch let error {
|
|
||||||
logger.error("ChatListNavLink.removePendingContact apiDeleteChat error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func invalidJSONPreview(_ json: String) -> some View {
|
private func invalidJSONPreview(_ json: String) -> some View {
|
||||||
Text("invalid chat data")
|
Text("invalid chat data")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.frame(height: rowHeights[dynamicTypeSize])
|
.frame(height: dynamicRowHeight)
|
||||||
.onTapGesture { showInvalidJSON = true }
|
.onTapGesture { showInvalidJSON = true }
|
||||||
.appSheet(isPresented: $showInvalidJSON) {
|
.appSheet(isPresented: $showInvalidJSON) {
|
||||||
invalidJSONView(json)
|
invalidJSONView(json)
|
||||||
@@ -479,16 +472,26 @@ struct ChatListNavLink: View {
|
|||||||
|
|
||||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||||
Task {
|
Task {
|
||||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||||
if ok {
|
if ok {
|
||||||
await MainActor.run {
|
ItemsModel.shared.loadOpenChat(contact.id)
|
||||||
chatModel.chatId = contact.id
|
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||||
|
Alert(
|
||||||
|
title: Text("Reject contact request"),
|
||||||
|
message: Text("The sender will NOT be notified"),
|
||||||
|
primaryButton: .destructive(Text("Reject")) {
|
||||||
|
Task { await rejectContactRequest(contactRequest) }
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Delete pending connection?"),
|
title: Text("Delete pending connection?"),
|
||||||
@@ -515,15 +518,14 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
|
||||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||||
if let alert = alert {
|
if let alert = alert {
|
||||||
AlertManager.shared.showAlert(alert)
|
showAlert(alert)
|
||||||
return false
|
return false
|
||||||
} else if let contact = contact {
|
} else if let contact = contact {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
ChatModel.shared.updateContact(contact)
|
ChatModel.shared.updateContact(contact)
|
||||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -564,18 +566,11 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ErrorAlert {
|
|
||||||
var title: LocalizedStringKey
|
|
||||||
var message: LocalizedStringKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||||
switch error as? ChatResponse {
|
if let r = error as? ChatResponse,
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
let alert = getNetworkErrorAlert(r) {
|
||||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
return alert
|
||||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
} else {
|
||||||
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))")
|
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ struct ChatListView: View {
|
|||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var searchShowingSimplexLink = false
|
@State private var searchShowingSimplexLink = false
|
||||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
|
||||||
@State private var userPickerVisible = false
|
@State private var userPickerVisible = false
|
||||||
@State private var showConnectDesktop = false
|
@State private var showConnectDesktop = false
|
||||||
|
@State private var scrollToSearchBar = false
|
||||||
|
|
||||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||||
|
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||||
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
@@ -32,21 +36,16 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var viewBody: some View {
|
private var viewBody: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
|
||||||
NavStackCompat(
|
NavStackCompat(
|
||||||
isActive: Binding(
|
isActive: Binding(
|
||||||
get: { chatModel.chatId != nil },
|
get: { chatModel.chatId != nil },
|
||||||
set: { _ in }
|
set: { active in
|
||||||
|
if !active { chatModel.chatId = nil }
|
||||||
|
}
|
||||||
),
|
),
|
||||||
destination: chatView
|
destination: chatView
|
||||||
) {
|
) { chatListView }
|
||||||
VStack {
|
|
||||||
if chatModel.chats.isEmpty {
|
|
||||||
onboardingButtons()
|
|
||||||
}
|
|
||||||
chatListView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if userPickerVisible {
|
if userPickerVisible {
|
||||||
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
|
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -66,9 +65,14 @@ struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var chatListView: some View {
|
private var chatListView: some View {
|
||||||
VStack {
|
let tm = ToolbarMaterial.material(toolbarMaterial)
|
||||||
|
return withToolbar(tm) {
|
||||||
chatList
|
chatList
|
||||||
|
.background(theme.colors.background)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(searchMode || oneHandUI)
|
||||||
}
|
}
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
AlertManager.shared.showAlert(Alert(
|
AlertManager.shared.showAlert(Alert(
|
||||||
@@ -86,55 +90,111 @@ struct ChatListView: View {
|
|||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.safeAreaInset(edge: .top) {
|
||||||
.background(theme.colors.background)
|
if oneHandUI { Divider().background(tm) }
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
}
|
||||||
.navigationBarHidden(searchMode)
|
.safeAreaInset(edge: .bottom) {
|
||||||
.toolbar {
|
if oneHandUI {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
|
||||||
let user = chatModel.currentUser ?? User.sampleData
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
let allRead = chatModel.users
|
|
||||||
.filter { u in !u.user.activeUser && !u.user.hidden }
|
|
||||||
.allSatisfy { u in u.unreadCount == 0 }
|
|
||||||
if !allRead {
|
|
||||||
unreadBadge(size: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
|
||||||
withAnimation {
|
|
||||||
userPickerVisible.toggle()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showSettings = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .principal) {
|
}
|
||||||
HStack(spacing: 4) {
|
}
|
||||||
Text("Chats")
|
|
||||||
.font(.headline)
|
static var hasHomeIndicator: Bool = {
|
||||||
SubsStatusIndicator()
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
}
|
let window = windowScene.windows.first {
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
window.safeAreaInsets.bottom > 0
|
||||||
|
} else { false }
|
||||||
|
}()
|
||||||
|
|
||||||
|
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
if oneHandUI {
|
||||||
|
content()
|
||||||
|
.toolbarBackground(.hidden, for: .bottomBar)
|
||||||
|
.toolbar { bottomToolbar }
|
||||||
|
} else {
|
||||||
|
content()
|
||||||
|
.toolbarBackground(.automatic, for: .navigationBar)
|
||||||
|
.toolbarBackground(material)
|
||||||
|
.toolbar { topToolbar }
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
} else {
|
||||||
switch chatModel.chatRunning {
|
if oneHandUI {
|
||||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
content().toolbar { bottomToolbarGroup }
|
||||||
case .some(false): chatStoppedIcon()
|
} else {
|
||||||
case .none: EmptyView()
|
content().toolbar { topToolbar }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
|
||||||
|
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
|
||||||
|
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
|
HStack {
|
||||||
|
leadingToolbarItem.padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
SubsStatusIndicator().padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
trailingToolbarItem.padding(.bottom, padding)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { scrollToSearchBar = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
|
||||||
|
let padding: Double = Self.hasHomeIndicator ? 0 : 14
|
||||||
|
ToolbarItemGroup(placement: .bottomBar) {
|
||||||
|
leadingToolbarItem.padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
SubsStatusIndicator().padding(.bottom, padding)
|
||||||
|
Spacer()
|
||||||
|
trailingToolbarItem.padding(.bottom, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var leadingToolbarItem: some View {
|
||||||
|
let user = chatModel.currentUser ?? User.sampleData
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
let allRead = chatModel.users
|
||||||
|
.filter { u in !u.user.activeUser && !u.user.hidden }
|
||||||
|
.allSatisfy { u in u.unreadCount == 0 }
|
||||||
|
if !allRead {
|
||||||
|
unreadBadge(size: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||||
|
withAnimation {
|
||||||
|
userPickerVisible.toggle()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showSettings = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder var trailingToolbarItem: some View {
|
||||||
|
switch chatModel.chatRunning {
|
||||||
|
case .some(true): NewChatMenuButton()
|
||||||
|
case .some(false): chatStoppedIcon()
|
||||||
|
case .none: EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private var chatList: some View {
|
@ViewBuilder private var chatList: some View {
|
||||||
let cs = filteredChats()
|
let cs = filteredChats()
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
ScrollViewReader { scrollProxy in
|
||||||
List {
|
List {
|
||||||
if !chatModel.chats.isEmpty {
|
if !chatModel.chats.isEmpty {
|
||||||
ChatListSearchBar(
|
ChatListSearchBar(
|
||||||
@@ -144,27 +204,50 @@ struct ChatListView: View {
|
|||||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||||
)
|
)
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, oneHandUI ? 8 : 0)
|
||||||
|
.id("searchBar")
|
||||||
|
}
|
||||||
|
if !oneHandUICardShown {
|
||||||
|
OneHandUICard()
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
ForEach(cs, id: \.viewId) { chat in
|
ForEach(cs, id: \.viewId) { chat in
|
||||||
ChatListNavLink(chat: chat)
|
ChatListNavLink(chat: chat)
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
.padding(.trailing, -16)
|
.padding(.trailing, -16)
|
||||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
.offset(x: -8)
|
.offset(x: -8)
|
||||||
}
|
}
|
||||||
}
|
.listStyle(.plain)
|
||||||
.onChange(of: chatModel.chatId) { _ in
|
.onChange(of: chatModel.chatId) { currentChatId in
|
||||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
if let chatId = chatModel.chatToTop, currentChatId != chatId {
|
||||||
chatModel.chatToTop = nil
|
chatModel.chatToTop = nil
|
||||||
chatModel.popChat(chatId)
|
chatModel.popChat(chatId)
|
||||||
|
}
|
||||||
|
stopAudioPlayer()
|
||||||
|
}
|
||||||
|
.onChange(of: chatModel.currentUser?.userId) { _ in
|
||||||
|
stopAudioPlayer()
|
||||||
|
}
|
||||||
|
.onChange(of: scrollToSearchBar) { scrollToSearchBar in
|
||||||
|
if scrollToSearchBar {
|
||||||
|
Task { self.scrollToSearchBar = false }
|
||||||
|
withAnimation { scrollProxy.scrollTo("searchBar") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||||
Text("No filtered chats").foregroundColor(theme.colors.secondary)
|
Text("No filtered chats")
|
||||||
|
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,64 +258,37 @@ struct ChatListView: View {
|
|||||||
.foregroundColor(theme.colors.primary)
|
.foregroundColor(theme.colors.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onboardingButtons() -> some View {
|
|
||||||
VStack(alignment: .trailing, spacing: 0) {
|
|
||||||
Path { p in
|
|
||||||
p.move(to: CGPoint(x: 8, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 16, y: 10))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: 10))
|
|
||||||
p.addLine(to: CGPoint(x: 8, y: 0))
|
|
||||||
}
|
|
||||||
.fill(theme.colors.primary)
|
|
||||||
.frame(width: 20, height: 10)
|
|
||||||
.padding(.trailing, 12)
|
|
||||||
|
|
||||||
connectButton("Tap to start a new chat") {
|
|
||||||
newChatMenuOption = .newContact
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
Text("You have no chats")
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 6)
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Text(label)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
.background(theme.colors.primary)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private func chatView() -> some View {
|
@ViewBuilder private func chatView() -> some View {
|
||||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||||
ChatView(chat: chat)
|
ChatView(chat: chat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stopAudioPlayer() {
|
||||||
|
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||||
|
VoiceItemState.smallView = [:]
|
||||||
|
}
|
||||||
|
|
||||||
private func filteredChats() -> [Chat] {
|
private func filteredChats() -> [Chat] {
|
||||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||||
return chatModel.chats.filter { $0.id == linkChatId }
|
return chatModel.chats.filter { $0.id == linkChatId }
|
||||||
} else {
|
} else {
|
||||||
let s = searchString()
|
let s = searchString()
|
||||||
return s == "" && !showUnreadAndFavorites
|
return s == "" && !showUnreadAndFavorites
|
||||||
? chatModel.chats
|
? chatModel.chats.filter { chat in
|
||||||
|
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||||
|
}
|
||||||
: chatModel.chats.filter { chat in
|
: chatModel.chats.filter { chat in
|
||||||
let cInfo = chat.chatInfo
|
let cInfo = chat.chatInfo
|
||||||
switch cInfo {
|
switch cInfo {
|
||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
return s == ""
|
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
|
||||||
? filtered(chat)
|
s == ""
|
||||||
: (viewNameContains(cInfo, s) ||
|
? filtered(chat)
|
||||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
: (viewNameContains(cInfo, s) ||
|
||||||
contact.fullName.localizedLowercase.contains(s))
|
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||||
|
contact.fullName.localizedLowercase.contains(s))
|
||||||
|
)
|
||||||
case let .group(gInfo):
|
case let .group(gInfo):
|
||||||
return s == ""
|
return s == ""
|
||||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||||
@@ -267,70 +323,59 @@ struct ChatListView: View {
|
|||||||
|
|
||||||
struct SubsStatusIndicator: View {
|
struct SubsStatusIndicator: View {
|
||||||
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||||
@State private var sess: ServerSessions = ServerSessions.newServerSessions
|
@State private var hasSess: Bool = false
|
||||||
@State private var timer: Timer? = nil
|
@State private var task: Task<Void, Never>?
|
||||||
@State private var timerCounter = 0
|
|
||||||
@State private var showServersSummary = false
|
@State private var showServersSummary = false
|
||||||
|
|
||||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = 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 {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
showServersSummary = true
|
showServersSummary = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
|
Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
|
||||||
|
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
|
||||||
if showSubscriptionPercentage {
|
if showSubscriptionPercentage {
|
||||||
SubscriptionStatusPercentageView(subs: subs, sess: sess)
|
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(ChatModel.shared.chatRunning != true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
startInitialTimer()
|
startTask()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
stopTimer()
|
stopTask()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showServersSummary) {
|
.appSheet(isPresented: $showServersSummary) {
|
||||||
ServersSummaryView()
|
ServersSummaryView()
|
||||||
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startInitialTimer() {
|
private func startTask() {
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
|
task = Task {
|
||||||
getServersSummary()
|
while !Task.isCancelled {
|
||||||
timerCounter += 1
|
if AppChatState.shared.value == .active {
|
||||||
// Switch to the regular timer after the initial phase
|
do {
|
||||||
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
|
let (subs, hasSess) = try await getAgentSubsTotal()
|
||||||
switchToRegularTimer()
|
await MainActor.run {
|
||||||
|
self.subs = subs
|
||||||
|
self.hasSess = hasSess
|
||||||
|
}
|
||||||
|
} catch let error {
|
||||||
|
logger.error("getSubsTotal error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchToRegularTimer() {
|
func stopTask() {
|
||||||
timer?.invalidate()
|
task?.cancel()
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
|
task = nil
|
||||||
getServersSummary()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopTimer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getServersSummary() {
|
|
||||||
do {
|
|
||||||
let summ = try getAgentServersSummary()
|
|
||||||
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
|
|
||||||
} catch let error {
|
|
||||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +425,6 @@ struct ChatListSearchBar: View {
|
|||||||
toggleFilterButton()
|
toggleFilterButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
|
||||||
}
|
}
|
||||||
.onChange(of: searchFocussed) { sf in
|
.onChange(of: searchFocussed) { sf in
|
||||||
withAnimation { searchMode = sf }
|
withAnimation { searchMode = sf }
|
||||||
|
|||||||
@@ -12,18 +12,24 @@ import SimpleXChat
|
|||||||
struct ChatPreviewView: View {
|
struct ChatPreviewView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var progressByTimeout: Bool
|
@Binding var progressByTimeout: Bool
|
||||||
@State var deleting: Bool = false
|
@State var deleting: Bool = false
|
||||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
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
|
@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 {
|
var body: some View {
|
||||||
let cItem = chat.chatItems.last
|
let cItem = chat.chatItems.last
|
||||||
return HStack(spacing: 8) {
|
return HStack(spacing: 8) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
ChatInfoImage(chat: chat, size: 63)
|
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||||
chatPreviewImageOverlayIcon()
|
chatPreviewImageOverlayIcon()
|
||||||
.padding([.bottom, .trailing], 1)
|
.padding([.bottom, .trailing], 1)
|
||||||
}
|
}
|
||||||
@@ -43,11 +49,38 @@ struct ChatPreviewView: View {
|
|||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
chatMessagePreview(cItem)
|
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)
|
||||||
|
}
|
||||||
chatStatusImage()
|
chatStatusImage()
|
||||||
.padding(.top, 26)
|
.padding(.top, dynamicChatInfoSize * 1.44)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.trailing, 8)
|
.padding(.trailing, 8)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -57,6 +90,33 @@ struct ChatPreviewView: View {
|
|||||||
.padding(.bottom, -8)
|
.padding(.bottom, -8)
|
||||||
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
||||||
deleting = contains
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,39 +173,50 @@ struct ChatPreviewView: View {
|
|||||||
.kerning(-2)
|
.kerning(-2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
let t = text
|
let t = text
|
||||||
.lineLimit(2)
|
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, hasFilePreview ? 0 : 8)
|
||||||
.padding(.trailing, 36)
|
.padding(.trailing, hasFilePreview ? 38 : 36)
|
||||||
|
.offset(x: hasFilePreview ? -2 : 0)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if !showChatPreviews && !draft {
|
if !showChatPreviews && !draft {
|
||||||
t.privacySensitive(true).redacted(reason: .privacy)
|
t.privacySensitive(true).redacted(reason: .privacy)
|
||||||
} else {
|
} else {
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
let s = chat.chatStats
|
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||||
if s.unreadCount > 0 || s.unreadChat {
|
}
|
||||||
unreadCountText(s.unreadCount)
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white)
|
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
||||||
.padding(.horizontal, 4)
|
let s = chat.chatStats
|
||||||
.frame(minWidth: 18, minHeight: 18)
|
if s.unreadCount > 0 || s.unreadChat {
|
||||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
unreadCountText(s.unreadCount)
|
||||||
.cornerRadius(10)
|
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
.foregroundColor(.white)
|
||||||
Image(systemName: "speaker.slash.fill")
|
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||||
Image(systemName: "star.fill")
|
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||||
.resizable()
|
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||||
.scaledToFill()
|
Image(systemName: "speaker.slash.fill")
|
||||||
.frame(width: 18, height: 18)
|
.resizable()
|
||||||
.padding(.trailing, 1)
|
.scaledToFill()
|
||||||
.foregroundColor(.secondary.opacity(0.65))
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +243,7 @@ struct ChatPreviewView: View {
|
|||||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||||
|
|
||||||
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
// 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
|
// can be refactored into a single function if functions calling these are changed to return same type
|
||||||
@@ -196,18 +267,18 @@ struct ChatPreviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
|
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
|
||||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||||
chatPreviewLayout(messageDraft(draft), draft: true)
|
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
|
||||||
} else if let cItem = cItem {
|
} else if let cItem = cItem {
|
||||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
|
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
|
||||||
} else {
|
} else {
|
||||||
switch (chat.chatInfo) {
|
switch (chat.chatInfo) {
|
||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||||
chatPreviewInfoText("Tap to Connect")
|
chatPreviewInfoText("Tap to Connect")
|
||||||
.foregroundColor(theme.colors.primary)
|
.foregroundColor(theme.colors.primary)
|
||||||
} else if !contact.ready && contact.activeConn != nil {
|
} else if !contact.sndReady && contact.activeConn != nil {
|
||||||
if contact.nextSendGrpInv {
|
if contact.nextSendGrpInv {
|
||||||
chatPreviewInfoText("send direct message")
|
chatPreviewInfoText("send direct message")
|
||||||
} else if contact.active {
|
} else if contact.active {
|
||||||
@@ -225,6 +296,54 @@ 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 {
|
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
|
||||||
groupInfo.membership.memberIncognito
|
groupInfo.membership.memberIncognito
|
||||||
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
||||||
@@ -253,51 +372,101 @@ struct ChatPreviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private func chatStatusImage() -> some View {
|
@ViewBuilder private func chatStatusImage() -> some View {
|
||||||
|
let size = dynamicSize(userFont).incognitoSize
|
||||||
switch chat.chatInfo {
|
switch chat.chatInfo {
|
||||||
case let .direct(contact):
|
case let .direct(contact):
|
||||||
if contact.active && contact.activeConn != nil {
|
if contact.active && contact.activeConn != nil {
|
||||||
switch (chatModel.contactNetworkStatus(contact)) {
|
NetworkStatusView(contact: contact, size: size)
|
||||||
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
|
||||||
case .error:
|
|
||||||
Image(systemName: "exclamationmark.circle")
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 17, height: 17)
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
|
||||||
default:
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||||
}
|
}
|
||||||
case .group:
|
case .group:
|
||||||
if progressByTimeout {
|
if progressByTimeout {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NetworkStatusView: View {
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@ObservedObject var networkModel = NetworkModel.shared
|
||||||
|
|
||||||
|
let contact: Contact
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize
|
||||||
|
switch (networkModel.contactNetworkStatus(contact)) {
|
||||||
|
case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size)
|
||||||
|
case .error:
|
||||||
|
Image(systemName: "exclamationmark.circle")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
|
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
|
||||||
if incognito {
|
if incognito {
|
||||||
Image(systemName: "theatermasks")
|
Image(systemName: "theatermasks")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: size, height: size)
|
||||||
.foregroundColor(secondaryColor)
|
.foregroundColor(secondaryColor)
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
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 {
|
func unreadCountText(_ n: Int) -> Text {
|
||||||
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
|
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 {
|
struct ChatPreviewView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
|
|||||||
|
|
||||||
enum CCInfoAlert: Identifiable {
|
enum CCInfoAlert: Identifiable {
|
||||||
case deleteInvitationAlert
|
case deleteInvitationAlert
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
|
|||||||
} success: {
|
} success: {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ struct ContactConnectionView: View {
|
|||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
@State private var localAlias = ""
|
@State private var localAlias = ""
|
||||||
@FocusState private var aliasTextFieldFocused: Bool
|
@FocusState private var aliasTextFieldFocused: Bool
|
||||||
@State private var showContactConnectionInfo = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if case let .contactConnection(conn) = chat.chatInfo {
|
if case let .contactConnection(conn) = chat.chatInfo {
|
||||||
@@ -31,7 +31,6 @@ struct ContactConnectionView: View {
|
|||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 48, height: 48)
|
||||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
||||||
.onTapGesture { showContactConnectionInfo = true }
|
|
||||||
}
|
}
|
||||||
.frame(width: 63, height: 63)
|
.frame(width: 63, height: 63)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
@@ -62,7 +61,7 @@ struct ContactConnectionView: View {
|
|||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
Text(contactConnection.description)
|
Text(contactConnection.description)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
|
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize)
|
||||||
.padding(.top, 26)
|
.padding(.top, 26)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
@@ -71,9 +70,6 @@ struct ContactConnectionView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
|
||||||
ContactConnectionInfo(contactConnection: contactConnection)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import SimpleXChat
|
|||||||
struct ContactRequestView: View {
|
struct ContactRequestView: View {
|
||||||
@EnvironmentObject var chatModel: ChatModel
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
var contactRequest: UserContactRequest
|
var contactRequest: UserContactRequest
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ChatInfoImage(chat: chat, size: 63)
|
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// OneHandUICard.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by EP on 06/08/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct OneHandUICard: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||||
|
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||||
|
@State private var showOneHandUIAlert = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Toggle chat list:").font(.title3)
|
||||||
|
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
|
||||||
|
}
|
||||||
|
Image(systemName: "multiply")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.onTapGesture {
|
||||||
|
showOneHandUIAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(theme.appColors.sentMessage)
|
||||||
|
.cornerRadius(12)
|
||||||
|
.frame(height: dynamicSize(userFont).rowHeight)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.alert(isPresented: $showOneHandUIAlert) {
|
||||||
|
Alert(
|
||||||
|
title: Text("Reachable chat toolbar"),
|
||||||
|
message: Text("You can change it in Appearance settings."),
|
||||||
|
dismissButton: .default(Text("Ok")) {
|
||||||
|
withAnimation {
|
||||||
|
oneHandUICardShown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OneHandUICard()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
|||||||
|
|
||||||
struct ServersSummaryView: View {
|
struct ServersSummaryView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
@State private var serversSummary: PresentedServersSummary? = nil
|
@State private var serversSummary: PresentedServersSummary? = nil
|
||||||
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
||||||
@State private var selectedServerType: PresentedServerType = .smp
|
@State private var selectedServerType: PresentedServerType = .smp
|
||||||
@@ -36,6 +37,7 @@ struct ServersSummaryView: View {
|
|||||||
viewBody()
|
viewBody()
|
||||||
.navigationTitle("Servers info")
|
.navigationTitle("Servers info")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
shareButton()
|
shareButton()
|
||||||
@@ -57,11 +59,21 @@ struct ServersSummaryView: View {
|
|||||||
|
|
||||||
private func startTimer() {
|
private func startTimer() {
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||||
getServersSummary()
|
if AppChatState.shared.value == .active {
|
||||||
|
getServersSummary()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopTimer() {
|
private func getServersSummary() {
|
||||||
|
do {
|
||||||
|
serversSummary = try getAgentServersSummary()
|
||||||
|
} catch let error {
|
||||||
|
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
@@ -90,8 +102,8 @@ struct ServersSummaryView: View {
|
|||||||
Group {
|
Group {
|
||||||
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||||
Picker("User selection", selection: $selectedUserCategory) {
|
Picker("User selection", selection: $selectedUserCategory) {
|
||||||
Text("All users").tag(PresentedUserCategory.allUsers)
|
Text("All profiles").tag(PresentedUserCategory.allUsers)
|
||||||
Text("Current user").tag(PresentedUserCategory.currentUser)
|
Text("Current profile").tag(PresentedUserCategory.currentUser)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
@@ -182,19 +194,22 @@ struct ServersSummaryView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No info, try to reload")
|
Text("No info, try to reload")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.background(theme.colors.background)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
||||||
Section {
|
Section {
|
||||||
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
|
infoRow("Active connections", numOrDash(totals.subs.ssActive))
|
||||||
infoRow("Total", numOrDash(totals.subs.total))
|
infoRow("Total", numOrDash(totals.subs.total))
|
||||||
|
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Message subscriptions")
|
Text("Message reception")
|
||||||
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
|
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||||
if showSubscriptionPercentage {
|
if showSubscriptionPercentage {
|
||||||
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
|
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +279,7 @@ struct ServersSummaryView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitle("SMP server")
|
.navigationBarTitle("SMP server")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(serverAddress(srvSumm.smpServer))
|
Text(serverAddress(srvSumm.smpServer))
|
||||||
@@ -271,9 +287,9 @@ struct ServersSummaryView: View {
|
|||||||
if let subs = srvSumm.subs {
|
if let subs = srvSumm.subs {
|
||||||
Spacer()
|
Spacer()
|
||||||
if showSubscriptionPercentage {
|
if showSubscriptionPercentage {
|
||||||
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
|
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||||
}
|
}
|
||||||
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
|
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||||
} else if let sess = srvSumm.sessions {
|
} else if let sess = srvSumm.sessions {
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "arrow.up.circle")
|
Image(systemName: "arrow.up.circle")
|
||||||
@@ -332,6 +348,7 @@ struct ServersSummaryView: View {
|
|||||||
)
|
)
|
||||||
.navigationBarTitle("XFTP server")
|
.navigationBarTitle("XFTP server")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(serverAddress(srvSumm.xftpServer))
|
Text(serverAddress(srvSumm.xftpServer))
|
||||||
@@ -360,7 +377,7 @@ struct ServersSummaryView: View {
|
|||||||
Button {
|
Button {
|
||||||
alert = SomeAlert(
|
alert = SomeAlert(
|
||||||
alert: Alert(
|
alert: Alert(
|
||||||
title: Text("Reset all servers statistics?"),
|
title: Text("Reset all statistics?"),
|
||||||
message: Text("Servers statistics will be reset - this cannot be undone!"),
|
message: Text("Servers statistics will be reset - this cannot be undone!"),
|
||||||
primaryButton: .destructive(Text("Reset")) {
|
primaryButton: .destructive(Text("Reset")) {
|
||||||
Task {
|
Task {
|
||||||
@@ -386,24 +403,16 @@ struct ServersSummaryView: View {
|
|||||||
Text("Reset all statistics")
|
Text("Reset all statistics")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getServersSummary() {
|
|
||||||
do {
|
|
||||||
serversSummary = try getAgentServersSummary()
|
|
||||||
} catch let error {
|
|
||||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SubscriptionStatusIndicatorView: View {
|
struct SubscriptionStatusIndicatorView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
var subs: SMPServerSubs
|
var subs: SMPServerSubs
|
||||||
var sess: ServerSessions
|
var hasSess: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
@@ -417,18 +426,18 @@ struct SubscriptionStatusIndicatorView: View {
|
|||||||
struct SubscriptionStatusPercentageView: View {
|
struct SubscriptionStatusPercentageView: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
var subs: SMPServerSubs
|
var subs: SMPServerSubs
|
||||||
var sess: ServerSessions
|
var hasSess: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||||
Text("\(Int(floor(statusPercent * 100)))%")
|
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
|
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
|
||||||
func roundedToQuarter(_ n: Double) -> Double {
|
func roundedToQuarter(_ n: Double) -> Double {
|
||||||
n >= 1 ? 1
|
n >= 1 ? 1
|
||||||
: n <= 0 ? 0
|
: n <= 0 ? 0
|
||||||
@@ -439,19 +448,23 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
|
|||||||
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
||||||
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
||||||
|
|
||||||
return online && subs.total > 0
|
return !online
|
||||||
? (
|
? noConnColorAndPercent
|
||||||
subs.ssActive == 0
|
: (
|
||||||
? (
|
subs.total == 0 && !hasSess
|
||||||
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
|
||||||
|
: (
|
||||||
|
subs.ssActive == 0
|
||||||
|
? (
|
||||||
|
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
|
||||||
|
)
|
||||||
|
: ( // ssActive > 0
|
||||||
|
hasSess
|
||||||
|
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||||
|
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
||||||
|
)
|
||||||
)
|
)
|
||||||
: ( // ssActive > 0
|
|
||||||
sess.ssConnected == 0
|
|
||||||
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
|
||||||
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: noConnColorAndPercent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SMPServerSummaryView: View {
|
struct SMPServerSummaryView: View {
|
||||||
@@ -470,6 +483,7 @@ struct SMPServerSummaryView: View {
|
|||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProtocolServersView(serverProtocol: .smp)
|
ProtocolServersView(serverProtocol: .smp)
|
||||||
.navigationTitle("Your SMP servers")
|
.navigationTitle("Your SMP servers")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Open server settings")
|
Text("Open server settings")
|
||||||
}
|
}
|
||||||
@@ -493,16 +507,16 @@ struct SMPServerSummaryView: View {
|
|||||||
|
|
||||||
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
||||||
Section {
|
Section {
|
||||||
infoRow("Connections subscribed", numOrDash(subs.ssActive))
|
infoRow("Active connections", numOrDash(subs.ssActive))
|
||||||
infoRow("Pending", numOrDash(subs.ssPending))
|
infoRow("Pending", numOrDash(subs.ssPending))
|
||||||
infoRow("Total", numOrDash(subs.total))
|
infoRow("Total", numOrDash(subs.total))
|
||||||
reconnectButton()
|
reconnectButton()
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Message subscriptions")
|
Text("Message reception")
|
||||||
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
|
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||||
if showSubscriptionPercentage {
|
if showSubscriptionPercentage {
|
||||||
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
|
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,6 +577,7 @@ struct SMPStatsView: View {
|
|||||||
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
.navigationTitle("Detailed statistics")
|
.navigationTitle("Detailed statistics")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Details")
|
Text("Details")
|
||||||
}
|
}
|
||||||
@@ -590,33 +605,37 @@ struct DetailedSMPStatsView: View {
|
|||||||
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
|
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
|
||||||
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
|
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
|
||||||
Text("Send errors")
|
Text("Send errors")
|
||||||
indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
|
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
|
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs))
|
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("other", numOrDash(stats._sentOtherErrs))
|
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
|
||||||
}
|
}
|
||||||
Section("Received messages") {
|
Section("Received messages") {
|
||||||
infoRow("Received total", numOrDash(stats._recvMsgs))
|
infoRow("Received total", numOrDash(stats._recvMsgs))
|
||||||
Text("Receive errors")
|
Text("Receive errors")
|
||||||
indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates))
|
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
|
||||||
indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs))
|
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("other errors", numOrDash(stats._recvErrs))
|
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
|
||||||
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
|
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
|
||||||
Text("Acknowledgement errors")
|
Text("Acknowledgement errors")
|
||||||
indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
|
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs))
|
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
|
||||||
}
|
}
|
||||||
Section {
|
Section("Connections") {
|
||||||
infoRow("Created", numOrDash(stats._connCreated))
|
infoRow("Created", numOrDash(stats._connCreated))
|
||||||
infoRow("Secured", numOrDash(stats._connCreated))
|
infoRow("Secured", numOrDash(stats._connCreated))
|
||||||
infoRow("Completed", numOrDash(stats._connCompleted))
|
infoRow("Completed", numOrDash(stats._connCompleted))
|
||||||
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
|
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
|
||||||
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
|
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
|
||||||
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
||||||
infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored))
|
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
|
||||||
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
|
||||||
|
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Connections")
|
Text("Connection notifications")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||||
}
|
}
|
||||||
@@ -626,29 +645,19 @@ struct DetailedSMPStatsView: View {
|
|||||||
|
|
||||||
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
|
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2)
|
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
|
||||||
Spacer()
|
Spacer()
|
||||||
Group {
|
Group {
|
||||||
if value == 0 && value2 == 0 {
|
if value == 0 && value2 == 0 {
|
||||||
Text("-")
|
Text(verbatim: "-")
|
||||||
} else {
|
} else {
|
||||||
Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
struct XFTPServerSummaryView: View {
|
||||||
var summary: XFTPServerSummary
|
var summary: XFTPServerSummary
|
||||||
var statsStartedAt: Date
|
var statsStartedAt: Date
|
||||||
@@ -662,6 +671,7 @@ struct XFTPServerSummaryView: View {
|
|||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProtocolServersView(serverProtocol: .xftp)
|
ProtocolServersView(serverProtocol: .xftp)
|
||||||
.navigationTitle("Your XFTP servers")
|
.navigationTitle("Your XFTP servers")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Open server settings")
|
Text("Open server settings")
|
||||||
}
|
}
|
||||||
@@ -692,6 +702,7 @@ struct XFTPStatsView: View {
|
|||||||
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||||
.navigationTitle("Detailed statistics")
|
.navigationTitle("Detailed statistics")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Details")
|
Text("Details")
|
||||||
}
|
}
|
||||||
@@ -725,8 +736,8 @@ struct DetailedXFTPStatsView: View {
|
|||||||
infoRow("Size", prettySize(stats._downloadsSize))
|
infoRow("Size", prettySize(stats._downloadsSize))
|
||||||
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
|
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
|
||||||
Text("Download errors")
|
Text("Download errors")
|
||||||
indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
|
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
|
||||||
indentedInfoRow("other", numOrDash(stats._downloadErrs))
|
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Downloaded files")
|
Text("Downloaded files")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//
|
||||||
|
// ContactListNavLink.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Diogo Cunha on 01/08/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
|
struct ContactListNavLink: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@ObservedObject var chat: Chat
|
||||||
|
var showDeletedChatIcon: Bool
|
||||||
|
@State private var alert: SomeAlert? = nil
|
||||||
|
@State private var actionSheet: SomeActionSheet? = nil
|
||||||
|
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||||
|
@State private var showConnectContactViaAddressDialog = false
|
||||||
|
@State private var showContactRequestDialog = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let contactType = chatContactType(chat: chat)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
switch (chat.chatInfo) {
|
||||||
|
case let .direct(contact):
|
||||||
|
switch contactType {
|
||||||
|
case .recent:
|
||||||
|
recentContactNavLink(contact)
|
||||||
|
case .chatDeleted:
|
||||||
|
deletedChatNavLink(contact)
|
||||||
|
case .card:
|
||||||
|
contactCardNavLink(contact)
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
case let .contactRequest(contactRequest):
|
||||||
|
contactRequestNavLink(contactRequest)
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $alert) { $0.alert }
|
||||||
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
|
.sheet(item: $sheet) {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
$0.content
|
||||||
|
.presentationDetents([.fraction(0.4)])
|
||||||
|
} else {
|
||||||
|
$0.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentContactNavLink(_ contact: Contact) -> some View {
|
||||||
|
Button {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(contact.id)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
contactPreview(contact, titleColor: theme.colors.onBackground)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button {
|
||||||
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: false,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletedChatNavLink(_ contact: Contact) -> some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await MainActor.run {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(contact.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
contactPreview(contact, titleColor: theme.colors.onBackground)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button {
|
||||||
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: false,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactPreview(_ contact: Contact, titleColor: Color) -> some View {
|
||||||
|
HStack{
|
||||||
|
ProfileImage(imageStr: contact.image, size: 30)
|
||||||
|
|
||||||
|
previewTitle(contact, titleColor: titleColor)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if showDeletedChatIcon && contact.chatDeleted {
|
||||||
|
Image(systemName: "archivebox")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.foregroundColor(.secondary.opacity(0.65))
|
||||||
|
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.foregroundColor(.secondary.opacity(0.65))
|
||||||
|
}
|
||||||
|
if contact.contactConnIncognito {
|
||||||
|
Image(systemName: "theatermasks")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
|
||||||
|
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
|
||||||
|
(
|
||||||
|
contact.verified == true
|
||||||
|
? verifiedIcon + t
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var verifiedIcon: Text {
|
||||||
|
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.baselineOffset(1)
|
||||||
|
.kerning(-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactCardNavLink(_ contact: Contact) -> some View {
|
||||||
|
Button {
|
||||||
|
showConnectContactViaAddressDialog = true
|
||||||
|
} label: {
|
||||||
|
contactCardPreview(contact)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button {
|
||||||
|
deleteContactDialog(
|
||||||
|
chat,
|
||||||
|
contact,
|
||||||
|
dismissToChatList: false,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { actionSheet = $0 },
|
||||||
|
showSheetContent: { sheet = $0 }
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
|
||||||
|
Button("Use current profile") { connectContactViaAddress_(contact, false) }
|
||||||
|
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||||
|
Task {
|
||||||
|
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
|
||||||
|
if ok {
|
||||||
|
ItemsModel.shared.loadOpenChat(contact.id)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
dismissAllSheets(animated: true) {
|
||||||
|
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactCardPreview(_ contact: Contact) -> some View {
|
||||||
|
HStack{
|
||||||
|
ProfileImage(imageStr: contact.image, size: 30)
|
||||||
|
|
||||||
|
Text(chat.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "envelope")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
|
||||||
|
Button {
|
||||||
|
showContactRequestDialog = true
|
||||||
|
} label: {
|
||||||
|
contactRequestPreview(contactRequest)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button {
|
||||||
|
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||||
|
} label: { Label("Accept", systemImage: "checkmark") }
|
||||||
|
.tint(theme.colors.primary)
|
||||||
|
Button {
|
||||||
|
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||||
|
} label: {
|
||||||
|
Label("Accept incognito", systemImage: "theatermasks")
|
||||||
|
}
|
||||||
|
.tint(.indigo)
|
||||||
|
Button {
|
||||||
|
alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest), id: "rejectContactRequestAlert")
|
||||||
|
} label: {
|
||||||
|
Label("Reject", systemImage: "multiply")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||||
|
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||||
|
Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } }
|
||||||
|
Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactRequestPreview(_ contactRequest: UserContactRequest) -> some View {
|
||||||
|
HStack{
|
||||||
|
ProfileImage(imageStr: contactRequest.image, size: 30)
|
||||||
|
|
||||||
|
Text(chat.chatInfo.chatViewName)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
|
|||||||
case let .migrationError(mtrError):
|
case let .migrationError(mtrError):
|
||||||
titleText("Incompatible database version")
|
titleText("Incompatible database version")
|
||||||
fileNameText(dbFile)
|
fileNameText(dbFile)
|
||||||
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
|
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||||
}
|
}
|
||||||
case let .errorSQL(dbFile, migrationSQLError):
|
case let .errorSQL(dbFile, migrationSQLError):
|
||||||
titleText("Database error")
|
titleText("Database error")
|
||||||
@@ -105,15 +105,6 @@ struct DatabaseErrorView: View {
|
|||||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
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 {
|
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum DatabaseAlert: Identifiable {
|
|||||||
case importArchive
|
case importArchive
|
||||||
case archiveImported
|
case archiveImported
|
||||||
case archiveImportedWithErrors(archiveErrors: [ArchiveError])
|
case archiveImportedWithErrors(archiveErrors: [ArchiveError])
|
||||||
|
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
|
||||||
case deleteChat
|
case deleteChat
|
||||||
case chatDeleted
|
case chatDeleted
|
||||||
case deleteLegacyDatabase
|
case deleteLegacyDatabase
|
||||||
@@ -29,6 +30,7 @@ enum DatabaseAlert: Identifiable {
|
|||||||
case .importArchive: return "importArchive"
|
case .importArchive: return "importArchive"
|
||||||
case .archiveImported: return "archiveImported"
|
case .archiveImported: return "archiveImported"
|
||||||
case .archiveImportedWithErrors: return "archiveImportedWithErrors"
|
case .archiveImportedWithErrors: return "archiveImportedWithErrors"
|
||||||
|
case .archiveExportedWithErrors: return "archiveExportedWithErrors"
|
||||||
case .deleteChat: return "deleteChat"
|
case .deleteChat: return "deleteChat"
|
||||||
case .chatDeleted: return "chatDeleted"
|
case .chatDeleted: return "chatDeleted"
|
||||||
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
|
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
|
||||||
@@ -265,10 +267,18 @@ struct DatabaseView: View {
|
|||||||
title: Text("Chat database imported"),
|
title: Text("Chat database imported"),
|
||||||
message: Text("Restart the app to use imported chat database")
|
message: Text("Restart the app to use imported chat database")
|
||||||
)
|
)
|
||||||
case .archiveImportedWithErrors:
|
case let .archiveImportedWithErrors(errs):
|
||||||
return Alert(
|
return Alert(
|
||||||
title: Text("Chat database imported"),
|
title: Text("Chat database imported"),
|
||||||
message: Text("Restart the app to use imported chat database") + Text("\n") + Text("Some non-fatal errors occurred during import - you may see Chat console for more details.")
|
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
|
||||||
|
)
|
||||||
|
case let .archiveExportedWithErrors(archivePath, errs):
|
||||||
|
return Alert(
|
||||||
|
title: Text("Chat database exported"),
|
||||||
|
message: Text("You may save the exported archive.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||||
|
dismissButton: .default(Text("Continue")) {
|
||||||
|
showShareSheet(items: [archivePath])
|
||||||
|
}
|
||||||
)
|
)
|
||||||
case .deleteChat:
|
case .deleteChat:
|
||||||
return Alert(
|
return Alert(
|
||||||
@@ -349,9 +359,16 @@ struct DatabaseView: View {
|
|||||||
progressIndicator = true
|
progressIndicator = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let archivePath = try await exportChatArchive()
|
let (archivePath, archiveErrors) = try await exportChatArchive()
|
||||||
showShareSheet(items: [archivePath])
|
if archiveErrors.isEmpty {
|
||||||
await MainActor.run { progressIndicator = false }
|
showShareSheet(items: [archivePath])
|
||||||
|
await MainActor.run { progressIndicator = false }
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
|
||||||
|
progressIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||||
@@ -486,6 +503,17 @@ struct DatabaseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
|
||||||
|
return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
|
||||||
|
|
||||||
|
func showArchiveError(_ err: ArchiveError) -> String {
|
||||||
|
switch err {
|
||||||
|
case let .import(importError): importError
|
||||||
|
case let .fileError(file, fileError): "\(file): \(fileError)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stopChatAsync() async throws {
|
func stopChatAsync() async throws {
|
||||||
try await apiStopChat()
|
try await apiStopChat()
|
||||||
ChatReceiver.shared.stop()
|
ChatReceiver.shared.stop()
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ struct MigrateToAppGroupView: View {
|
|||||||
do {
|
do {
|
||||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
try await apiExportArchive(config: config)
|
_ = try await apiExportArchive(config: config)
|
||||||
await MainActor.run { setV3DBMigration(.exported) }
|
await MainActor.run { setV3DBMigration(.exported) }
|
||||||
} catch let error {
|
} catch let error {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -222,7 +222,7 @@ struct MigrateToAppGroupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
|
func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [ArchiveError]) {
|
||||||
let archiveTime = Date.now
|
let archiveTime = Date.now
|
||||||
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
|
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
|
||||||
let archiveName = "simplex-chat.\(ts).zip"
|
let archiveName = "simplex-chat.\(ts).zip"
|
||||||
@@ -233,13 +233,13 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
|
|||||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||||
}
|
}
|
||||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||||
try await apiExportArchive(config: config)
|
let errs = try await apiExportArchive(config: config)
|
||||||
if storagePath == nil {
|
if storagePath == nil {
|
||||||
deleteOldArchive()
|
deleteOldArchive()
|
||||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||||
chatArchiveTimeDefault.set(archiveTime)
|
chatArchiveTimeDefault.set(archiveTime)
|
||||||
}
|
}
|
||||||
return archivePath
|
return (archivePath, errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteOldArchive() {
|
func deleteOldArchive() {
|
||||||
|
|||||||
@@ -8,41 +8,21 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private struct SheetIsPresented<C>: ViewModifier where C: View {
|
class AppSheetState: ObservableObject {
|
||||||
var isPresented: Binding<Bool>
|
static let shared = AppSheetState()
|
||||||
var onDismiss: (() -> Void)?
|
@Published var scenePhaseActive: Bool = false
|
||||||
var sheetContent: () -> C
|
|
||||||
@Environment(\.scenePhase) var scenePhase
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content.sheet(isPresented: isPresented, onDismiss: onDismiss) {
|
|
||||||
sheetContent().modifier(PrivacySensitive())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SheetForItem<T, C>: ViewModifier where T: Identifiable, C: View {
|
|
||||||
var item: Binding<T?>
|
|
||||||
var onDismiss: (() -> Void)?
|
|
||||||
var sheetContent: (T) -> C
|
|
||||||
@Environment(\.scenePhase) var scenePhase
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content.sheet(item: item, onDismiss: onDismiss) { it in
|
|
||||||
sheetContent(it).modifier(PrivacySensitive())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct PrivacySensitive: ViewModifier {
|
private struct PrivacySensitive: ViewModifier {
|
||||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||||
@Environment(\.scenePhase) var scenePhase
|
// Screen protection doesn't work for appSheet on iOS 16 if @Environment(\.scenePhase) is used instead of global state
|
||||||
|
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if case .active = scenePhase {
|
if !protectScreen {
|
||||||
content
|
content
|
||||||
} else {
|
} else {
|
||||||
content.privacySensitive(protectScreen).redacted(reason: .privacy)
|
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +33,9 @@ extension View {
|
|||||||
onDismiss: (() -> Void)? = nil,
|
onDismiss: (() -> Void)? = nil,
|
||||||
content: @escaping () -> Content
|
content: @escaping () -> Content
|
||||||
) -> some View where Content: View {
|
) -> some View where Content: View {
|
||||||
modifier(SheetIsPresented(isPresented: isPresented, onDismiss: onDismiss, sheetContent: content))
|
sheet(isPresented: isPresented, onDismiss: onDismiss) {
|
||||||
|
content().modifier(PrivacySensitive())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func appSheet<T, Content>(
|
func appSheet<T, Content>(
|
||||||
@@ -61,6 +43,8 @@ extension View {
|
|||||||
onDismiss: (() -> Void)? = nil,
|
onDismiss: (() -> Void)? = nil,
|
||||||
content: @escaping (T) -> Content
|
content: @escaping (T) -> Content
|
||||||
) -> some View where T: Identifiable, Content: View {
|
) -> some View where T: Identifiable, Content: View {
|
||||||
modifier(SheetForItem(item: item, onDismiss: onDismiss, sheetContent: content))
|
sheet(item: item, onDismiss: onDismiss) { it in
|
||||||
|
content(it).modifier(PrivacySensitive())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,10 @@ struct ChatInfoImage: View {
|
|||||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||||
|
|
||||||
var body: some View {
|
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 }
|
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
|
||||||
return ProfileImage(
|
return ProfileImage(
|
||||||
imageStr: chat.chatInfo.image,
|
imageStr: chat.chatInfo.image,
|
||||||
iconName: iconName,
|
iconName: chatIconName(chat.chatInfo),
|
||||||
size: size,
|
size: size,
|
||||||
color: iconColor
|
color: iconColor
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,20 +11,31 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
struct ChatViewBackground: ViewModifier {
|
struct ChatViewBackground: ViewModifier {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
var image: Image
|
var image: Image
|
||||||
var imageType: WallpaperType
|
var imageType: WallpaperType
|
||||||
var background: Color
|
var background: Color
|
||||||
var tint: Color
|
var tint: Color
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
|
// Workaround a problem (SwiftUI bug?) when wallpaper is not updated when user changes global theme in iOS settings from dark to light and vice versa
|
||||||
|
if colorScheme == .light {
|
||||||
|
back(content)
|
||||||
|
} else {
|
||||||
|
back(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func back(_ content: Content) -> some View {
|
||||||
content.background(
|
content.background(
|
||||||
Canvas { context, size in
|
Canvas { context, size in
|
||||||
var image = context.resolve(image)
|
var image = context.resolve(image)
|
||||||
let rect = CGRectMake(0, 0, size.width, size.height)
|
let rect = CGRectMake(0, 0, size.width, size.height)
|
||||||
func repeatDraw(_ imageScale: CGFloat) {
|
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)
|
image.shading = .color(tint)
|
||||||
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
|
let scale = imageScale * 2.5 // scale wallpaper for iOS
|
||||||
for h in 0 ... Int(size.height / image.size.height / scale) {
|
for h in 0 ... Int(size.height / image.size.height / scale) {
|
||||||
for w in 0 ... Int(size.width / image.size.width / 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)
|
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
|
||||||
@@ -79,7 +90,7 @@ struct ChatViewBackground: ViewModifier {
|
|||||||
case WallpaperType.empty: ()
|
case WallpaperType.empty: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
).ignoresSafeArea(.all)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
//
|
|
||||||
// KeyboardPadding.swift
|
|
||||||
// SimpleX (iOS)
|
|
||||||
//
|
|
||||||
// Created by Evgeny on 10/07/2023.
|
|
||||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
@ViewBuilder func keyboardPadding() -> some View {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
GeometryReader { g in
|
|
||||||
self.padding(.bottom, max(0, ChatModel.shared.keyboardHeight - g.safeAreaInsets.bottom))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,17 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
struct NavLinkPlain<V: Hashable, Label: View>: View {
|
struct NavLinkPlain<Label: View>: View {
|
||||||
@State var tag: V
|
let chatId: ChatId
|
||||||
@Binding var selection: V?
|
@Binding var selection: ChatId?
|
||||||
@ViewBuilder var label: () -> Label
|
@ViewBuilder var label: () -> Label
|
||||||
var disabled = false
|
var disabled = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Button("") { DispatchQueue.main.async { selection = tag } }
|
Button("") { ItemsModel.shared.loadOpenChat(chatId) }
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
label()
|
label()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ struct NavStackCompat <C: View, D: View>: View {
|
|||||||
if #available(iOS 16, *) {
|
if #available(iOS 16, *) {
|
||||||
NavigationStack(path: Binding(
|
NavigationStack(path: Binding(
|
||||||
get: { isActive.wrappedValue ? [true] : [] },
|
get: { isActive.wrappedValue ? [true] : [] },
|
||||||
set: { _ in }
|
set: { path in
|
||||||
|
if path.isEmpty { isActive.wrappedValue = false }
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
ZStack {
|
ZStack {
|
||||||
NavigationLink(value: true) { EmptyView() }
|
NavigationLink(value: true) { EmptyView() }
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
let defaultProfileImageCorner = 22.5
|
|
||||||
|
|
||||||
struct ProfileImage: View {
|
struct ProfileImage: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var imageStr: String? = nil
|
var imageStr: String? = nil
|
||||||
@@ -18,13 +16,12 @@ struct ProfileImage: View {
|
|||||||
var size: CGFloat
|
var size: CGFloat
|
||||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||||
var backgroundColor: Color? = nil
|
var backgroundColor: Color? = nil
|
||||||
|
var blurred = false
|
||||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let image = imageStr,
|
if let uiImage = UIImage(base64Encoded: imageStr) {
|
||||||
let data = Data(base64Encoded: dropImagePrefix(image)),
|
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
|
||||||
let uiImage = UIImage(data: data) {
|
|
||||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
|
|
||||||
} else {
|
} else {
|
||||||
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
||||||
Image(systemName: iconName)
|
Image(systemName: iconName)
|
||||||
@@ -36,26 +33,6 @@ 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 {
|
extension Color {
|
||||||
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
|
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// SwipeLabel.swift
|
||||||
|
// SimpleX (iOS)
|
||||||
|
//
|
||||||
|
// Created by Levitating Pineapple on 06/08/2024.
|
||||||
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SwipeLabel: View {
|
||||||
|
private let text: String
|
||||||
|
private let systemImage: String
|
||||||
|
private let inverted: Bool
|
||||||
|
|
||||||
|
init(_ text: String, systemImage: String, inverted: Bool) {
|
||||||
|
self.text = text
|
||||||
|
self.systemImage = systemImage
|
||||||
|
self.inverted = inverted
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if inverted {
|
||||||
|
Image(
|
||||||
|
uiImage: SwipeActionView(
|
||||||
|
systemName: systemImage,
|
||||||
|
text: text
|
||||||
|
).snapshot(inverted: inverted)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Label(text, systemImage: systemImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SwipeActionView: UIView {
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let label = UILabel()
|
||||||
|
private let fontSize: CGFloat
|
||||||
|
|
||||||
|
init(systemName: String, text: String) {
|
||||||
|
fontSize = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).pointSize
|
||||||
|
super.init(frame: CGRect(x: 0, y: 0, width: 64, height: 32 + fontSize))
|
||||||
|
imageView.image = UIImage(systemName: systemName)
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
label.text = text
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.font = UIFont.systemFont(ofSize: fontSize, weight: .medium)
|
||||||
|
addSubview(imageView)
|
||||||
|
addSubview(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
imageView.frame = CGRect(
|
||||||
|
x: 20,
|
||||||
|
y: 0,
|
||||||
|
width: 24,
|
||||||
|
height: 24
|
||||||
|
)
|
||||||
|
label.frame = CGRect(
|
||||||
|
x: 0,
|
||||||
|
y: 32,
|
||||||
|
width: 64,
|
||||||
|
height: fontSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError("not implemented") }
|
||||||
|
|
||||||
|
func snapshot(inverted: Bool) -> UIImage {
|
||||||
|
UIGraphicsImageRenderer(bounds: bounds).image { context in
|
||||||
|
if inverted {
|
||||||
|
context.cgContext.scaleBy(x: 1, y: -1)
|
||||||
|
context.cgContext.translateBy(x: 0, y: -bounds.height)
|
||||||
|
}
|
||||||
|
layer.render(in: context.cgContext)
|
||||||
|
}.withRenderingMode(.alwaysTemplate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -9,11 +9,45 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
|
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||||
if condition() {
|
if condition {
|
||||||
transform(self)
|
transform(self)
|
||||||
} else {
|
} else {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,8 +64,9 @@ struct LocalAuthView: View {
|
|||||||
deleteAppDatabaseAndFiles()
|
deleteAppDatabaseAndFiles()
|
||||||
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
||||||
m.chatId = nil
|
m.chatId = nil
|
||||||
m.reversedChatItems = []
|
ItemsModel.shared.reversedChatItems = []
|
||||||
m.chats = []
|
m.chats = []
|
||||||
|
m.popChatCollector.clear()
|
||||||
m.users = []
|
m.users = []
|
||||||
_ = kcAppPassword.set(password)
|
_ = kcAppPassword.set(password)
|
||||||
_ = kcSelfDestructPassword.remove()
|
_ = kcSelfDestructPassword.remove()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
|||||||
case keychainError(_ title: LocalizedStringKey = "Keychain error")
|
case keychainError(_ title: LocalizedStringKey = "Keychain error")
|
||||||
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
|
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
|
||||||
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
|
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
|
||||||
|
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
|
||||||
|
|
||||||
case error(title: LocalizedStringKey, error: String = "")
|
case error(title: LocalizedStringKey, error: String = "")
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
|||||||
case .keychainError: return "keychainError"
|
case .keychainError: return "keychainError"
|
||||||
case let .databaseError(title, message): return "\(title) \(message)"
|
case let .databaseError(title, message): return "\(title) \(message)"
|
||||||
case let .unknownError(title, message): return "\(title) \(message)"
|
case let .unknownError(title, message): return "\(title) \(message)"
|
||||||
|
case let .archiveExportedWithErrors(path, _): return "archiveExportedWithErrors \(path)"
|
||||||
|
|
||||||
case let .error(title, _): return "error \(title)"
|
case let .error(title, _): return "error \(title)"
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,14 @@ struct MigrateFromDevice: View {
|
|||||||
return Alert(title: Text(title), message: Text(message))
|
return Alert(title: Text(title), message: Text(message))
|
||||||
case let .unknownError(title, message):
|
case let .unknownError(title, message):
|
||||||
return Alert(title: Text(title), message: Text(message))
|
return Alert(title: Text(title), message: Text(message))
|
||||||
|
case let .archiveExportedWithErrors(archivePath, errs):
|
||||||
|
return Alert(
|
||||||
|
title: Text("Chat database exported"),
|
||||||
|
message: Text("You may migrate the exported database.") + Text(verbatim: "\n\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||||
|
dismissButton: .default(Text("Continue")) {
|
||||||
|
Task { await uploadArchive(path: archivePath) }
|
||||||
|
}
|
||||||
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return Alert(title: Text(title), message: Text(error))
|
||||||
}
|
}
|
||||||
@@ -449,15 +459,12 @@ struct MigrateFromDevice: View {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true)
|
||||||
let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory())
|
let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory())
|
||||||
if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path),
|
if errs.isEmpty {
|
||||||
let totalBytes = attrs[.size] as? Int64 {
|
await uploadArchive(path: archivePath)
|
||||||
await MainActor.run {
|
|
||||||
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
alert = .error(title: "Exported file doesn't exist")
|
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs)
|
||||||
migrationState = .uploadConfirmation
|
migrationState = .uploadConfirmation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +476,20 @@ struct MigrateFromDevice: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uploadArchive(path archivePath: URL) async {
|
||||||
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path),
|
||||||
|
let totalBytes = attrs[.size] as? Int64 {
|
||||||
|
await MainActor.run {
|
||||||
|
migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run {
|
||||||
|
alert = .error(title: "Exported file doesn't exist")
|
||||||
|
migrationState = .uploadConfirmation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
|
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
|
||||||
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
|
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
|
||||||
@@ -662,7 +683,7 @@ private struct PassphraseConfirmationView: View {
|
|||||||
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
|
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
|
||||||
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
|
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
|
||||||
} else {
|
} else {
|
||||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
|
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
|
|||||||
case let .migrationError(mtrError):
|
case let .migrationError(mtrError):
|
||||||
("Incompatible database version",
|
("Incompatible database version",
|
||||||
nil,
|
nil,
|
||||||
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
|
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
|
||||||
nil)
|
nil)
|
||||||
}
|
}
|
||||||
default: ("Error", nil, "Unknown error", nil)
|
default: ("Error", nil, "Unknown error", nil)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct AddContactLearnMore: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
.modifier(ThemedBackground())
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,40 +35,37 @@ struct AddGroupView: View {
|
|||||||
creatingGroup: true,
|
creatingGroup: true,
|
||||||
showFooterCounter: false
|
showFooterCounter: false
|
||||||
) { _ in
|
) { _ in
|
||||||
dismiss()
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
m.chatId = groupInfo.id
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} else {
|
} else {
|
||||||
GroupLinkView(
|
GroupLinkView(
|
||||||
groupId: groupInfo.groupId,
|
groupId: groupInfo.groupId,
|
||||||
groupLink: $groupLink,
|
groupLink: $groupLink,
|
||||||
groupLinkMemberRole: $groupLinkMemberRole,
|
groupLinkMemberRole: $groupLinkMemberRole,
|
||||||
showTitle: true,
|
showTitle: false,
|
||||||
creatingGroup: true
|
creatingGroup: true
|
||||||
) {
|
) {
|
||||||
dismiss()
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
m.chatId = groupInfo.id
|
dismissAllSheets(animated: true) {
|
||||||
|
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationBarTitle("Group link")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
createGroupView().keyboardPadding()
|
createGroupView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createGroupView() -> some View {
|
func createGroupView() -> some View {
|
||||||
List {
|
List {
|
||||||
Group {
|
Group {
|
||||||
Text("Create secret group")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.bold()
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
.onTapGesture(perform: hideKeyboard)
|
|
||||||
|
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
ProfileImage(imageStr: profile.image, size: 128)
|
ProfileImage(imageStr: profile.image, size: 128)
|
||||||
@@ -194,6 +191,7 @@ struct AddGroupView: View {
|
|||||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
m.groupMembers = groupMembers.map { GMember.init($0) }
|
||||||
|
m.populateGroupMembersIndexes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||||
@@ -203,13 +201,14 @@ struct AddGroupView: View {
|
|||||||
chat = c
|
chat = c
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
dismiss()
|
dismissAllSheets(animated: true) {
|
||||||
AlertManager.shared.showAlert(
|
AlertManager.shared.showAlert(
|
||||||
Alert(
|
Alert(
|
||||||
title: Text("Error creating group"),
|
title: Text("Error creating group"),
|
||||||
message: Text(responseError(error))
|
message: Text(responseError(error))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,53 +7,499 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SimpleXChat
|
||||||
|
|
||||||
enum NewChatMenuOption: Identifiable {
|
enum ContactType: Int {
|
||||||
case newContact
|
case card, request, recent, chatDeleted, unlisted
|
||||||
case scanPaste
|
|
||||||
case newGroup
|
|
||||||
|
|
||||||
var id: Self { self }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NewChatMenuButton: View {
|
struct NewChatMenuButton: View {
|
||||||
@Binding var newChatMenuOption: NewChatMenuOption?
|
@State private var showNewChatSheet = false
|
||||||
|
@State private var alert: SomeAlert? = nil
|
||||||
|
@State private var globalAlert: SomeAlert? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
|
||||||
Button {
|
Button {
|
||||||
newChatMenuOption = .newContact
|
showNewChatSheet = true
|
||||||
} label: {
|
|
||||||
Text("Add contact")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
newChatMenuOption = .scanPaste
|
|
||||||
} label: {
|
|
||||||
Text("Scan / Paste link")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
newChatMenuOption = .newGroup
|
|
||||||
} label: {
|
|
||||||
Text("Create group")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "square.and.pencil")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
}
|
}
|
||||||
.sheet(item: $newChatMenuOption) { opt in
|
.appSheet(isPresented: $showNewChatSheet) {
|
||||||
switch opt {
|
NewChatSheet(alert: $alert)
|
||||||
case .newContact: NewChatView(selection: .invite)
|
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||||
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
|
.alert(item: $alert) { a in
|
||||||
case .newGroup: AddGroupView()
|
return a.alert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This is a workaround to show "Keep unused invitation" alert in both following cases:
|
||||||
|
// - on going back from NewChatView to NewChatSheet,
|
||||||
|
// - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet)
|
||||||
|
.onChange(of: alert?.id) { a in
|
||||||
|
if !showNewChatSheet && alert != nil {
|
||||||
|
globalAlert = alert
|
||||||
|
alert = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(item: $globalAlert) { a in
|
||||||
|
return a.alert
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
private var indent: CGFloat = 36
|
||||||
NewChatMenuButton(
|
|
||||||
newChatMenuOption: Binding.constant(nil)
|
struct NewChatSheet: View {
|
||||||
)
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@State private var baseContactTypes: [ContactType] = [.card, .request, .recent]
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@State private var searchMode = false
|
||||||
|
@FocusState var searchFocussed: Bool
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var searchShowingSimplexLink = false
|
||||||
|
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||||
|
@Binding var alert: SomeAlert?
|
||||||
|
|
||||||
|
// Sheet height management
|
||||||
|
@State private var isAddContactActive = false
|
||||||
|
@State private var isScanPasteLinkActive = false
|
||||||
|
@State private var isLargeSheet = false
|
||||||
|
@State private var allowSmallSheet = true
|
||||||
|
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let showArchive = !filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty
|
||||||
|
let v = NavigationView {
|
||||||
|
viewBody(showArchive)
|
||||||
|
.navigationTitle("New message")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.navigationBarHidden(searchMode)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
}
|
||||||
|
if #available(iOS 16.0, *), oneHandUI {
|
||||||
|
let sheetHeight: CGFloat = showArchive ? 575 : 500
|
||||||
|
v.presentationDetents(
|
||||||
|
allowSmallSheet ? [.height(sheetHeight), .large] : [.large],
|
||||||
|
selection: Binding(
|
||||||
|
get: { isLargeSheet || !allowSmallSheet ? .large : .height(sheetHeight) },
|
||||||
|
set: { isLargeSheet = $0 == .large }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func viewBody(_ showArchive: Bool) -> some View {
|
||||||
|
List {
|
||||||
|
HStack {
|
||||||
|
ContactsListSearchBar(
|
||||||
|
searchMode: $searchMode,
|
||||||
|
searchFocussed: $searchFocussed,
|
||||||
|
searchText: $searchText,
|
||||||
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
|
||||||
|
if (searchText.isEmpty) {
|
||||||
|
Section {
|
||||||
|
NavigationLink(isActive: $isAddContactActive) {
|
||||||
|
NewChatView(selection: .invite, parentAlert: $alert)
|
||||||
|
.navigationTitle("New chat")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
navigateOnTap(Label("Add contact", systemImage: "link.badge.plus")) {
|
||||||
|
isAddContactActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink(isActive: $isScanPasteLinkActive) {
|
||||||
|
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert)
|
||||||
|
.navigationTitle("New chat")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) {
|
||||||
|
isScanPasteLinkActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
AddGroupView()
|
||||||
|
.navigationTitle("Create secret group")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
} label: {
|
||||||
|
Label("Create group", systemImage: "person.2.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showArchive) {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
DeletedChats()
|
||||||
|
} label: {
|
||||||
|
newChatActionButton("archivebox", color: theme.colors.secondary) { Text("Archived contacts") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactsList(
|
||||||
|
baseContactTypes: $baseContactTypes,
|
||||||
|
searchMode: $searchMode,
|
||||||
|
searchText: $searchText,
|
||||||
|
header: "Your Contacts",
|
||||||
|
searchFocussed: $searchFocussed,
|
||||||
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
|
||||||
|
showDeletedChatIcon: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extends label's tap area to match `.insetGrouped` list row insets
|
||||||
|
private func navigateOnTap<L: View>(_ label: L, setActive: @escaping () -> Void) -> some View {
|
||||||
|
label
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.leading, 16).padding(.vertical, 8).padding(.trailing, 32)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
isLargeSheet = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
allowSmallSheet = false
|
||||||
|
setActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, -16).padding(.vertical, -8).padding(.trailing, -32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatActionButton<Content : View>(_ icon: String, color: Color/* = .secondary*/, content: @escaping () -> Content) -> some View {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
.foregroundColor(color)
|
||||||
|
content().foregroundColor(theme.colors.onBackground).padding(.leading, indent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatContactType(chat: Chat) -> ContactType {
|
||||||
|
switch chat.chatInfo {
|
||||||
|
case .contactRequest:
|
||||||
|
return .request
|
||||||
|
case let .direct(contact):
|
||||||
|
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||||
|
return .card
|
||||||
|
} else if contact.chatDeleted {
|
||||||
|
return .chatDeleted
|
||||||
|
} else if contact.contactStatus == .active {
|
||||||
|
return .recent
|
||||||
|
} else {
|
||||||
|
return .unlisted
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return .unlisted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
|
||||||
|
return chats.filter { chat in
|
||||||
|
contactTypes.contains(chatContactType(chat: chat))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContactsList: View {
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@EnvironmentObject var chatModel: ChatModel
|
||||||
|
@Binding var baseContactTypes: [ContactType]
|
||||||
|
@Binding var searchMode: Bool
|
||||||
|
@Binding var searchText: String
|
||||||
|
var header: String? = nil
|
||||||
|
@FocusState.Binding var searchFocussed: Bool
|
||||||
|
@Binding var searchShowingSimplexLink: Bool
|
||||||
|
@Binding var searchChatFilteredBySimplexLink: String?
|
||||||
|
var showDeletedChatIcon: Bool
|
||||||
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let contactTypes = contactTypesSearchTargets(baseContactTypes: baseContactTypes, searchEmpty: searchText.isEmpty)
|
||||||
|
let contactChats = filterContactTypes(chats: chatModel.chats, contactTypes: contactTypes)
|
||||||
|
let filteredContactChats = filteredContactChats(
|
||||||
|
showUnreadAndFavorites: showUnreadAndFavorites,
|
||||||
|
searchShowingSimplexLink: searchShowingSimplexLink,
|
||||||
|
searchChatFilteredBySimplexLink: searchChatFilteredBySimplexLink,
|
||||||
|
searchText: searchText,
|
||||||
|
contactChats: contactChats
|
||||||
|
)
|
||||||
|
|
||||||
|
if !filteredContactChats.isEmpty {
|
||||||
|
Section(header: Group {
|
||||||
|
if let header = header {
|
||||||
|
Text(header)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
ForEach(filteredContactChats, id: \.viewId) { chat in
|
||||||
|
ContactListNavLink(chat: chat, showDeletedChatIcon: showDeletedChatIcon)
|
||||||
|
.disabled(chatModel.chatRunning != true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filteredContactChats.isEmpty && !contactChats.isEmpty {
|
||||||
|
noResultSection(text: "No filtered contacts")
|
||||||
|
} else if contactChats.isEmpty {
|
||||||
|
noResultSection(text: "No contacts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func noResultSection(text: String) -> some View {
|
||||||
|
Section {
|
||||||
|
Text(text)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 7, leading: 0, bottom: 7, trailing: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contactTypesSearchTargets(baseContactTypes: [ContactType], searchEmpty: Bool) -> [ContactType] {
|
||||||
|
if baseContactTypes.contains(.chatDeleted) || searchEmpty {
|
||||||
|
return baseContactTypes
|
||||||
|
} else {
|
||||||
|
return baseContactTypes + [.chatDeleted]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
|
||||||
|
let chat1Type = chatContactType(chat: chat1)
|
||||||
|
let chat2Type = chatContactType(chat: chat2)
|
||||||
|
|
||||||
|
if chat1Type.rawValue < chat2Type.rawValue {
|
||||||
|
return true
|
||||||
|
} else if chat1Type.rawValue > chat2Type.rawValue {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return chat2.chatInfo.chatTs < chat1.chatInfo.chatTs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Bool) -> Bool {
|
||||||
|
var meetsPredicate = true
|
||||||
|
let s = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let cInfo = chat.chatInfo
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
if (!cInfo.chatViewName.lowercased().contains(searchText.lowercased())) {
|
||||||
|
if case let .direct(contact) = cInfo {
|
||||||
|
meetsPredicate = contact.profile.displayName.lowercased().contains(s) || contact.fullName.lowercased().contains(s)
|
||||||
|
} else {
|
||||||
|
meetsPredicate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showUnreadAndFavorites {
|
||||||
|
meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return meetsPredicate
|
||||||
|
}
|
||||||
|
|
||||||
|
func filteredContactChats(
|
||||||
|
showUnreadAndFavorites: Bool,
|
||||||
|
searchShowingSimplexLink: Bool,
|
||||||
|
searchChatFilteredBySimplexLink: String?,
|
||||||
|
searchText: String,
|
||||||
|
contactChats: [Chat]
|
||||||
|
) -> [Chat] {
|
||||||
|
let linkChatId = searchChatFilteredBySimplexLink
|
||||||
|
let s = searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
|
||||||
|
let filteredChats: [Chat]
|
||||||
|
|
||||||
|
if let linkChatId = linkChatId {
|
||||||
|
filteredChats = contactChats.filter { $0.id == linkChatId }
|
||||||
|
} else {
|
||||||
|
filteredChats = contactChats.filter { chat in
|
||||||
|
filterChat(chat: chat, searchText: s, showUnreadAndFavorites: showUnreadAndFavorites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredChats.sorted(by: chatsByTypeComparator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContactsListSearchBar: View {
|
||||||
|
@EnvironmentObject var m: ChatModel
|
||||||
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@Binding var searchMode: Bool
|
||||||
|
@FocusState.Binding var searchFocussed: Bool
|
||||||
|
@Binding var searchText: String
|
||||||
|
@Binding var searchShowingSimplexLink: Bool
|
||||||
|
@Binding var searchChatFilteredBySimplexLink: String?
|
||||||
|
@State private var ignoreSearchTextChange = false
|
||||||
|
@State private var alert: PlanAndConnectAlert?
|
||||||
|
@State private var sheet: PlanAndConnectActionSheet?
|
||||||
|
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: 8)
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
TextField("Search or paste SimpleX link", text: $searchText)
|
||||||
|
.foregroundColor(searchShowingSimplexLink ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
|
.disabled(searchShowingSimplexLink)
|
||||||
|
.focused($searchFocussed)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.onTapGesture {
|
||||||
|
searchText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
|
.cornerRadius(10.0)
|
||||||
|
|
||||||
|
if searchFocussed {
|
||||||
|
Text("Cancel")
|
||||||
|
.foregroundColor(theme.colors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
searchText = ""
|
||||||
|
searchFocussed = false
|
||||||
|
}
|
||||||
|
} else if m.chats.count > 0 {
|
||||||
|
toggleFilterButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
.onChange(of: searchFocussed) { sf in
|
||||||
|
withAnimation { searchMode = sf }
|
||||||
|
}
|
||||||
|
.onChange(of: searchText) { t in
|
||||||
|
if ignoreSearchTextChange {
|
||||||
|
ignoreSearchTextChange = false
|
||||||
|
} else {
|
||||||
|
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
|
||||||
|
searchFocussed = false
|
||||||
|
if case let .simplexLink(linkType, _, smpHosts) = link.format {
|
||||||
|
ignoreSearchTextChange = true
|
||||||
|
searchText = simplexLinkText(linkType, smpHosts)
|
||||||
|
}
|
||||||
|
searchShowingSimplexLink = true
|
||||||
|
searchChatFilteredBySimplexLink = nil
|
||||||
|
connect(link.text)
|
||||||
|
} else {
|
||||||
|
if t != "" { // if some other text is pasted, enter search mode
|
||||||
|
searchFocussed = true
|
||||||
|
}
|
||||||
|
searchShowingSimplexLink = false
|
||||||
|
searchChatFilteredBySimplexLink = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $alert) { a in
|
||||||
|
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
|
||||||
|
}
|
||||||
|
.actionSheet(item: $sheet) { s in
|
||||||
|
planAndConnectActionSheet(s, dismiss: true, cleanup: { searchText = "" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleFilterButton() -> some View {
|
||||||
|
ZStack {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
|
||||||
|
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
|
||||||
|
.onTapGesture {
|
||||||
|
showUnreadAndFavorites = !showUnreadAndFavorites
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect(_ link: String) {
|
||||||
|
planAndConnect(
|
||||||
|
link,
|
||||||
|
showAlert: { alert = $0 },
|
||||||
|
showActionSheet: { sheet = $0 },
|
||||||
|
dismiss: true,
|
||||||
|
incognito: nil,
|
||||||
|
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct DeletedChats: View {
|
||||||
|
@State private var baseContactTypes: [ContactType] = [.chatDeleted]
|
||||||
|
@State private var searchMode = false
|
||||||
|
@FocusState var searchFocussed: Bool
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var searchShowingSimplexLink = false
|
||||||
|
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ContactsListSearchBar(
|
||||||
|
searchMode: $searchMode,
|
||||||
|
searchFocussed: $searchFocussed,
|
||||||
|
searchText: $searchText,
|
||||||
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||||
|
)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
ContactsList(
|
||||||
|
baseContactTypes: $baseContactTypes,
|
||||||
|
searchMode: $searchMode,
|
||||||
|
searchText: $searchText,
|
||||||
|
searchFocussed: $searchFocussed,
|
||||||
|
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||||
|
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
|
||||||
|
showDeletedChatIcon: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.navigationTitle("Archived contacts")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.navigationBarHidden(searchMode)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NewChatMenuButton()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,19 @@ struct SomeAlert: Identifiable {
|
|||||||
var id: String
|
var id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SomeActionSheet: Identifiable {
|
||||||
|
var actionSheet: ActionSheet
|
||||||
|
var id: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SomeSheet<Content: View>: Identifiable {
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
var id: String
|
||||||
|
}
|
||||||
|
|
||||||
private enum NewChatViewAlert: Identifiable {
|
private enum NewChatViewAlert: Identifiable {
|
||||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||||
case newChatSomeAlert(alert: SomeAlert)
|
case newChatSomeAlert(alert: SomeAlert)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||||
@@ -47,22 +56,10 @@ struct NewChatView: View {
|
|||||||
@State private var creatingConnReq = false
|
@State private var creatingConnReq = false
|
||||||
@State private var pastedLink: String = ""
|
@State private var pastedLink: String = ""
|
||||||
@State private var alert: NewChatViewAlert?
|
@State private var alert: NewChatViewAlert?
|
||||||
|
@Binding var parentAlert: SomeAlert?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
|
||||||
Text("New chat")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.bold()
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
Spacer()
|
|
||||||
InfoSheetButton {
|
|
||||||
AddContactLearnMore(showTitle: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.padding(.top)
|
|
||||||
|
|
||||||
Picker("New chat", selection: $selection) {
|
Picker("New chat", selection: $selection) {
|
||||||
Label("Add contact", systemImage: "link")
|
Label("Add contact", systemImage: "link")
|
||||||
.tag(NewChatOption.invite)
|
.tag(NewChatOption.invite)
|
||||||
@@ -88,6 +85,7 @@ struct NewChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.background(
|
.background(
|
||||||
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
|
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -110,6 +108,13 @@ struct NewChatView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
InfoSheetButton {
|
||||||
|
AddContactLearnMore(showTitle: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
.onChange(of: invitationUsed) { used in
|
.onChange(of: invitationUsed) { used in
|
||||||
if used && !(m.showingInvitation?.connChatUsed ?? true) {
|
if used && !(m.showingInvitation?.connChatUsed ?? true) {
|
||||||
@@ -119,19 +124,22 @@ struct NewChatView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
if !(m.showingInvitation?.connChatUsed ?? true),
|
if !(m.showingInvitation?.connChatUsed ?? true),
|
||||||
let conn = contactConnection {
|
let conn = contactConnection {
|
||||||
AlertManager.shared.showAlert(Alert(
|
parentAlert = SomeAlert(
|
||||||
title: Text("Keep unused invitation?"),
|
alert: Alert(
|
||||||
message: Text("You can view invitation link again in connection details."),
|
title: Text("Keep unused invitation?"),
|
||||||
primaryButton: .default(Text("Keep")) {},
|
message: Text("You can view invitation link again in connection details."),
|
||||||
secondaryButton: .destructive(Text("Delete")) {
|
primaryButton: .default(Text("Keep")) {},
|
||||||
Task {
|
secondaryButton: .destructive(Text("Delete")) {
|
||||||
await deleteChat(Chat(
|
Task {
|
||||||
chatInfo: .contactConnection(contactConnection: conn),
|
await deleteChat(Chat(
|
||||||
chatItems: []
|
chatInfo: .contactConnection(contactConnection: conn),
|
||||||
))
|
chatItems: []
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
),
|
||||||
))
|
id: "keepUnusedInvitation"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
m.showingInvitation = nil
|
m.showingInvitation = nil
|
||||||
}
|
}
|
||||||
@@ -837,7 +845,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
|
|||||||
dismissAllSheets(animated: true)
|
dismissAllSheets(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = await connectContactViaAddress(contact.contactId, incognito)
|
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||||
|
if ok {
|
||||||
|
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||||
|
}
|
||||||
cleanup?()
|
cleanup?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,11 +898,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if dismiss {
|
if dismiss {
|
||||||
dismissAllSheets(animated: true) {
|
dismissAllSheets(animated: true) {
|
||||||
m.chatId = c.id
|
ItemsModel.shared.loadOpenChat(c.id)
|
||||||
showAlreadyExistsAlert?()
|
showAlreadyExistsAlert?()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.chatId = c.id
|
ItemsModel.shared.loadOpenChat(c.id)
|
||||||
showAlreadyExistsAlert?()
|
showAlreadyExistsAlert?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -906,11 +917,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if dismiss {
|
if dismiss {
|
||||||
dismissAllSheets(animated: true) {
|
dismissAllSheets(animated: true) {
|
||||||
m.chatId = g.id
|
ItemsModel.shared.loadOpenChat(g.id)
|
||||||
showAlreadyExistsAlert?()
|
showAlreadyExistsAlert?()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.chatId = g.id
|
ItemsModel.shared.loadOpenChat(g.id)
|
||||||
showAlreadyExistsAlert?()
|
showAlreadyExistsAlert?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -961,8 +972,13 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
struct NewChatView_Previews: PreviewProvider {
|
||||||
NewChatView(
|
static var previews: some View {
|
||||||
selection: .invite
|
@State var parentAlert: SomeAlert?
|
||||||
)
|
|
||||||
|
NewChatView(
|
||||||
|
selection: .invite,
|
||||||
|
parentAlert: $parentAlert
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ struct CreateProfile: View {
|
|||||||
focusDisplayName = true
|
focusDisplayName = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.keyboardPadding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +127,6 @@ struct CreateFirstProfile: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.keyboardPadding()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func onboardingButtons() -> some View {
|
func onboardingButtons() -> some View {
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ private struct VersionDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct FeatureDescription {
|
private struct FeatureDescription {
|
||||||
var icon: String
|
var icon: String?
|
||||||
var title: LocalizedStringKey
|
var title: LocalizedStringKey
|
||||||
var description: LocalizedStringKey
|
var description: LocalizedStringKey?
|
||||||
|
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
private let versionDescriptions: [VersionDescription] = [
|
private let versionDescriptions: [VersionDescription] = [
|
||||||
@@ -428,6 +429,44 @@ private let versionDescriptions: [VersionDescription] = [
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
VersionDescription(
|
||||||
|
version: "v6.0",
|
||||||
|
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
|
||||||
|
features: [
|
||||||
|
FeatureDescription(
|
||||||
|
icon: nil,
|
||||||
|
title: "New chat experience 🎉",
|
||||||
|
description: nil,
|
||||||
|
subfeatures: [
|
||||||
|
("link.badge.plus", "Connect to your friends faster."),
|
||||||
|
("archivebox", "Archive contacts to chat later."),
|
||||||
|
("trash", "Delete up to 20 messages at once."),
|
||||||
|
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
|
||||||
|
("paintpalette", "Color chats with the new themes."),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
FeatureDescription(
|
||||||
|
icon: nil,
|
||||||
|
title: "New media options",
|
||||||
|
description: nil,
|
||||||
|
subfeatures: [
|
||||||
|
("square.and.arrow.up", "Share from other apps."),
|
||||||
|
("play.circle", "Play from the chat list."),
|
||||||
|
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
FeatureDescription(
|
||||||
|
icon: "arrow.forward",
|
||||||
|
title: "Private message routing 🚀",
|
||||||
|
description: "It protects your IP address and connections."
|
||||||
|
),
|
||||||
|
FeatureDescription(
|
||||||
|
icon: "network",
|
||||||
|
title: "Better networking",
|
||||||
|
description: "Connection and servers status."
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
private let lastVersion = versionDescriptions.last!.version
|
private let lastVersion = versionDescriptions.last!.version
|
||||||
@@ -453,35 +492,37 @@ struct WhatsNewView: View {
|
|||||||
VStack {
|
VStack {
|
||||||
TabView(selection: $currentVersion) {
|
TabView(selection: $currentVersion) {
|
||||||
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
|
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
ScrollView {
|
||||||
Text("New in \(v.version)")
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.font(.title)
|
Text("New in \(v.version)")
|
||||||
.foregroundColor(theme.colors.secondary)
|
.font(.title)
|
||||||
.frame(maxWidth: .infinity)
|
.foregroundColor(theme.colors.secondary)
|
||||||
.padding(.vertical)
|
.frame(maxWidth: .infinity)
|
||||||
ForEach(v.features, id: \.icon) { f in
|
.padding(.vertical)
|
||||||
featureDescription(f.icon, f.title, f.description)
|
ForEach(v.features, id: \.title) { f in
|
||||||
.padding(.bottom, 8)
|
featureDescription(f)
|
||||||
}
|
.padding(.bottom, 8)
|
||||||
if let post = v.post {
|
}
|
||||||
Link(destination: post) {
|
if let post = v.post {
|
||||||
HStack {
|
Link(destination: post) {
|
||||||
Text("Read more")
|
HStack {
|
||||||
Image(systemName: "arrow.up.right.circle")
|
Text("Read more")
|
||||||
|
Image(systemName: "arrow.up.right.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if !viaSettings {
|
||||||
if !viaSettings {
|
Spacer()
|
||||||
Spacer()
|
Button("Ok") {
|
||||||
Button("Ok") {
|
dismiss()
|
||||||
dismiss()
|
}
|
||||||
|
.font(.title3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.font(.title3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.tag(i)
|
.tag(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,18 +536,37 @@ struct WhatsNewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
|
private func featureDescription(_ f: FeatureDescription) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
if let icon = f.icon {
|
||||||
Image(systemName: icon)
|
HStack(alignment: .center, spacing: 4) {
|
||||||
.symbolRenderingMode(.monochrome)
|
Image(systemName: icon)
|
||||||
.foregroundColor(theme.colors.secondary)
|
.symbolRenderingMode(.monochrome)
|
||||||
.frame(minWidth: 30, alignment: .center)
|
.foregroundColor(theme.colors.secondary)
|
||||||
Text(title).font(.title3).bold()
|
.frame(minWidth: 30, alignment: .center)
|
||||||
|
Text(f.title).font(.title3).bold()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(f.title).font(.title3).bold()
|
||||||
|
}
|
||||||
|
if let d = f.description {
|
||||||
|
Text(d)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(10)
|
||||||
|
}
|
||||||
|
if f.subfeatures.count > 0 {
|
||||||
|
ForEach(f.subfeatures, id: \.icon) { s in
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
Image(systemName: s.icon)
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
.frame(minWidth: 30, alignment: .center)
|
||||||
|
Text(s.description)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(description)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.lineLimit(10)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
|
|||||||
case badInvitationError
|
case badInvitationError
|
||||||
case badVersionError(version: String?)
|
case badVersionError(version: String?)
|
||||||
case desktopDisconnectedError
|
case desktopDisconnectedError
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
|
|||||||
case .desktopDisconnectedError:
|
case .desktopDisconnectedError:
|
||||||
Alert(title: Text("Connection terminated"))
|
Alert(title: Text("Connection terminated"))
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
Alert(title: Text(title), message: Text(error))
|
mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||||
|
|||||||
@@ -24,34 +24,114 @@ enum NetworkSettingsAlert: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AdvancedNetworkSettings: View {
|
struct AdvancedNetworkSettings: View {
|
||||||
|
@Environment(\.dismiss) var dismiss: DismissAction
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
|
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||||
@State private var netCfg = NetCfg.defaults
|
@State private var netCfg = NetCfg.defaults
|
||||||
@State private var currentNetCfg = NetCfg.defaults
|
@State private var currentNetCfg = NetCfg.defaults
|
||||||
@State private var cfgLoaded = false
|
@State private var cfgLoaded = false
|
||||||
@State private var enableKeepAlive = true
|
@State private var enableKeepAlive = true
|
||||||
@State private var keepAliveOpts = KeepAliveOpts.defaults
|
@State private var keepAliveOpts = KeepAliveOpts.defaults
|
||||||
@State private var showSettingsAlert: NetworkSettingsAlert?
|
@State private var showSettingsAlert: NetworkSettingsAlert?
|
||||||
|
@State private var onionHosts: OnionHosts = .no
|
||||||
|
@State private var showSaveDialog = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
NavigationLink {
|
||||||
updateNetCfgView(NetCfg.defaults)
|
List {
|
||||||
showSettingsAlert = .update
|
Section {
|
||||||
|
SelectionListView(list: SMPProxyMode.values, selection: $netCfg.smpProxyMode) { mode in
|
||||||
|
netCfg.smpProxyMode = mode
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(proxyModeInfo(netCfg.smpProxyMode))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Private routing")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} label: {
|
} label: {
|
||||||
Text("Reset to defaults")
|
HStack {
|
||||||
|
Text("Private routing")
|
||||||
|
Spacer()
|
||||||
|
Text(netCfg.smpProxyMode.label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(currentNetCfg == NetCfg.defaults)
|
|
||||||
|
NavigationLink {
|
||||||
Button {
|
List {
|
||||||
updateNetCfgView(NetCfg.proxyDefaults)
|
Section {
|
||||||
showSettingsAlert = .update
|
SelectionListView(list: SMPProxyFallback.values, selection: $netCfg.smpProxyFallback) { mode in
|
||||||
|
netCfg.smpProxyFallback = mode
|
||||||
|
}
|
||||||
|
.disabled(netCfg.smpProxyMode == .never)
|
||||||
|
} footer: {
|
||||||
|
Text(proxyFallbackInfo(netCfg.smpProxyFallback))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Allow downgrade")
|
||||||
|
.modifier(ThemedBackground(grouped: true))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
} label: {
|
} label: {
|
||||||
Text("Set timeouts for proxy/VPN")
|
HStack {
|
||||||
|
Text("Allow downgrade")
|
||||||
|
Spacer()
|
||||||
|
Text(netCfg.smpProxyFallback.label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(currentNetCfg == NetCfg.proxyDefaults)
|
|
||||||
|
|
||||||
|
Toggle("Show message status", isOn: $showSentViaProxy)
|
||||||
|
} header: {
|
||||||
|
Text("Private message routing")
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
} footer: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
|
||||||
|
if showSentViaProxy {
|
||||||
|
Text("Show → on messages sent via private routing.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||||
|
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
} footer: {
|
||||||
|
Text(onionHostsInfo(onionHosts))
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
.onChange(of: onionHosts) { hosts in
|
||||||
|
if hosts != OnionHosts(netCfg: currentNetCfg) {
|
||||||
|
let (hostMode, requiredHostMode) = hosts.hostMode
|
||||||
|
netCfg.hostMode = hostMode
|
||||||
|
netCfg.requiredHostMode = requiredHostMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if developerTools {
|
||||||
|
Section {
|
||||||
|
Picker("Transport isolation", selection: $netCfg.sessionMode) {
|
||||||
|
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
} footer: {
|
||||||
|
Text(sessionModeInfo(netCfg.sessionMode))
|
||||||
|
.foregroundColor(theme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("TCP connection") {
|
||||||
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
|
timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [10_000000, 15_000000, 20_000000, 30_000000, 45_000000, 60_000000, 90_000000], label: secondsLabel)
|
||||||
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel)
|
||||||
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
|
timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel)
|
||||||
@@ -72,24 +152,21 @@ struct AdvancedNetworkSettings: View {
|
|||||||
}
|
}
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
} header: {
|
}
|
||||||
Text("")
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
Section {
|
||||||
} footer: {
|
Button("Reset to defaults") {
|
||||||
HStack {
|
updateNetCfgView(NetCfg.defaults)
|
||||||
Button {
|
}
|
||||||
updateNetCfgView(currentNetCfg)
|
.disabled(netCfg == NetCfg.defaults)
|
||||||
} label: {
|
|
||||||
Label("Revert", systemImage: "arrow.counterclockwise").font(.callout)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Button("Set timeouts for proxy/VPN") {
|
||||||
|
updateNetCfgView(netCfg.withProxyTimeouts)
|
||||||
Button {
|
}
|
||||||
showSettingsAlert = .update
|
.disabled(netCfg.hasProxyTimeouts)
|
||||||
} label: {
|
|
||||||
Label("Save", systemImage: "checkmark").font(.callout)
|
Button("Save and reconnect") {
|
||||||
}
|
showSettingsAlert = .update
|
||||||
}
|
}
|
||||||
.disabled(netCfg == currentNetCfg)
|
.disabled(netCfg == currentNetCfg)
|
||||||
}
|
}
|
||||||
@@ -111,10 +188,10 @@ struct AdvancedNetworkSettings: View {
|
|||||||
switch a {
|
switch a {
|
||||||
case .update:
|
case .update:
|
||||||
return Alert(
|
return Alert(
|
||||||
title: Text("Update network settings?"),
|
title: Text("Update settings?"),
|
||||||
message: Text("Updating settings will re-connect the client to all servers."),
|
message: Text("Updating settings will re-connect the client to all servers."),
|
||||||
primaryButton: .default(Text("Ok")) {
|
primaryButton: .default(Text("Ok")) {
|
||||||
saveNetCfg()
|
_ = saveNetCfg()
|
||||||
},
|
},
|
||||||
secondaryButton: .cancel()
|
secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
@@ -125,23 +202,43 @@ struct AdvancedNetworkSettings: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||||
|
if netCfg == currentNetCfg {
|
||||||
|
dismiss()
|
||||||
|
cfgLoaded = false
|
||||||
|
} else {
|
||||||
|
showSaveDialog = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.confirmationDialog("Update network settings?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
||||||
|
Button("Save and reconnect") {
|
||||||
|
if saveNetCfg() {
|
||||||
|
dismiss()
|
||||||
|
cfgLoaded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Exit without saving") { dismiss() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNetCfgView(_ cfg: NetCfg) {
|
private func updateNetCfgView(_ cfg: NetCfg) {
|
||||||
netCfg = cfg
|
netCfg = cfg
|
||||||
|
onionHosts = OnionHosts(netCfg: netCfg)
|
||||||
enableKeepAlive = netCfg.enableKeepAlive
|
enableKeepAlive = netCfg.enableKeepAlive
|
||||||
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
|
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveNetCfg() {
|
private func saveNetCfg() -> Bool {
|
||||||
do {
|
do {
|
||||||
try setNetworkConfig(netCfg)
|
try setNetworkConfig(netCfg)
|
||||||
currentNetCfg = netCfg
|
currentNetCfg = netCfg
|
||||||
setNetCfg(netCfg)
|
setNetCfg(netCfg)
|
||||||
|
return true
|
||||||
} catch let error {
|
} catch let error {
|
||||||
let err = responseError(error)
|
let err = responseError(error)
|
||||||
showSettingsAlert = .error(err: err)
|
showSettingsAlert = .error(err: err)
|
||||||
logger.error("\(err)")
|
logger.error("\(err)")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +261,38 @@ struct AdvancedNetworkSettings: View {
|
|||||||
}
|
}
|
||||||
.frame(height: 36)
|
.frame(height: 36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
|
||||||
|
switch hosts {
|
||||||
|
case .no: return "Onion hosts will not be used."
|
||||||
|
case .prefer: return "Onion hosts will be used when available.\nRequires compatible VPN."
|
||||||
|
case .require: return "Onion hosts will be **required** for connection.\nRequires compatible VPN."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
|
||||||
|
switch mode {
|
||||||
|
case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
|
||||||
|
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
|
||||||
|
switch mode {
|
||||||
|
case .always: return "Always use private routing."
|
||||||
|
case .unknown: return "Use private routing with unknown servers."
|
||||||
|
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
|
||||||
|
case .never: return "Do NOT use private routing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
|
||||||
|
switch proxyFallback {
|
||||||
|
case .allow: return "Send messages directly when your or destination server does not support private routing."
|
||||||
|
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
|
||||||
|
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AdvancedNetworkSettings_Previews: PreviewProvider {
|
struct AdvancedNetworkSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ extension AppSettings {
|
|||||||
privacyAcceptImagesGroupDefault.set(val)
|
privacyAcceptImagesGroupDefault.set(val)
|
||||||
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||||
}
|
}
|
||||||
if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) }
|
if let val = privacyLinkPreviews {
|
||||||
|
privacyLinkPreviewsGroupDefault.set(val)
|
||||||
|
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||||
|
}
|
||||||
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
|
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 = 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 = 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 = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
|
||||||
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
|
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
|
||||||
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
|
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
|
||||||
@@ -44,11 +48,15 @@ extension AppSettings {
|
|||||||
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
|
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
|
||||||
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
|
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
|
||||||
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
|
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
|
||||||
if let val = uiProfileImageCornerRadius { def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) }
|
if let val = uiProfileImageCornerRadius {
|
||||||
|
profileImageCornerRadiusGroupDefault.set(val)
|
||||||
|
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||||
|
}
|
||||||
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
|
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
|
||||||
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
|
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
|
||||||
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
|
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
|
||||||
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
|
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
|
||||||
|
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var current: AppSettings {
|
public static var current: AppSettings {
|
||||||
@@ -62,6 +70,7 @@ extension AppSettings {
|
|||||||
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
||||||
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
||||||
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
|
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.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
|
||||||
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||||
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||||
@@ -74,11 +83,12 @@ extension AppSettings {
|
|||||||
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
|
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
|
||||||
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
|
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
|
||||||
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
|
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
|
||||||
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||||
c.uiColorScheme = currentThemeDefault.get()
|
c.uiColorScheme = currentThemeDefault.get()
|
||||||
c.uiDarkColorScheme = systemDarkThemeDefault.get()
|
c.uiDarkColorScheme = systemDarkThemeDefault.get()
|
||||||
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
||||||
c.uiThemes = themeOverridesDefault.get()
|
c.uiThemes = themeOverridesDefault.get()
|
||||||
|
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ struct AppearanceSettings: View {
|
|||||||
}()
|
}()
|
||||||
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
|
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
|
||||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
|
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
|
||||||
|
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||||
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
|
||||||
@State var themeUserDestination: (Int64, ThemeModeOverrides?)? = {
|
@State var themeUserDestination: (Int64, ThemeModeOverrides?)? = {
|
||||||
if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil {
|
if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil {
|
||||||
@@ -62,6 +64,16 @@ struct AppearanceSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Chat list") {
|
||||||
|
Toggle("Reachable chat toolbar", isOn: $oneHandUI)
|
||||||
|
Picker("Toolbar opacity", selection: $toolbarMaterial) {
|
||||||
|
ForEach(ToolbarMaterial.allCases, id: \.rawValue) { tm in
|
||||||
|
Text(tm.text).tag(tm.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ThemeDestinationPicker(themeUserDestination: $themeUserDestination, themeUserDest: themeUserDestination?.0, customizeThemeIsOpen: $customizeThemeIsOpen)
|
ThemeDestinationPicker(themeUserDestination: $themeUserDestination, themeUserDest: themeUserDestination?.0, customizeThemeIsOpen: $customizeThemeIsOpen)
|
||||||
|
|
||||||
@@ -143,7 +155,8 @@ struct AppearanceSettings: View {
|
|||||||
Text("Themes")
|
Text("Themes")
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
.onChange(of: profileImageCornerRadius) { _ in
|
.onChange(of: profileImageCornerRadius) { cornerRadius in
|
||||||
|
profileImageCornerRadiusGroupDefault.set(cornerRadius)
|
||||||
saveThemeToDatabase(nil)
|
saveThemeToDatabase(nil)
|
||||||
}
|
}
|
||||||
.onChange(of: colorMode) { mode in
|
.onChange(of: colorMode) { mode in
|
||||||
@@ -290,6 +303,43 @@ struct AppearanceSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ToolbarMaterial: String, CaseIterable {
|
||||||
|
case bar
|
||||||
|
case ultraThin
|
||||||
|
case thin
|
||||||
|
case regular
|
||||||
|
case thick
|
||||||
|
case ultraThick
|
||||||
|
|
||||||
|
static func material(_ s: String) -> Material {
|
||||||
|
ToolbarMaterial(rawValue: s)?.material ?? Material.bar
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultMaterial: String = ToolbarMaterial.regular.rawValue
|
||||||
|
|
||||||
|
var material: Material {
|
||||||
|
switch self {
|
||||||
|
case .bar: .bar
|
||||||
|
case .ultraThin: .ultraThin
|
||||||
|
case .thin: .thin
|
||||||
|
case .regular: .regular
|
||||||
|
case .thick: .thick
|
||||||
|
case .ultraThick: .ultraThick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
switch self {
|
||||||
|
case .bar: "System"
|
||||||
|
case .ultraThin: "Ultra thin"
|
||||||
|
case .thin: "Thin"
|
||||||
|
case .regular: "Regular"
|
||||||
|
case .thick: "Thick"
|
||||||
|
case .ultraThick: "Ultra thick"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ChatThemePreview: View {
|
struct ChatThemePreview: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var base: DefaultTheme
|
var base: DefaultTheme
|
||||||
@@ -305,13 +355,16 @@ struct ChatThemePreview: View {
|
|||||||
let view = VStack {
|
let view = VStack {
|
||||||
if withMessages {
|
if withMessages {
|
||||||
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
|
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 {
|
HStack {
|
||||||
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
|
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
|
||||||
|
.modifier(ChatItemClipped())
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
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))
|
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
|
||||||
|
.modifier(ChatItemClipped())
|
||||||
.frame(alignment: .trailing)
|
.frame(alignment: .trailing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -747,7 +800,7 @@ struct ThemeDestinationPicker: View {
|
|||||||
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
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 }) {
|
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
|
||||||
Picker("Apply to mode", selection: $themeUserDest) {
|
Picker("Apply to", selection: $themeUserDest) {
|
||||||
ForEach(values, id: \.0) { (_, text) in
|
ForEach(values, id: \.0) { (_, text) in
|
||||||
Text(text)
|
Text(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct DeveloperView: View {
|
|||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||||
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
|
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
|
||||||
|
@State private var hintsUnchanged = hintDefaultsUnchanged()
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -33,8 +35,9 @@ struct DeveloperView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") }
|
settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") }
|
||||||
}
|
}
|
||||||
settingsRow("internaldrive", color: theme.colors.secondary) {
|
settingsRow("lightbulb.max", color: theme.colors.secondary) {
|
||||||
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
|
Button("Reset all hints", action: resetHintDefaults)
|
||||||
|
.disabled(hintsUnchanged)
|
||||||
}
|
}
|
||||||
settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) {
|
settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) {
|
||||||
Toggle("Show developer options", isOn: $developerTools)
|
Toggle("Show developer options", isOn: $developerTools)
|
||||||
@@ -45,9 +48,34 @@ struct DeveloperView: View {
|
|||||||
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
|
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if developerTools {
|
||||||
|
Section {
|
||||||
|
settingsRow("internaldrive", color: theme.colors.secondary) {
|
||||||
|
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Developer options")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resetHintDefaults() {
|
||||||
|
for def in hintDefaults {
|
||||||
|
if let val = appDefaults[def] as? Bool {
|
||||||
|
UserDefaults.standard.set(val, forKey: def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hintsUnchanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hintDefaultsUnchanged() -> Bool {
|
||||||
|
hintDefaults.allSatisfy { def in
|
||||||
|
appDefaults[def] as? Bool == UserDefaults.standard.bool(forKey: def)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DeveloperView_Previews: PreviewProvider {
|
struct DeveloperView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -10,18 +10,10 @@ import SwiftUI
|
|||||||
import SimpleXChat
|
import SimpleXChat
|
||||||
|
|
||||||
private enum NetworkAlert: Identifiable {
|
private enum NetworkAlert: Identifiable {
|
||||||
case updateOnionHosts(hosts: OnionHosts)
|
|
||||||
case updateSessionMode(mode: TransportSessionMode)
|
|
||||||
case updateSMPProxyMode(proxyMode: SMPProxyMode)
|
|
||||||
case updateSMPProxyFallback(proxyFallback: SMPProxyFallback)
|
|
||||||
case error(err: String)
|
case error(err: String)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)"
|
|
||||||
case let .updateSessionMode(mode): return "updateSessionMode \(mode)"
|
|
||||||
case let .updateSMPProxyMode(proxyMode): return "updateSMPProxyMode \(proxyMode)"
|
|
||||||
case let .updateSMPProxyFallback(proxyFallback): return "updateSMPProxyFallback \(proxyFallback)"
|
|
||||||
case let .error(err): return "error \(err)"
|
case let .error(err): return "error \(err)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,17 +22,6 @@ private enum NetworkAlert: Identifiable {
|
|||||||
struct NetworkAndServers: View {
|
struct NetworkAndServers: View {
|
||||||
@EnvironmentObject var m: ChatModel
|
@EnvironmentObject var m: ChatModel
|
||||||
@EnvironmentObject var theme: AppTheme
|
@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
|
|
||||||
@State private var onionHosts: OnionHosts = .no
|
|
||||||
@State private var sessionMode: TransportSessionMode = .user
|
|
||||||
@State private var proxyMode: SMPProxyMode = .never
|
|
||||||
@State private var proxyFallback: SMPProxyFallback = .allow
|
|
||||||
@State private var alert: NetworkAlert?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -51,7 +32,7 @@ struct NetworkAndServers: View {
|
|||||||
.navigationTitle("Your SMP servers")
|
.navigationTitle("Your SMP servers")
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("SMP servers")
|
Text("Message servers")
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -59,26 +40,12 @@ struct NetworkAndServers: View {
|
|||||||
.navigationTitle("Your XFTP servers")
|
.navigationTitle("Your XFTP servers")
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("XFTP servers")
|
Text("Media & file servers")
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
|
|
||||||
|
|
||||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
|
||||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
|
||||||
}
|
|
||||||
.frame(height: 36)
|
|
||||||
|
|
||||||
if developerTools {
|
|
||||||
Picker("Transport isolation", selection: $sessionMode) {
|
|
||||||
ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) }
|
|
||||||
}
|
|
||||||
.frame(height: 36)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
AdvancedNetworkSettings()
|
AdvancedNetworkSettings()
|
||||||
.navigationTitle("Network settings")
|
.navigationTitle("Advanced settings")
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
} label: {
|
} label: {
|
||||||
Text("Advanced network settings")
|
Text("Advanced network settings")
|
||||||
@@ -86,35 +53,6 @@ struct NetworkAndServers: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Messages & files")
|
Text("Messages & files")
|
||||||
.foregroundColor(theme.colors.secondary)
|
.foregroundColor(theme.colors.secondary)
|
||||||
} footer: {
|
|
||||||
Text("Using .onion hosts requires compatible VPN provider.")
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Picker("Private routing", selection: $proxyMode) {
|
|
||||||
ForEach(SMPProxyMode.values, id: \.self) { Text($0.text) }
|
|
||||||
}
|
|
||||||
.frame(height: 36)
|
|
||||||
|
|
||||||
Picker("Allow downgrade", selection: $proxyFallback) {
|
|
||||||
ForEach(SMPProxyFallback.values, id: \.self) { Text($0.text) }
|
|
||||||
}
|
|
||||||
.disabled(proxyMode == .never)
|
|
||||||
.frame(height: 36)
|
|
||||||
|
|
||||||
Toggle("Show message status", isOn: $showSentViaProxy)
|
|
||||||
} header: {
|
|
||||||
Text("Private message routing")
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
|
||||||
} footer: {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text("To protect your IP address, private routing uses your SMP servers to deliver messages.")
|
|
||||||
if showSentViaProxy {
|
|
||||||
Text("Show → on messages sent via private routing.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(theme.colors.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
|
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
|
||||||
@@ -136,147 +74,6 @@ struct NetworkAndServers: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
if cfgLoaded { return }
|
|
||||||
cfgLoaded = true
|
|
||||||
currentNetCfg = getNetCfg()
|
|
||||||
resetNetCfgView()
|
|
||||||
}
|
|
||||||
.onChange(of: onionHosts) { hosts in
|
|
||||||
if hosts != OnionHosts(netCfg: currentNetCfg) {
|
|
||||||
alert = .updateOnionHosts(hosts: hosts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: sessionMode) { mode in
|
|
||||||
if mode != netCfg.sessionMode {
|
|
||||||
alert = .updateSessionMode(mode: mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: proxyMode) { mode in
|
|
||||||
if mode != netCfg.smpProxyMode {
|
|
||||||
alert = .updateSMPProxyMode(proxyMode: mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: proxyFallback) { fallbackMode in
|
|
||||||
if fallbackMode != netCfg.smpProxyFallback {
|
|
||||||
alert = .updateSMPProxyFallback(proxyFallback: fallbackMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert(item: $alert) { a in
|
|
||||||
switch a {
|
|
||||||
case let .updateOnionHosts(hosts):
|
|
||||||
return Alert(
|
|
||||||
title: Text("Update .onion hosts setting?"),
|
|
||||||
message: Text(onionHostsInfo(hosts)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
|
|
||||||
primaryButton: .default(Text("Ok")) {
|
|
||||||
let (hostMode, requiredHostMode) = hosts.hostMode
|
|
||||||
netCfg.hostMode = hostMode
|
|
||||||
netCfg.requiredHostMode = requiredHostMode
|
|
||||||
saveNetCfg()
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel() {
|
|
||||||
resetNetCfgView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case let .updateSessionMode(mode):
|
|
||||||
return Alert(
|
|
||||||
title: Text("Update transport isolation mode?"),
|
|
||||||
message: Text(sessionModeInfo(mode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
|
|
||||||
primaryButton: .default(Text("Ok")) {
|
|
||||||
netCfg.sessionMode = mode
|
|
||||||
saveNetCfg()
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel() {
|
|
||||||
resetNetCfgView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case let .updateSMPProxyMode(proxyMode):
|
|
||||||
return Alert(
|
|
||||||
title: Text("Message routing mode"),
|
|
||||||
message: Text(proxyModeInfo(proxyMode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
|
|
||||||
primaryButton: .default(Text("Ok")) {
|
|
||||||
netCfg.smpProxyMode = proxyMode
|
|
||||||
saveNetCfg()
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel() {
|
|
||||||
resetNetCfgView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case let .updateSMPProxyFallback(proxyFallback):
|
|
||||||
return Alert(
|
|
||||||
title: Text("Message routing fallback"),
|
|
||||||
message: Text(proxyFallbackInfo(proxyFallback)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."),
|
|
||||||
primaryButton: .default(Text("Ok")) {
|
|
||||||
netCfg.smpProxyFallback = proxyFallback
|
|
||||||
saveNetCfg()
|
|
||||||
},
|
|
||||||
secondaryButton: .cancel() {
|
|
||||||
resetNetCfgView()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
case let .error(err):
|
|
||||||
return Alert(
|
|
||||||
title: Text("Error updating settings"),
|
|
||||||
message: Text(err)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveNetCfg() {
|
|
||||||
do {
|
|
||||||
let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults
|
|
||||||
netCfg.tcpConnectTimeout = def.tcpConnectTimeout
|
|
||||||
netCfg.tcpTimeout = def.tcpTimeout
|
|
||||||
try setNetworkConfig(netCfg)
|
|
||||||
currentNetCfg = netCfg
|
|
||||||
setNetCfg(netCfg)
|
|
||||||
} catch let error {
|
|
||||||
let err = responseError(error)
|
|
||||||
resetNetCfgView()
|
|
||||||
alert = .error(err: err)
|
|
||||||
logger.error("\(err)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetNetCfgView() {
|
|
||||||
netCfg = currentNetCfg
|
|
||||||
onionHosts = OnionHosts(netCfg: netCfg)
|
|
||||||
sessionMode = netCfg.sessionMode
|
|
||||||
proxyMode = netCfg.smpProxyMode
|
|
||||||
proxyFallback = netCfg.smpProxyFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey {
|
|
||||||
switch hosts {
|
|
||||||
case .no: return "Onion hosts will not be used."
|
|
||||||
case .prefer: return "Onion hosts will be used when available. Requires enabling VPN."
|
|
||||||
case .require: return "Onion hosts will be required for connection. Requires enabling VPN."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey {
|
|
||||||
switch mode {
|
|
||||||
case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**."
|
|
||||||
case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey {
|
|
||||||
switch mode {
|
|
||||||
case .always: return "Always use private routing."
|
|
||||||
case .unknown: return "Use private routing with unknown servers."
|
|
||||||
case .unprotected: return "Use private routing with unknown servers when IP address is not protected."
|
|
||||||
case .never: return "Do NOT use private routing."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey {
|
|
||||||
switch proxyFallback {
|
|
||||||
case .allow: return "Send messages directly when your or destination server does not support private routing."
|
|
||||||
case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing."
|
|
||||||
case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct PrivacySettings: View {
|
|||||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||||
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
|
@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 contactReceipts = false
|
||||||
@State private var contactReceiptsReset = false
|
@State private var contactReceiptsReset = false
|
||||||
@State private var contactReceiptsOverrides = 0
|
@State private var contactReceiptsOverrides = 0
|
||||||
@@ -69,6 +70,9 @@ struct PrivacySettings: View {
|
|||||||
Section {
|
Section {
|
||||||
settingsRow("network", color: theme.colors.secondary) {
|
settingsRow("network", color: theme.colors.secondary) {
|
||||||
Toggle("Send link previews", isOn: $useLinkPreviews)
|
Toggle("Send link previews", isOn: $useLinkPreviews)
|
||||||
|
.onChange(of: useLinkPreviews) { linkPreviews in
|
||||||
|
privacyLinkPreviewsGroupDefault.set(linkPreviews)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
settingsRow("message", color: theme.colors.secondary) {
|
settingsRow("message", color: theme.colors.secondary) {
|
||||||
Toggle("Show last messages", isOn: $showChatPreviews)
|
Toggle("Show last messages", isOn: $showChatPreviews)
|
||||||
@@ -113,6 +117,22 @@ struct PrivacySettings: View {
|
|||||||
privacyAcceptImagesGroupDefault.set($0)
|
privacyAcceptImagesGroupDefault.set($0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
settingsRow("circle.filled.pattern.diagonalline.rectangle", 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) {
|
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
|
||||||
Toggle("Protect IP address", isOn: $askToApproveRelays)
|
Toggle("Protect IP address", isOn: $askToApproveRelays)
|
||||||
}
|
}
|
||||||
@@ -348,6 +368,7 @@ struct SimplexLockView: View {
|
|||||||
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
@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)
|
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||||
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
|
@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 performLAToggleReset = false
|
||||||
@State private var performLAModeReset = false
|
@State private var performLAModeReset = false
|
||||||
@State private var performLASelfDestructReset = false
|
@State private var performLASelfDestructReset = false
|
||||||
@@ -419,6 +440,12 @@ struct SimplexLockView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if performLA {
|
||||||
|
Section("Share to SimpleX") {
|
||||||
|
Toggle("Allow sharing", isOn: $allowShareExtension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if performLA && laMode == .passcode {
|
if performLA && laMode == .passcode {
|
||||||
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
|
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
|
||||||
Toggle(isOn: $selfDestruct) {
|
Toggle(isOn: $selfDestruct) {
|
||||||
@@ -443,6 +470,7 @@ struct SimplexLockView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: performLA) { performLAToggle in
|
.onChange(of: performLA) { performLAToggle in
|
||||||
|
appLocalAuthEnabledGroupDefault.set(performLAToggle)
|
||||||
prefLANoticeShown = true
|
prefLANoticeShown = true
|
||||||
if performLAToggleReset {
|
if performLAToggleReset {
|
||||||
performLAToggleReset = false
|
performLAToggleReset = false
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ struct ProtocolServerView: View {
|
|||||||
let serverProtocol: ServerProtocol
|
let serverProtocol: ServerProtocol
|
||||||
@Binding var server: ServerCfg
|
@Binding var server: ServerCfg
|
||||||
@State var serverToEdit: ServerCfg
|
@State var serverToEdit: ServerCfg
|
||||||
@State var serverEnabled: Bool
|
|
||||||
@State private var showTestFailure = false
|
@State private var showTestFailure = false
|
||||||
@State private var testing = false
|
@State private var testing = false
|
||||||
@State private var testFailure: ProtocolTestFailure?
|
@State private var testFailure: ProtocolTestFailure?
|
||||||
@@ -113,10 +112,10 @@ struct ProtocolServerView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
showTestStatus(server: serverToEdit)
|
showTestStatus(server: serverToEdit)
|
||||||
}
|
}
|
||||||
Toggle("Use for new connections", isOn: $serverEnabled)
|
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
|
||||||
.onChange(of: serverEnabled) { enabled in
|
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||||
serverToEdit.enabled = enabled ? .enabled : .disabled
|
.disabled(useForNewDisabled)
|
||||||
}
|
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,17 +175,12 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func serverHostname(_ srv: String) -> String {
|
|
||||||
parseServerAddress(srv)?.hostnames.first ?? srv
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProtocolServerView_Previews: PreviewProvider {
|
struct ProtocolServerView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ProtocolServerView(
|
ProtocolServerView(
|
||||||
serverProtocol: .smp,
|
serverProtocol: .smp,
|
||||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||||
serverToEdit: ServerCfg.sampleData.custom,
|
serverToEdit: ServerCfg.sampleData.custom
|
||||||
serverEnabled: true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ struct ProtocolServersView: View {
|
|||||||
@Environment(\.editMode) private var editMode
|
@Environment(\.editMode) private var editMode
|
||||||
let serverProtocol: ServerProtocol
|
let serverProtocol: ServerProtocol
|
||||||
@State private var currServers: [ServerCfg] = []
|
@State private var currServers: [ServerCfg] = []
|
||||||
@State private var presetServers: [String] = []
|
@State private var presetServers: [ServerCfg] = []
|
||||||
@State private var servers: [ServerCfg] = []
|
@State private var configuredServers: [ServerCfg] = []
|
||||||
|
@State private var otherServers: [ServerCfg] = []
|
||||||
@State private var selectedServer: String? = nil
|
@State private var selectedServer: String? = nil
|
||||||
@State private var showAddServer = false
|
@State private var showAddServer = false
|
||||||
@State private var showScanProtoServer = false
|
@State private var showScanProtoServer = false
|
||||||
@@ -53,31 +54,53 @@ struct ProtocolServersView: View {
|
|||||||
|
|
||||||
private func protocolServersView() -> some View {
|
private func protocolServersView() -> some View {
|
||||||
List {
|
List {
|
||||||
Section {
|
if !configuredServers.isEmpty {
|
||||||
ForEach($servers) { srv in
|
Section {
|
||||||
protocolServerView(srv)
|
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)
|
||||||
}
|
}
|
||||||
.onMove { indexSet, offset in
|
}
|
||||||
servers.move(fromOffsets: indexSet, toOffset: offset)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
.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 {
|
Section {
|
||||||
Button("Reset") { servers = currServers }
|
Button("Add server") {
|
||||||
.disabled(servers == currServers || testing)
|
showAddServer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Reset") { partitionServers(currServers) }
|
||||||
|
.disabled(Set(allServers) == Set(currServers) || testing)
|
||||||
Button("Test servers", action: testServers)
|
Button("Test servers", action: testServers)
|
||||||
.disabled(testing || allServersDisabled)
|
.disabled(testing || allServersDisabled)
|
||||||
Button("Save servers", action: saveServers)
|
Button("Save servers", action: saveServers)
|
||||||
@@ -86,17 +109,17 @@ struct ProtocolServersView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar { EditButton() }
|
.toolbar { EditButton() }
|
||||||
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
|
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||||
Button("Enter server manually") {
|
Button("Enter server manually") {
|
||||||
servers.append(ServerCfg.empty)
|
otherServers.append(ServerCfg.empty)
|
||||||
selectedServer = servers.last?.id
|
selectedServer = allServers.last?.id
|
||||||
}
|
}
|
||||||
Button("Scan server QR code") { showScanProtoServer = true }
|
Button("Scan server QR code") { showScanProtoServer = true }
|
||||||
Button("Add preset servers", action: addAllPresets)
|
Button("Add preset servers", action: addAllPresets)
|
||||||
.disabled(hasAllPresets())
|
.disabled(hasAllPresets())
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showScanProtoServer) {
|
.sheet(isPresented: $showScanProtoServer) {
|
||||||
ScanProtocolServer(servers: $servers)
|
ScanProtocolServer(servers: $otherServers)
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
}
|
}
|
||||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||||
@@ -107,7 +130,7 @@ struct ProtocolServersView: View {
|
|||||||
showSaveDialog = true
|
showSaveDialog = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.confirmationDialog("Save servers?", isPresented: $showSaveDialog) {
|
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
saveServers()
|
saveServers()
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -133,27 +156,39 @@ struct ProtocolServersView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// this condition is needed to prevent re-setting the servers when exiting single server view
|
// this condition is needed to prevent re-setting the servers when exiting single server view
|
||||||
if !justOpened { return }
|
if justOpened {
|
||||||
do {
|
do {
|
||||||
let r = try getUserProtoServers(serverProtocol)
|
let r = try getUserProtoServers(serverProtocol)
|
||||||
currServers = r.protoServers
|
currServers = r.protoServers
|
||||||
presetServers = r.presetServers
|
presetServers = r.presetServers
|
||||||
servers = currServers
|
partitionServers(currServers)
|
||||||
} catch let error {
|
} catch let error {
|
||||||
alert = .error(
|
alert = .error(
|
||||||
title: "Error loading \(proto) servers",
|
title: "Error loading \(proto) servers",
|
||||||
error: "Error: \(responseError(error))"
|
error: "Error: \(responseError(error))"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
justOpened = false
|
||||||
|
} else {
|
||||||
|
partitionServers(allServers)
|
||||||
}
|
}
|
||||||
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 {
|
private var saveDisabled: Bool {
|
||||||
servers.isEmpty ||
|
allServers.isEmpty ||
|
||||||
servers == currServers ||
|
Set(allServers) == Set(currServers) ||
|
||||||
testing ||
|
testing ||
|
||||||
!servers.allSatisfy { srv in
|
!allServers.allSatisfy { srv in
|
||||||
if let address = parseServerAddress(srv.server) {
|
if let address = parseServerAddress(srv.server) {
|
||||||
return uniqueAddress(srv, address)
|
return uniqueAddress(srv, address)
|
||||||
}
|
}
|
||||||
@@ -163,7 +198,7 @@ struct ProtocolServersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var allServersDisabled: Bool {
|
private var allServersDisabled: Bool {
|
||||||
servers.allSatisfy { $0.enabled != .enabled }
|
allServers.allSatisfy { !$0.enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
||||||
@@ -172,8 +207,7 @@ struct ProtocolServersView: View {
|
|||||||
ProtocolServerView(
|
ProtocolServerView(
|
||||||
serverProtocol: serverProtocol,
|
serverProtocol: serverProtocol,
|
||||||
server: server,
|
server: server,
|
||||||
serverToEdit: srv,
|
serverToEdit: srv
|
||||||
serverEnabled: srv.enabled == .enabled
|
|
||||||
)
|
)
|
||||||
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
||||||
.modifier(ThemedBackground(grouped: true))
|
.modifier(ThemedBackground(grouped: true))
|
||||||
@@ -187,7 +221,7 @@ struct ProtocolServersView: View {
|
|||||||
invalidServer()
|
invalidServer()
|
||||||
} else if !uniqueAddress(srv, address) {
|
} else if !uniqueAddress(srv, address) {
|
||||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||||
} else if srv.enabled != .enabled {
|
} else if !srv.enabled {
|
||||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||||
} else {
|
} else {
|
||||||
showTestStatus(server: srv)
|
showTestStatus(server: srv)
|
||||||
@@ -200,7 +234,7 @@ struct ProtocolServersView: View {
|
|||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
|
|
||||||
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
||||||
if srv.enabled == .enabled {
|
if srv.enabled {
|
||||||
v
|
v
|
||||||
} else {
|
} else {
|
||||||
v.foregroundColor(theme.colors.secondary)
|
v.foregroundColor(theme.colors.secondary)
|
||||||
@@ -227,7 +261,7 @@ struct ProtocolServersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
|
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
|
||||||
servers.allSatisfy { srv in
|
allServers.allSatisfy { srv in
|
||||||
address.hostnames.allSatisfy { host in
|
address.hostnames.allSatisfy { host in
|
||||||
srv.id == s.id || !srv.server.contains(host)
|
srv.id == s.id || !srv.server.contains(host)
|
||||||
}
|
}
|
||||||
@@ -241,13 +275,13 @@ struct ProtocolServersView: View {
|
|||||||
private func addAllPresets() {
|
private func addAllPresets() {
|
||||||
for srv in presetServers {
|
for srv in presetServers {
|
||||||
if !hasPreset(srv) {
|
if !hasPreset(srv) {
|
||||||
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: .enabled))
|
configuredServers.append(srv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hasPreset(_ srv: String) -> Bool {
|
private func hasPreset(_ srv: ServerCfg) -> Bool {
|
||||||
servers.contains(where: { $0.server == srv })
|
allServers.contains(where: { $0.server == srv.server })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func testServers() {
|
private func testServers() {
|
||||||
@@ -265,19 +299,31 @@ struct ProtocolServersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resetTestStatus() {
|
private func resetTestStatus() {
|
||||||
for i in 0..<servers.count {
|
for i in 0..<configuredServers.count {
|
||||||
if servers[i].enabled == .enabled {
|
if configuredServers[i].enabled {
|
||||||
servers[i].tested = nil
|
configuredServers[i].tested = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in 0..<otherServers.count {
|
||||||
|
if otherServers[i].enabled {
|
||||||
|
otherServers[i].tested = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||||
var fs: [String: ProtocolTestFailure] = [:]
|
var fs: [String: ProtocolTestFailure] = [:]
|
||||||
for i in 0..<servers.count {
|
for i in 0..<configuredServers.count {
|
||||||
if servers[i].enabled == .enabled {
|
if configuredServers[i].enabled {
|
||||||
if let f = await testServerConnection(server: $servers[i]) {
|
if let f = await testServerConnection(server: $configuredServers[i]) {
|
||||||
fs[serverHostname(servers[i].server)] = f
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,9 +333,9 @@ struct ProtocolServersView: View {
|
|||||||
func saveServers() {
|
func saveServers() {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await setUserProtoServers(serverProtocol, servers: servers)
|
try await setUserProtoServers(serverProtocol, servers: allServers)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
currServers = servers
|
currServers = allServers
|
||||||
editMode?.wrappedValue = .inactive
|
editMode?.wrappedValue = .inactive
|
||||||
}
|
}
|
||||||
} catch let error {
|
} catch let error {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct ScanProtocolServer: View {
|
|||||||
switch resp {
|
switch resp {
|
||||||
case let .success(r):
|
case let .success(r):
|
||||||
if parseServerAddress(r.string) != nil {
|
if parseServerAddress(r.string) != nil {
|
||||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: .enabled))
|
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
showAddressError = true
|
showAddressError = true
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
|
|||||||
|
|
||||||
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
|
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
|
||||||
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
|
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
|
||||||
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
|
let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group
|
||||||
let DEFAULT_LA_MODE = "localAuthenticationMode"
|
let DEFAULT_LA_MODE = "localAuthenticationMode"
|
||||||
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
|
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
|
||||||
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
|
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
|
||||||
@@ -28,12 +28,13 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
|
|||||||
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
|
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
|
||||||
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
|
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_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
|
||||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
|
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
|
||||||
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
|
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
|
||||||
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
||||||
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
||||||
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
||||||
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
||||||
|
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
|
||||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||||
@@ -46,6 +47,8 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
|
|||||||
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
|
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
|
||||||
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
|
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
|
||||||
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
||||||
|
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||||
|
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||||
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||||
@@ -60,6 +63,8 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
|
|||||||
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
|
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
|
||||||
|
let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
|
||||||
|
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
|
||||||
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
|
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
|
||||||
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
|
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
|
||||||
|
|
||||||
@@ -87,11 +92,14 @@ let appDefaults: [String: Any] = [
|
|||||||
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
|
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
|
||||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||||
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
||||||
|
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
|
||||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||||
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
||||||
DEFAULT_DEVELOPER_TOOLS: false,
|
DEFAULT_DEVELOPER_TOOLS: false,
|
||||||
DEFAULT_ENCRYPTION_STARTED: false,
|
DEFAULT_ENCRYPTION_STARTED: false,
|
||||||
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
|
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
|
||||||
|
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||||
|
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
|
||||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
||||||
@@ -102,6 +110,8 @@ let appDefaults: [String: Any] = [
|
|||||||
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
||||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
|
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
|
||||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
|
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
|
||||||
|
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true,
|
||||||
|
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
|
||||||
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
|
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
|
||||||
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
|
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
|
||||||
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
|
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
|
||||||
@@ -112,6 +122,18 @@ let appDefaults: [String: Any] = [
|
|||||||
DEFAULT_CURRENT_THEME_IDS: "{}"
|
DEFAULT_CURRENT_THEME_IDS: "{}"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// only Bool defaults can be used here,
|
||||||
|
// or hintDefaultsUnchanged and resetHintDefaults need to be changed
|
||||||
|
let hintDefaults = [
|
||||||
|
DEFAULT_LA_NOTICE_SHOWN,
|
||||||
|
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||||
|
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
|
||||||
|
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
|
||||||
|
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
|
||||||
|
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE,
|
||||||
|
DEFAULT_SHOW_DELETE_CONTACT_NOTICE
|
||||||
|
]
|
||||||
|
|
||||||
// not used anymore
|
// not used anymore
|
||||||
enum ConnectViaLinkTab: String {
|
enum ConnectViaLinkTab: String {
|
||||||
case scan
|
case scan
|
||||||
@@ -156,6 +178,9 @@ let onboardingStageDefault = EnumDefault<OnboardingStage>(defaults: UserDefaults
|
|||||||
|
|
||||||
let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME)
|
let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME)
|
||||||
|
|
||||||
|
let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE)
|
||||||
|
let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE)
|
||||||
|
|
||||||
let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME)
|
let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME)
|
||||||
let systemDarkThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SYSTEM_DARK_THEME, withDefault: DefaultTheme.DARK.themeName)
|
let systemDarkThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SYSTEM_DARK_THEME, withDefault: DefaultTheme.DARK.themeName)
|
||||||
let currentThemeIdsDefault = CodableDefault<[String: String]>(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] )
|
let currentThemeIdsDefault = CodableDefault<[String: String]>(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] )
|
||||||
@@ -163,6 +188,9 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def
|
|||||||
|
|
||||||
func setGroupDefaults() {
|
func setGroupDefaults() {
|
||||||
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
|
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
|
||||||
|
appLocalAuthEnabledGroupDefault.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 {
|
public class StringDefault {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct UserAddressView: View {
|
|||||||
case deleteAddress
|
case deleteAddress
|
||||||
case profileAddress(on: Bool)
|
case profileAddress(on: Bool)
|
||||||
case shareOnCreate
|
case shareOnCreate
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -185,7 +185,7 @@ struct UserAddressView: View {
|
|||||||
}, secondaryButton: .cancel()
|
}, secondaryButton: .cancel()
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct UserProfilesView: View {
|
|||||||
case hiddenProfilesNotice
|
case hiddenProfilesNotice
|
||||||
case muteProfileAlert
|
case muteProfileAlert
|
||||||
case activateUserError(error: String)
|
case activateUserError(error: String)
|
||||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -172,7 +172,7 @@ struct UserProfilesView: View {
|
|||||||
message: Text(err)
|
message: Text(err)
|
||||||
)
|
)
|
||||||
case let .error(title, error):
|
case let .error(title, error):
|
||||||
return Alert(title: Text(title), message: Text(error))
|
return mkAlert(title: title, message: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="!1 colored!" xml:space="preserve" approved="no">
|
<trans-unit id="!1 colored!" xml:space="preserve" approved="no">
|
||||||
<source>!1 colored!</source>
|
<source>!1 colored!</source>
|
||||||
<target state="translated">! 1 ملون!</target>
|
<target state="translated">! 1 مُلوَّن!</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="#secret#" xml:space="preserve" approved="no">
|
<trans-unit id="#secret#" xml:space="preserve" approved="no">
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%@ is not verified" xml:space="preserve" approved="no">
|
<trans-unit id="%@ is not verified" xml:space="preserve" approved="no">
|
||||||
<source>%@ is not verified</source>
|
<source>%@ is not verified</source>
|
||||||
<target state="translated">%@ لم يتم التحقق منها</target>
|
<target state="translated">%@ لم يتم التحقق منه</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%@ is verified" xml:space="preserve" approved="no">
|
<trans-unit id="%@ is verified" xml:space="preserve" approved="no">
|
||||||
@@ -107,8 +107,9 @@
|
|||||||
<target state="translated">%d ثانية</target>
|
<target state="translated">%d ثانية</target>
|
||||||
<note>message ttl</note>
|
<note>message ttl</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
<trans-unit id="%d skipped message(s)" xml:space="preserve" approved="no">
|
||||||
<source>%d skipped message(s)</source>
|
<source>%d skipped message(s)</source>
|
||||||
|
<target state="translated">%d الرسائل المتخطية</target>
|
||||||
<note>integrity error chat item</note>
|
<note>integrity error chat item</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lld" xml:space="preserve" approved="no">
|
<trans-unit id="%lld" xml:space="preserve" approved="no">
|
||||||
@@ -121,12 +122,14 @@
|
|||||||
<target state="needs-translation">%lld %@</target>
|
<target state="needs-translation">%lld %@</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve">
|
<trans-unit id="%lld contact(s) selected" xml:space="preserve" approved="no">
|
||||||
<source>%lld contact(s) selected</source>
|
<source>%lld contact(s) selected</source>
|
||||||
|
<target state="translated">%lld تم اختيار جهات الاتصال</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve">
|
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve" approved="no">
|
||||||
<source>%lld file(s) with total size of %@</source>
|
<source>%lld file(s) with total size of %@</source>
|
||||||
|
<target state="translated">%lld الملفات ذات الحجم الإجمالي %@</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lld members" xml:space="preserve" approved="no">
|
<trans-unit id="%lld members" xml:space="preserve" approved="no">
|
||||||
@@ -134,8 +137,9 @@
|
|||||||
<target state="translated">%lld أعضاء</target>
|
<target state="translated">%lld أعضاء</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lld second(s)" xml:space="preserve">
|
<trans-unit id="%lld second(s)" xml:space="preserve" approved="no">
|
||||||
<source>%lld second(s)</source>
|
<source>%lld second(s)</source>
|
||||||
|
<target state="translated">%lld ثوانى</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%lldd" xml:space="preserve" approved="no">
|
<trans-unit id="%lldd" xml:space="preserve" approved="no">
|
||||||
@@ -185,7 +189,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve" approved="no">
|
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve" approved="no">
|
||||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||||
<target state="translated">** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بك.</target>
|
<target state="translated">** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بكم.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
||||||
@@ -195,12 +199,12 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve" approved="no">
|
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve" approved="no">
|
||||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||||
<target state="translated">** المزيد من الخصوصية **: تحقق من الرسائل الجديدة كل 20 دقيقة. تتم مشاركة رمز الجهاز مع خادم SimpleX Chat ، ولكن ليس عدد جهات الاتصال أو الرسائل لديك.</target>
|
<target state="translated">** المزيد من الخصوصية **: تحققوا من الرسائل الجديدة كل 20 دقيقة. تتم مشاركة رمز الجهاز مع خادم SimpleX Chat ، ولكن ليس عدد جهات الاتصال أو الرسائل لديكم.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve" approved="no">
|
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve" approved="no">
|
||||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||||
<target state="translated">** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامك للتطبيق).</target>
|
<target state="translated">** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامكم للتطبيق).</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Paste received link** or open it in the browser and tap **Open in mobile app**." xml:space="preserve" approved="no">
|
<trans-unit id="**Paste received link** or open it in the browser and tap **Open in mobile app**." xml:space="preserve" approved="no">
|
||||||
@@ -210,7 +214,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Please note**: you will NOT be able to recover or change passphrase if you lose it." xml:space="preserve" approved="no">
|
<trans-unit id="**Please note**: you will NOT be able to recover or change passphrase if you lose it." xml:space="preserve" approved="no">
|
||||||
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
|
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
|
||||||
<target state="translated">** يرجى ملاحظة **: لن تتمكن من استعادة أو تغيير عبارة المرور إذا فقدتها.</target>
|
<target state="translated">** يرجى ملاحظة **: لن تتمكنوا من استعادة أو تغيير عبارة المرور إذا فقدتموها.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve" approved="no">
|
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||||
@@ -305,7 +309,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="A separate TCP connection will be used **for each chat profile you have in the app**." xml:space="preserve" approved="no">
|
<trans-unit id="A separate TCP connection will be used **for each chat profile you have in the app**." xml:space="preserve" approved="no">
|
||||||
<source>A separate TCP connection will be used **for each chat profile you have in the app**.</source>
|
<source>A separate TCP connection will be used **for each chat profile you have in the app**.</source>
|
||||||
<target state="translated">سيتم استخدام اتصال TCP منفصل ** لكل ملف تعريف دردشة لديك في التطبيق **.</target>
|
<target state="translated">سيتم استخدام اتصال TCP منفصل ** لكل ملف تعريف دردشة لديكم في التطبيق **.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." xml:space="preserve" approved="no">
|
<trans-unit id="A separate TCP connection will be used **for each contact and group member**. **Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." xml:space="preserve" approved="no">
|
||||||
@@ -355,24 +359,29 @@
|
|||||||
<source>Accept requests</source>
|
<source>Accept requests</source>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add preset servers" xml:space="preserve">
|
<trans-unit id="Add preset servers" xml:space="preserve" approved="no">
|
||||||
<source>Add preset servers</source>
|
<source>Add preset servers</source>
|
||||||
|
<target state="translated">إضافة خوادم محددة مسبقا</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add profile" xml:space="preserve">
|
<trans-unit id="Add profile" xml:space="preserve" approved="no">
|
||||||
<source>Add profile</source>
|
<source>Add profile</source>
|
||||||
|
<target state="translated">إضافة الملف الشخصي</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve">
|
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve" approved="no">
|
||||||
<source>Add servers by scanning QR codes.</source>
|
<source>Add servers by scanning QR codes.</source>
|
||||||
|
<target state="translated">إضافة خوادم عن طريق مسح رموز QR.</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add server…" xml:space="preserve">
|
<trans-unit id="Add server" xml:space="preserve" approved="no">
|
||||||
<source>Add server…</source>
|
<source>Add server</source>
|
||||||
|
<target state="translated">أضف الخادم</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add to another device" xml:space="preserve">
|
<trans-unit id="Add to another device" xml:space="preserve" approved="no">
|
||||||
<source>Add to another device</source>
|
<source>Add to another device</source>
|
||||||
|
<target state="translated">أضف إلى جهاز آخر</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
||||||
@@ -3667,7 +3676,7 @@ SimpleX servers cannot see your profile.</source>
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
|
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
|
||||||
<source>## In reply to</source>
|
<source>## In reply to</source>
|
||||||
<target state="translated">## ردًا على</target>
|
<target state="translated">## ردًّا على</target>
|
||||||
<note>copied message info</note>
|
<note>copied message info</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
|
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
|
||||||
@@ -3675,6 +3684,208 @@ SimpleX servers cannot see your profile.</source>
|
|||||||
<target state="translated">%@ و %@ متصل</target>
|
<target state="translated">%@ و %@ متصل</target>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="%@ downloaded" xml:space="preserve" approved="no">
|
||||||
|
<source>%@ downloaded</source>
|
||||||
|
<target state="translated">%@ تم التنزيل</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%@ and %@" xml:space="preserve" approved="no">
|
||||||
|
<source>%@ and %@</source>
|
||||||
|
<target state="translated">%@ و %@</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%@ connected" xml:space="preserve" approved="no">
|
||||||
|
<source>%@ connected</source>
|
||||||
|
<target state="translated">%@ متصل</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld minutes" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld minutes</source>
|
||||||
|
<target state="translated">%lld دقائق</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%@, %@ and %lld members" xml:space="preserve" approved="no">
|
||||||
|
<source>%@, %@ and %lld members</source>
|
||||||
|
<target state="translated">%@, %@ و %lld أعضاء</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%d weeks" xml:space="preserve" approved="no">
|
||||||
|
<source>%d weeks</source>
|
||||||
|
<target state="translated">%d أسابيع</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%@ uploaded" xml:space="preserve" approved="no">
|
||||||
|
<source>%@ uploaded</source>
|
||||||
|
<target state="translated">%@ تم الرفع</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%@, %@ and %lld other members connected" xml:space="preserve" approved="no">
|
||||||
|
<source>%@, %@ and %lld other members connected</source>
|
||||||
|
<target state="translated">%@, %@ و %lld أعضاء آخرين متصلين</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld seconds" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld seconds</source>
|
||||||
|
<target state="translated">%lld ثواني</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%u messages failed to decrypt." xml:space="preserve" approved="no">
|
||||||
|
<source>%u messages failed to decrypt.</source>
|
||||||
|
<target state="translated">%u فشلت عملية فك تشفير الرسائل.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld messages marked deleted" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld messages marked deleted</source>
|
||||||
|
<target state="translated">%lld الرسائل معلمه بالحذف</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld messages moderated by %@" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld messages moderated by %@</source>
|
||||||
|
<target state="translated">%lld رسائل تمت إدارتها بواسطة %@</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld new interface languages" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld new interface languages</source>
|
||||||
|
<target state="translated">%lld لغات واجهة جديدة</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld group events" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld group events</source>
|
||||||
|
<target state="translated">%lld أحداث المجموعة</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld messages blocked by admin" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld messages blocked by admin</source>
|
||||||
|
<target state="translated">%lld رسائل محظورة بواسطه المسؤول</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%lld messages blocked" xml:space="preserve" approved="no">
|
||||||
|
<source>%lld messages blocked</source>
|
||||||
|
<target state="translated">%lld رسائل تم حظرها</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="%u messages skipped." xml:space="preserve" approved="no">
|
||||||
|
<source>%u messages skipped.</source>
|
||||||
|
<target state="translated">%u تم تخطي الرسائل.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="**Add contact**: to create a new invitation link, or connect via a link you received." xml:space="preserve" approved="no">
|
||||||
|
<source>**Add contact**: to create a new invitation link, or connect via a link you received.</source>
|
||||||
|
<target state="translated">**إضافة جهة اتصال**: لإنشاء رابط دعوة جديد، أو الاتصال عبر الرابط الذي تلقيتوهم.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="**Create group**: to create a new group." xml:space="preserve" approved="no">
|
||||||
|
<source>**Create group**: to create a new group.</source>
|
||||||
|
<target state="translated">**إنشاء مجموعة**: لإنشاء مجموعة جديدة.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="(this device v%@)" xml:space="preserve" approved="no">
|
||||||
|
<source>(this device v%@)</source>
|
||||||
|
<target state="translated">(هذا الجهاز v%@)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="(new)" xml:space="preserve" approved="no">
|
||||||
|
<source>(new)</source>
|
||||||
|
<target state="translated">(جديد)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection." xml:space="preserve" approved="no">
|
||||||
|
<source>**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection.</source>
|
||||||
|
<target state="translated">**يرجى الملاحظة**: سيؤدي استخدام نفس قاعدة البيانات على جهازين إلى كسر فك تشفير الرسائل من اتصالاتكم كحماية أمنية.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="A new random profile will be shared." xml:space="preserve" approved="no">
|
||||||
|
<source>A new random profile will be shared.</source>
|
||||||
|
<target state="translated">سيتم مشاركة ملف تعريفي عشوائي جديد.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="30 seconds" xml:space="preserve" approved="no">
|
||||||
|
<source>30 seconds</source>
|
||||||
|
<target state="translated">30 ثانيه</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="- more stable message delivery. - a bit better groups. - and more!" xml:space="preserve" approved="no">
|
||||||
|
<source>- more stable message delivery.
|
||||||
|
- a bit better groups.
|
||||||
|
- and more!</source>
|
||||||
|
<target state="translated">- تسليم رسائل أكثر استقرارًا.
|
||||||
|
- مجموعات أفضل قليلاً.
|
||||||
|
- والمزيد!</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="0 sec" xml:space="preserve" approved="no">
|
||||||
|
<source>0 sec</source>
|
||||||
|
<target state="translated">0 ثانيه</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="1 minute" xml:space="preserve" approved="no">
|
||||||
|
<source>1 minute</source>
|
||||||
|
<target state="translated">1 دقيقة</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5 minutes" xml:space="preserve" approved="no">
|
||||||
|
<source>5 minutes</source>
|
||||||
|
<target state="translated">5 دقائق</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="<p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p>" xml:space="preserve" approved="no">
|
||||||
|
<source><p>Hi!</p>
|
||||||
|
<p><a href="%@">Connect to me via SimpleX Chat</a></p></source>
|
||||||
|
<target state="translated"><p>مرحبا!</p>
|
||||||
|
<p><a href="%@">أتصل بى من خلال SimpleX Chat</a></p></target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="0s" xml:space="preserve" approved="no">
|
||||||
|
<source>0s</source>
|
||||||
|
<target state="translated">0 ث</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="A few more things" xml:space="preserve" approved="no">
|
||||||
|
<source>A few more things</source>
|
||||||
|
<target state="translated">بعض الأشياء الأخرى</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable." xml:space="preserve" approved="no">
|
||||||
|
<source>- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!
|
||||||
|
- delivery receipts (up to 20 members).
|
||||||
|
- faster and more stable.</source>
|
||||||
|
<target state="translated">- أتصل بـ [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!
|
||||||
|
- delivery receipts (up to 20 members).
|
||||||
|
- أسرع و أكثر اسْتِقْرارًا.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="**Warning**: the archive will be removed." xml:space="preserve" approved="no">
|
||||||
|
<source>**Warning**: the archive will be removed.</source>
|
||||||
|
<target state="translated">**تحذير**: سيتم إزالة الأرشيف.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="- optionally notify deleted contacts. - profile names with spaces. - and more!" xml:space="preserve" approved="no">
|
||||||
|
<source>- optionally notify deleted contacts.
|
||||||
|
- profile names with spaces.
|
||||||
|
- and more!</source>
|
||||||
|
<target state="translated">- إخطار جهات الاتصال المحذوفة بشكل اختياري.
|
||||||
|
- أسماء الملفات الشخصية مع المسافات.
|
||||||
|
- والمزيد!</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="- voice messages up to 5 minutes. - custom time to disappear. - editing history." xml:space="preserve" approved="no">
|
||||||
|
<source>- voice messages up to 5 minutes.
|
||||||
|
- custom time to disappear.
|
||||||
|
- editing history.</source>
|
||||||
|
<target state="translated">- رسائل صوتية تصل مدتها إلى 5 دقائق.
|
||||||
|
- وقت مخصص للاختفاء.
|
||||||
|
- تعديل السجل.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Add welcome message" xml:space="preserve" approved="no">
|
||||||
|
<source>Add welcome message</source>
|
||||||
|
<target state="translated">إضافة رسالة ترحيب</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Abort changing address?" xml:space="preserve" approved="no">
|
||||||
|
<source>Abort changing address?</source>
|
||||||
|
<target state="translated">هل تريد إلغاء تغيير العنوان؟</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Add contact" xml:space="preserve" approved="no">
|
||||||
|
<source>Add contact</source>
|
||||||
|
<target state="translated">إضافة جهة اتصال</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Abort" xml:space="preserve" approved="no">
|
||||||
|
<source>Abort</source>
|
||||||
|
<target state="translated">إحباط</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="About SimpleX address" xml:space="preserve" approved="no">
|
||||||
|
<source>About SimpleX address</source>
|
||||||
|
<target state="translated">حول عنوان SimpleX</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Accept connection request?" xml:space="preserve" approved="no">
|
||||||
|
<source>Accept connection request?</source>
|
||||||
|
<target state="translated">قبول طلب الاتصال؟</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Acknowledged" xml:space="preserve" approved="no">
|
||||||
|
<source>Acknowledged</source>
|
||||||
|
<target state="translated">معترف به</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Acknowledgement errors" xml:space="preserve" approved="no">
|
||||||
|
<source>Acknowledgement errors</source>
|
||||||
|
<target state="translated">أخطاء الإقرار</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." xml:space="preserve" approved="no">
|
||||||
|
<source>Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts.</source>
|
||||||
|
<target state="translated">أضف عنوانًا إلى ملفكم الشخصي، حتى تتمكن جهات الاتصال الخاصة بكم من مشاركته مع أشخاص اخرين. سيتم إرسال تحديث الملف الشخصي إلى جهات الاتصال الخاصة بكم.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Abort changing address" xml:space="preserve" approved="no">
|
||||||
|
<source>Abort changing address</source>
|
||||||
|
<target state="translated">إحباط تغيير العنوان</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="Active connections" xml:space="preserve" approved="no">
|
||||||
|
<source>Active connections</source>
|
||||||
|
<target state="translated">اتصالات نشطة</target>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ar" datatype="plaintext">
|
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ar" datatype="plaintext">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+3
@@ -1,6 +1,9 @@
|
|||||||
/* Bundle display name */
|
/* Bundle display name */
|
||||||
"CFBundleDisplayName" = "SimpleX NSE";
|
"CFBundleDisplayName" = "SimpleX NSE";
|
||||||
|
|
||||||
/* Bundle name */
|
/* Bundle name */
|
||||||
"CFBundleName" = "SimpleX NSE";
|
"CFBundleName" = "SimpleX NSE";
|
||||||
|
|
||||||
/* Copyright (human-readable) */
|
/* Copyright (human-readable) */
|
||||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||||
|
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
Localizable.strings
|
||||||
|
SimpleX
|
||||||
|
|
||||||
|
Created by EP on 30/07/2024.
|
||||||
|
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
*/
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
InfoPlist.strings
|
||||||
|
SimpleX
|
||||||
|
|
||||||
|
Created by EP on 30/07/2024.
|
||||||
|
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
*/
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
Localizable.strings
|
||||||
|
SimpleX
|
||||||
|
|
||||||
|
Created by EP on 30/07/2024.
|
||||||
|
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||||
|
*/
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
"project" : "SimpleX.xcodeproj",
|
"project" : "SimpleX.xcodeproj",
|
||||||
"targetLocale" : "bg",
|
"targetLocale" : "bg",
|
||||||
"toolInfo" : {
|
"toolInfo" : {
|
||||||
"toolBuildNumber" : "15A240d",
|
"toolBuildNumber" : "15F31d",
|
||||||
"toolID" : "com.apple.dt.xcode",
|
"toolID" : "com.apple.dt.xcode",
|
||||||
"toolName" : "Xcode",
|
"toolName" : "Xcode",
|
||||||
"toolVersion" : "15.0"
|
"toolVersion" : "15.4"
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
}
|
}
|
||||||
@@ -386,8 +386,8 @@
|
|||||||
<source>Add servers by scanning QR codes.</source>
|
<source>Add servers by scanning QR codes.</source>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add server…" xml:space="preserve">
|
<trans-unit id="Add server" xml:space="preserve">
|
||||||
<source>Add server…</source>
|
<source>Add server</source>
|
||||||
<note>No comment provided by engineer.</note>
|
<note>No comment provided by engineer.</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="Add to another device" xml:space="preserve">
|
<trans-unit id="Add to another device" xml:space="preserve">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+3
@@ -1,6 +1,9 @@
|
|||||||
/* Bundle display name */
|
/* Bundle display name */
|
||||||
"CFBundleDisplayName" = "SimpleX NSE";
|
"CFBundleDisplayName" = "SimpleX NSE";
|
||||||
|
|
||||||
/* Bundle name */
|
/* Bundle name */
|
||||||
"CFBundleName" = "SimpleX NSE";
|
"CFBundleName" = "SimpleX NSE";
|
||||||
|
|
||||||
/* Copyright (human-readable) */
|
/* Copyright (human-readable) */
|
||||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||||
|
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
Localizable.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
Reference in New Issue
Block a user