mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387faa0c27 | |||
| aa990da17c | |||
| a48c82f4a1 |
@@ -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).
|
||||
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||
5. ⚡️ [Contribute](#contribute) and [support us with donations](#please-support-us-with-your-donations).
|
||||
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||
|
||||
[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.
|
||||
- developing features - please connect to us via chat so we can help you get started.
|
||||
|
||||
## Please support us with your donations
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
@@ -233,8 +233,6 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
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)
|
||||
|
||||
[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,12 +15,31 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
|
||||
application.registerForRemoteNotifications()
|
||||
if #available(iOS 17.0, *) { trackKeyboard() }
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
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() {
|
||||
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct ContentView: View {
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 40
|
||||
private let callTopPadding: CGFloat = 50
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
@@ -151,12 +151,12 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
|
||||
reactOnDarkThemeChanges()
|
||||
}
|
||||
.onChange(of: colorScheme) { scheme in
|
||||
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
|
||||
// so when a user changes his global theme from dark to light or back, the app will adapt to it
|
||||
reactOnDarkThemeChanges(scheme == .dark)
|
||||
reactOnDarkThemeChanges()
|
||||
}
|
||||
.onChange(of: theme.name) { _ in
|
||||
ThemeManager.adjustWindowStyle()
|
||||
@@ -207,7 +207,7 @@ struct ContentView: View {
|
||||
CallDuration(call: call)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(height: callTopPadding)
|
||||
.frame(height: callTopPadding - 10)
|
||||
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
||||
.onTapGesture {
|
||||
chatModel.activeCallViewIsCollapsed = false
|
||||
|
||||
@@ -43,86 +43,6 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
items.append(item)
|
||||
}
|
||||
|
||||
class ItemsModel: ObservableObject {
|
||||
static let shared = ItemsModel()
|
||||
private let publisher = ObservableObjectPublisher()
|
||||
private var bag = Set<AnyCancellable>()
|
||||
var reversedChatItems: [ChatItem] = [] {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
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 {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@@ -143,15 +63,17 @@ final class ChatModel: ObservableObject {
|
||||
@Published var contentViewAccessAuthenticated: Bool = false
|
||||
@Published var laRequest: LocalAuthRequest?
|
||||
// list of chat "previews"
|
||||
@Published private(set) var chats: [Chat] = []
|
||||
@Published var chats: [Chat] = []
|
||||
@Published var deletedChats: Set<String> = []
|
||||
// map of connections network statuses, key is agent connection id
|
||||
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||
@Published var membersLoaded = false
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@@ -183,6 +105,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
|
||||
@Published var draft: ComposeState?
|
||||
@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 networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
|
||||
@@ -192,8 +116,6 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
static let shared = ChatModel()
|
||||
|
||||
let im = ItemsModel.shared
|
||||
|
||||
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
|
||||
|
||||
let ntfEnableLocal = true
|
||||
@@ -273,33 +195,14 @@ final class ChatModel: ObservableObject {
|
||||
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? {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func addChat(_ chat: Chat) {
|
||||
if chatId == nil {
|
||||
withAnimation { addChat_(chat, at: 0) }
|
||||
} else {
|
||||
addChat_(chat, at: 0)
|
||||
func addChat(_ chat: Chat, at position: Int = 0) {
|
||||
withAnimation {
|
||||
chats.insert(chat, at: position)
|
||||
}
|
||||
popChatCollector.throttlePopChat(chat.chatInfo.id, currentPosition: 0)
|
||||
}
|
||||
|
||||
func addChat_(_ chat: Chat, at position: Int = 0) {
|
||||
chats.insert(chat, at: position)
|
||||
}
|
||||
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
@@ -357,10 +260,26 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateChats(_ newChats: [ChatData]) {
|
||||
chats = newChats.map { Chat($0) }
|
||||
func updateChats(with newChats: [ChatData]) {
|
||||
for i in 0..<newChats.count {
|
||||
let c = newChats[i]
|
||||
if let j = getChatIndex(c.id) {
|
||||
let chat = chats[j]
|
||||
chat.chatInfo = c.chatInfo
|
||||
chat.chatItems = c.chatItems
|
||||
chat.chatStats = c.chatStats
|
||||
if i != j {
|
||||
if chatId != c.chatInfo.id {
|
||||
popChat_(j, to: i)
|
||||
} else if i == 0 {
|
||||
chatToTop = c.chatInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addChat(Chat(c), at: i)
|
||||
}
|
||||
}
|
||||
NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers())
|
||||
popChatCollector.clear()
|
||||
}
|
||||
|
||||
// func addGroup(_ group: SimpleXChat.Group) {
|
||||
@@ -368,12 +287,6 @@ final class ChatModel: ObservableObject {
|
||||
// }
|
||||
|
||||
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
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = switch cInfo {
|
||||
@@ -391,9 +304,18 @@ final class ChatModel: ObservableObject {
|
||||
[cItem]
|
||||
}
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: 1)
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 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 {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
}
|
||||
@@ -408,7 +330,7 @@ final class ChatModel: ObservableObject {
|
||||
var res: Bool
|
||||
if let chat = getChat(cInfo.id) {
|
||||
if let pItem = chat.chatItems.last {
|
||||
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
||||
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
|
||||
chat.chatItems = [cItem]
|
||||
}
|
||||
} else {
|
||||
@@ -419,9 +341,6 @@ final class ChatModel: ObservableObject {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
res = true
|
||||
}
|
||||
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
// update current chat
|
||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||
}
|
||||
@@ -438,8 +357,7 @@ final class ChatModel: ObservableObject {
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
im.itemAdded = true
|
||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -463,17 +381,17 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
im.reversedChatItems[i] = cItem
|
||||
im.reversedChatItems[i].viewTimestamp = .now
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
}
|
||||
|
||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
decreaseUnreadCounter(cInfo)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
@@ -485,24 +403,23 @@ final class ChatModel: ObservableObject {
|
||||
if chatId == cInfo.id {
|
||||
if let i = getChatItemIndex(cItem) {
|
||||
_ = withAnimation {
|
||||
im.reversedChatItems.remove(at: i)
|
||||
self.reversedChatItems.remove(at: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
|
||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
|
||||
if previous {
|
||||
while i < im.reversedChatItems.count - 1 {
|
||||
while i < reversedChatItems.count - 1 {
|
||||
i += 1
|
||||
if let res = map(im.reversedChatItems[i]) { return res }
|
||||
if let res = map(reversedChatItems[i]) { return res }
|
||||
}
|
||||
} else {
|
||||
while i > 0 {
|
||||
i -= 1
|
||||
if let res = map(im.reversedChatItems[i]) { return res }
|
||||
if let res = map(reversedChatItems[i]) { return res }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -533,8 +450,7 @@ final class ChatModel: ObservableObject {
|
||||
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
|
||||
let cItem = ChatItem.liveDummy(chatInfo.chatType)
|
||||
withAnimation {
|
||||
im.reversedChatItems.insert(cItem, at: 0)
|
||||
im.itemAdded = true
|
||||
reversedChatItems.insert(cItem, at: 0)
|
||||
}
|
||||
return cItem
|
||||
}
|
||||
@@ -542,15 +458,15 @@ final class ChatModel: ObservableObject {
|
||||
func removeLiveDummy(animated: Bool = true) {
|
||||
if hasLiveDummy {
|
||||
if animated {
|
||||
withAnimation { _ = im.reversedChatItems.removeFirst() }
|
||||
withAnimation { _ = reversedChatItems.removeFirst() }
|
||||
} else {
|
||||
_ = im.reversedChatItems.removeFirst()
|
||||
_ = reversedChatItems.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasLiveDummy: Bool {
|
||||
im.reversedChatItems.first?.isLiveDummy == true
|
||||
reversedChatItems.first?.isLiveDummy == true
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
@@ -567,7 +483,7 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
private func markCurrentChatRead(fromIndex i: Int = 0) {
|
||||
var j = i
|
||||
while j < im.reversedChatItems.count {
|
||||
while j < reversedChatItems.count {
|
||||
markChatItemRead_(j)
|
||||
j += 1
|
||||
}
|
||||
@@ -581,7 +497,7 @@ final class ChatModel: ObservableObject {
|
||||
var unreadBelow = 0
|
||||
var j = i - 1
|
||||
while j >= 0 {
|
||||
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
|
||||
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
|
||||
unreadBelow += 1
|
||||
}
|
||||
j -= 1
|
||||
@@ -616,146 +532,53 @@ final class ChatModel: ObservableObject {
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
im.reversedChatItems = []
|
||||
reversedChatItems = []
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
if chatId == cInfo.id,
|
||||
let itemIndex = getChatItemIndex(cItem),
|
||||
im.reversedChatItems[itemIndex].isRcvNew {
|
||||
await MainActor.run {
|
||||
withTransaction(Transaction()) {
|
||||
// update current chat
|
||||
markChatItemRead_(itemIndex)
|
||||
// update preview
|
||||
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
|
||||
}
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
// update current chat
|
||||
markChatItemRead_(i)
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let unreadCollector = UnreadCollector()
|
||||
|
||||
class UnreadCollector {
|
||||
private let subject = PassthroughSubject<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) {
|
||||
let meta = im.reversedChatItems[i].meta
|
||||
let meta = reversedChatItems[i].meta
|
||||
if case .rcvNew = meta.itemStatus {
|
||||
im.reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
im.reversedChatItems[i].viewTimestamp = .now
|
||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
|
||||
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||
changeUnreadCounter(user: currentUser!, by: count)
|
||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
decreaseUnreadCounter(user: currentUser!)
|
||||
}
|
||||
}
|
||||
|
||||
func increaseUnreadCounter(user: any UserLike) {
|
||||
changeUnreadCounter(user: user, by: 1)
|
||||
NtfManager.shared.incNtfBadgeCount()
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(user: any UserLike, by: Int = 1) {
|
||||
changeUnreadCounter(user: user, by: -by)
|
||||
NtfManager.shared.decNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
private func changeUnreadCounter(user: any UserLike, by: Int) {
|
||||
if let i = users.firstIndex(where: { $0.user.userId == user.userId }) {
|
||||
users[i].unreadCount += by
|
||||
}
|
||||
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
func totalUnreadCountForAllUsers() -> Int {
|
||||
@@ -769,8 +592,8 @@ final class ChatModel: ObservableObject {
|
||||
var ns: [String] = []
|
||||
if let ciCategory = chatItem.mergeCategory,
|
||||
var i = getChatItemIndex(chatItem) {
|
||||
while i < im.reversedChatItems.count {
|
||||
let ci = im.reversedChatItems[i]
|
||||
while i < reversedChatItems.count {
|
||||
let ci = reversedChatItems[i]
|
||||
if ci.mergeCategory != ciCategory { break }
|
||||
if let m = ci.memberConnected {
|
||||
ns.append(m.displayName)
|
||||
@@ -785,7 +608,7 @@ final class ChatModel: ObservableObject {
|
||||
// returns the index of the passed item and the next item (it has smaller index)
|
||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
|
||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
||||
} else {
|
||||
(nil, nil)
|
||||
}
|
||||
@@ -795,10 +618,10 @@ final class ChatModel: ObservableObject {
|
||||
// and the previous visible item with another merge category
|
||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||
guard var i = ciIndex else { return (nil, nil) }
|
||||
let fst = im.reversedChatItems.count - 1
|
||||
let fst = reversedChatItems.count - 1
|
||||
while i < fst {
|
||||
i = i + 1
|
||||
let ci = im.reversedChatItems[i]
|
||||
let ci = reversedChatItems[i]
|
||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||
return (i - 1, ci)
|
||||
}
|
||||
@@ -811,7 +634,7 @@ final class ChatModel: ObservableObject {
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
for i in range {
|
||||
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
|
||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
@@ -821,7 +644,6 @@ final class ChatModel: ObservableObject {
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
// no animation here, for it not to look like it just moved when leaving the chat
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
@@ -887,29 +709,37 @@ final class ChatModel: ObservableObject {
|
||||
var i = 0
|
||||
var totalBelow = 0
|
||||
var unreadBelow = 0
|
||||
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
|
||||
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
|
||||
totalBelow += 1
|
||||
if im.reversedChatItems[i].isRcvNew {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
unreadBelow += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return UnreadChatItemCounts(
|
||||
// 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
|
||||
)
|
||||
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
|
||||
}
|
||||
|
||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||
let maxIx = im.reversedChatItems.count - 1
|
||||
let maxIx = reversedChatItems.count - 1
|
||||
var i = 0
|
||||
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
|
||||
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
|
||||
while i < maxIx && !inView(i) { i += 1 }
|
||||
while i < maxIx && inView(i) { i += 1 }
|
||||
return im.reversedChatItems[min(i - 1, maxIx)]
|
||||
return reversedChatItems[min(i - 1, maxIx)]
|
||||
}
|
||||
|
||||
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,16 +755,14 @@ struct NTFContactRequest {
|
||||
|
||||
struct UnreadChatItemCounts: Equatable {
|
||||
var isNearBottom: Bool
|
||||
var isReallyNearBottom: Bool
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
@Published var chatStats: ChatStats
|
||||
var created = Date.now
|
||||
fileprivate var popTs: Date?
|
||||
|
||||
init(_ cData: ChatData) {
|
||||
self.chatInfo = cData.chatInfo
|
||||
@@ -981,6 +809,24 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
|
||||
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
|
||||
|
||||
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
|
||||
if case let .group(groupInfo) = self.chatInfo {
|
||||
let p = groupInfo.fullGroupPreferences
|
||||
return switch feature {
|
||||
case .timedMessages: p.timedMessages.on
|
||||
case .directMessages: p.directMessages.on(for: groupInfo.membership)
|
||||
case .fullDelete: p.fullDelete.on
|
||||
case .reactions: p.reactions.on
|
||||
case .voice: p.voice.on(for: groupInfo.membership)
|
||||
case .files: p.files.on(for: groupInfo.membership)
|
||||
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
|
||||
case .history: p.history.on
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,18 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SwiftyGif
|
||||
import LinkPresentation
|
||||
|
||||
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
||||
func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
||||
if let file = file, file.loaded {
|
||||
return file.fileSource
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
do {
|
||||
@@ -38,7 +37,7 @@ public func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
||||
return nil
|
||||
}
|
||||
|
||||
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
||||
func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
||||
if let cfArgs = cfArgs {
|
||||
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
|
||||
} else {
|
||||
@@ -46,7 +45,7 @@ public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
||||
}
|
||||
}
|
||||
|
||||
public func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
if let fileSource = getLoadedFileSource(file) {
|
||||
let filePath = getAppFilePath(fileSource.filePath)
|
||||
if FileManager.default.fileExists(atPath: filePath.path) {
|
||||
@@ -56,13 +55,13 @@ public func getLoadedVideo(_ file: CIFile?) -> URL? {
|
||||
return nil
|
||||
}
|
||||
|
||||
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
||||
func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
||||
let fileName = generateNewFileName("IMG", "gif")
|
||||
guard let imageData = image.imageData else { return nil }
|
||||
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
}
|
||||
|
||||
public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
||||
func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
||||
let hasAlpha = imageHasAlpha(uiImage)
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
|
||||
@@ -72,7 +71,7 @@ public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
||||
return nil
|
||||
}
|
||||
|
||||
public func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
let size = image.size
|
||||
let side = min(size.width, size.height)
|
||||
let newSize = CGSize(width: side, height: side)
|
||||
@@ -85,7 +84,7 @@ public func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
|
||||
}
|
||||
|
||||
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
|
||||
var img = image
|
||||
var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
@@ -100,7 +99,7 @@ public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha
|
||||
return data
|
||||
}
|
||||
|
||||
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
var img = image
|
||||
let hasAlpha = imageHasAlpha(image)
|
||||
var str = compressImageStr(img, hasAlpha: hasAlpha)
|
||||
@@ -116,7 +115,7 @@ public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String
|
||||
return str
|
||||
}
|
||||
|
||||
public func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
|
||||
func compressImageStr(_ image: UIImage, _ compressionQuality: CGFloat = 0.85, hasAlpha: Bool) -> String? {
|
||||
let ext = hasAlpha ? "png" : "jpg"
|
||||
if let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: compressionQuality) {
|
||||
return "data:image/\(ext);base64,\(data.base64EncodedString())"
|
||||
@@ -139,7 +138,7 @@ private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, ha
|
||||
}
|
||||
}
|
||||
|
||||
public func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
if let cgImage = img.cgImage {
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
||||
@@ -159,35 +158,7 @@ public func imageHasAlpha(_ img: UIImage) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Reduces image size, while consuming less RAM
|
||||
///
|
||||
/// Used by ShareExtension to downsize large images
|
||||
/// before passing them to regular image processing pipeline
|
||||
/// to avoid exceeding 120MB memory
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: Location of the image data
|
||||
/// - size: Maximum dimension (width or height)
|
||||
/// - Returns: Downsampled image or `nil`, if the image can't be located
|
||||
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
|
||||
autoreleasepool {
|
||||
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
|
||||
CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
[
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
|
||||
] as CFDictionary
|
||||
)
|
||||
.map { UIImage(cgImage: $0) }
|
||||
} else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
let savedFile: CryptoFile?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
@@ -213,7 +184,7 @@ public func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
return savedFile
|
||||
}
|
||||
|
||||
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
do {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
@@ -226,6 +197,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
||||
savedFile = CryptoFile.plain(fileName)
|
||||
}
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return savedFile
|
||||
} catch {
|
||||
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
||||
@@ -233,7 +205,7 @@ public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
||||
}
|
||||
}
|
||||
|
||||
public func saveWallpaperFile(url: URL) -> String? {
|
||||
func saveWallpaperFile(url: URL) -> String? {
|
||||
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
|
||||
do {
|
||||
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
|
||||
@@ -244,7 +216,7 @@ public func saveWallpaperFile(url: URL) -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
public func saveWallpaperFile(image: UIImage) -> String? {
|
||||
func saveWallpaperFile(image: UIImage) -> String? {
|
||||
let hasAlpha = imageHasAlpha(image)
|
||||
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
|
||||
let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha)
|
||||
@@ -257,7 +229,7 @@ public func saveWallpaperFile(image: UIImage) -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
public func removeWallpaperFile(fileName: String? = nil) {
|
||||
func removeWallpaperFile(fileName: String? = nil) {
|
||||
do {
|
||||
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
|
||||
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
|
||||
@@ -270,7 +242,7 @@ public func removeWallpaperFile(fileName: String? = nil) {
|
||||
}
|
||||
}
|
||||
|
||||
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||
func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
||||
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
||||
}
|
||||
|
||||
@@ -302,7 +274,7 @@ private func getTimestamp() -> String {
|
||||
return df.string(from: Date())
|
||||
}
|
||||
|
||||
public func dropImagePrefix(_ s: String) -> String {
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
||||
}
|
||||
|
||||
@@ -310,23 +282,8 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
||||
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
||||
}
|
||||
|
||||
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
||||
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
||||
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
||||
s.outputURL = outputUrl
|
||||
s.outputFileType = .mp4
|
||||
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
await s.export()
|
||||
if let err = s.error {
|
||||
logger.error("Failed to export video with error: \(err)")
|
||||
}
|
||||
return s.status == .completed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
extension AVAsset {
|
||||
public func generatePreview() -> (UIImage, Int)? {
|
||||
func generatePreview() -> (UIImage, Int)? {
|
||||
let generator = AVAssetImageGenerator(asset: self)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
var actualTime = CMTimeMake(value: 0, timescale: 0)
|
||||
@@ -338,7 +295,7 @@ extension AVAsset {
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
||||
func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
||||
if let cgImage = cgImage {
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
||||
@@ -383,75 +340,4 @@ extension UIImage {
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
public convenience init?(base64Encoded: String?) {
|
||||
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) {
|
||||
self.init(data: data)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
||||
logger.debug("getLinkMetadata: fetching URL preview")
|
||||
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
||||
if let e = error {
|
||||
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
||||
}
|
||||
if let metadata = metadata,
|
||||
let imageProvider = metadata.imageProvider,
|
||||
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
||||
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
||||
var linkPreview: LinkPreview? = nil
|
||||
if let error = error {
|
||||
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
||||
} else {
|
||||
if let image = object as? UIImage,
|
||||
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
|
||||
let title = metadata.title,
|
||||
let uri = metadata.originalURL {
|
||||
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
|
||||
}
|
||||
}
|
||||
cb(linkPreview)
|
||||
}
|
||||
} else {
|
||||
logger.error("Could not load link preview image")
|
||||
cb(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func getLinkPreview(for url: URL) async -> LinkPreview? {
|
||||
await withCheckedContinuation { cont in
|
||||
getLinkPreview(url: url) { cont.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private let squareToCircleRatio = 0.935
|
||||
|
||||
private let radiusFactor = (1 - squareToCircleRatio) / 50
|
||||
|
||||
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
|
||||
if radius >= 50 {
|
||||
blurredFrame(img, size, blurred).clipShape(Circle())
|
||||
} else if radius <= 0 {
|
||||
let sz = size * squareToCircleRatio
|
||||
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
|
||||
} else {
|
||||
let sz = size * (squareToCircleRatio + radius * radiusFactor)
|
||||
blurredFrame(img, sz, blurred)
|
||||
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
|
||||
.padding((size - sz) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
|
||||
let v = img.resizable().frame(width: size, height: size)
|
||||
if blurred {
|
||||
v.blur(radius: size / 4)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||
}
|
||||
} else {
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
}
|
||||
handler()
|
||||
}
|
||||
@@ -240,8 +238,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
ntfBadgeCountGroupDefault.set(count)
|
||||
}
|
||||
|
||||
func changeNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||
func decNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count))
|
||||
}
|
||||
|
||||
func incNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count)
|
||||
}
|
||||
|
||||
private func addNotification(_ content: UNMutableNotificationContent) {
|
||||
|
||||
@@ -112,9 +112,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
return resp
|
||||
}
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil, log: Bool = true) async -> ChatResponse {
|
||||
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse {
|
||||
await withCheckedContinuation { cont in
|
||||
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl, log: log))
|
||||
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ func apiGetActiveUser(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, pastTimestamp: pastTimestamp), ctrl)
|
||||
func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl)
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
||||
}
|
||||
|
||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -225,15 +225,6 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCheckChatRunning() throws -> Bool {
|
||||
let r = chatSendCmdSync(.checkChatRunning)
|
||||
switch r {
|
||||
case .chatRunning: return true
|
||||
case .chatStopped: return false
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
|
||||
func apiStopChat() async throws {
|
||||
let r = await chatSendCmd(.apiStopChat)
|
||||
switch r {
|
||||
@@ -279,10 +270,8 @@ func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
let r = await chatSendCmd(.apiExportArchive(config: config))
|
||||
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
|
||||
throw r
|
||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
}
|
||||
|
||||
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
|
||||
@@ -320,8 +309,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
|
||||
let loadItemsPerPage = 50
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
@@ -332,20 +321,15 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
func loadChat(chat: Chat, search: String = "") {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
if clearItems {
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
}
|
||||
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)
|
||||
}
|
||||
m.reversedChatItems = []
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
m.reversedChatItems = chat.chatItems.reversed()
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
@@ -413,7 +397,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("send message error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error sending message",
|
||||
message: "Error: \(responseError(r))"
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -421,7 +405,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error creating message",
|
||||
message: "Error: \(responseError(r))"
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,15 +421,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
|
||||
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
|
||||
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -705,7 +689,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
return ((.contact, connection), nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
let alert = contactAlreadyExistsAlert(contact)
|
||||
return (nil, alert)
|
||||
@@ -715,7 +699,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||
)
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
||||
let alert = mkAlert(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
|
||||
@@ -748,7 +732,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
} else {
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Error: \(responseError(r))"
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -765,38 +749,22 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
|
||||
return (nil, alert)
|
||||
}
|
||||
|
||||
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
|
||||
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
|
||||
let chatId = type.rawValue + id.description
|
||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
|
||||
if case .direct = type, case .contactDeleted = r { return }
|
||||
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||
if case .group = type, case .groupDeletedUser = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
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 {
|
||||
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
|
||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
||||
} catch let error {
|
||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -806,39 +774,6 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
||||
@@ -963,7 +898,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
||||
let am = AlertManager.shared
|
||||
|
||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
|
||||
am.showAlertMsg(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Sender may have deleted the connection request."
|
||||
@@ -974,7 +909,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error accepting contact request",
|
||||
message: "Error: \(responseError(r))"
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -1000,7 +935,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
||||
return (fileTransferMeta, nil)
|
||||
} else {
|
||||
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
||||
return (nil, responseError(r))
|
||||
return (nil, String(describing: r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,7 +945,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
|
||||
return (rcvFileTransfer, nil)
|
||||
} else {
|
||||
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
||||
return (nil, responseError(r))
|
||||
return (nil, String(describing: r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,7 +1025,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(responseError(r))"
|
||||
message: "Error: \(String(describing: r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1157,9 +1092,18 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
if let alert = getNetworkErrorAlert(r) {
|
||||
return mkAlert(title: alert.title, message: alert.message)
|
||||
} else {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return mkAlert(
|
||||
title: "Connection timeout",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1167,17 +1111,7 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1218,18 +1152,12 @@ func apiEndCall(_ contact: Contact) async throws {
|
||||
try await sendCommandOkResp(.apiEndCall(contact: contact))
|
||||
}
|
||||
|
||||
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
|
||||
func apiGetCallInvitations() throws -> [RcvCallInvitation] {
|
||||
let r = chatSendCmdSync(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
|
||||
let r = await chatSendCmd(.apiGetCallInvitations)
|
||||
if case let .callInvitations(invs) = r { return invs }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
|
||||
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
|
||||
@@ -1279,7 +1207,7 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
} catch {
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
@@ -1320,7 +1248,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
||||
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
||||
switch r {
|
||||
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
|
||||
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
|
||||
default: throw r
|
||||
}
|
||||
@@ -1426,14 +1354,6 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
|
||||
let userId = try currentUserId("getAgentSubsTotal")
|
||||
let r = await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
|
||||
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
|
||||
logger.error("getAgentSubsTotal error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||
let userId = try currentUserId("getAgentServersSummary")
|
||||
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||
@@ -1517,16 +1437,15 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
let chatRunning = try apiCheckChatRunning()
|
||||
let justStarted = try apiStartChat()
|
||||
m.users = try listUsers()
|
||||
if !chatRunning {
|
||||
if justStarted {
|
||||
try getUserChatData()
|
||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||
if (refreshInvitations) {
|
||||
Task { try await refreshCallInvitations() }
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
_ = try apiStartChat()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
@@ -1597,7 +1516,7 @@ func getUserChatData() throws {
|
||||
m.userAddress = try apiGetUserAddress()
|
||||
m.chatItemTTL = try getChatItemTTL()
|
||||
let chats = try apiGetChats()
|
||||
m.updateChats(chats)
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
|
||||
private func getUserChatDataAsync() async throws {
|
||||
@@ -1609,12 +1528,12 @@ private func getUserChatDataAsync() async throws {
|
||||
await MainActor.run {
|
||||
m.userAddress = userAddress
|
||||
m.chatItemTTL = chatItemTTL
|
||||
m.updateChats(chats)
|
||||
m.chats = chats.map { Chat.init($0) }
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
m.userAddress = nil
|
||||
m.updateChats([])
|
||||
m.chats = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1664,7 +1583,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await TerminalItems.shared.add(.resp(.now, res))
|
||||
}
|
||||
let m = ChatModel.shared
|
||||
let n = NetworkModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
@@ -1687,7 +1605,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
NtfManager.shared.notifyContactConnected(user, contact)
|
||||
}
|
||||
await MainActor.run {
|
||||
n.setContactNetworkStatus(contact, .connected)
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .contactConnecting(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
@@ -1699,19 +1617,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .contactSndReady(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
if let conn = contact.activeConn {
|
||||
m.dismissConnReqView(conn.id)
|
||||
m.removeChat(conn.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
n.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
@@ -1744,7 +1649,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
await MainActor.run {
|
||||
if m.chatId == mergedContact.id {
|
||||
ItemsModel.shared.loadOpenChat(mergedContact.id)
|
||||
m.chatId = intoContact.id
|
||||
}
|
||||
m.removeChat(mergedContact.id)
|
||||
}
|
||||
@@ -1752,27 +1657,27 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .networkStatus(status, connections):
|
||||
// dispatch queue to synchronize access
|
||||
networkStatusesLock.sync {
|
||||
var ns = n.networkStatuses
|
||||
var ns = m.networkStatuses
|
||||
// slow loop is on the background thread
|
||||
for cId in connections {
|
||||
ns[cId] = status
|
||||
}
|
||||
// fast model update is on the main thread
|
||||
DispatchQueue.main.sync {
|
||||
n.networkStatuses = ns
|
||||
m.networkStatuses = ns
|
||||
}
|
||||
}
|
||||
case let .networkStatuses(_, statuses): ()
|
||||
// dispatch queue to synchronize access
|
||||
networkStatusesLock.sync {
|
||||
var ns = n.networkStatuses
|
||||
var ns = m.networkStatuses
|
||||
// slow loop is on the background thread
|
||||
for s in statuses {
|
||||
ns[s.agentConnId] = s.networkStatus
|
||||
}
|
||||
// fast model update is on the main thread
|
||||
DispatchQueue.main.sync {
|
||||
n.networkStatuses = ns
|
||||
m.networkStatuses = ns
|
||||
}
|
||||
}
|
||||
case let .newChatItem(user, aChatItem):
|
||||
@@ -1815,25 +1720,21 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
|
||||
}
|
||||
}
|
||||
case let .chatItemsDeleted(user, items, _):
|
||||
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
|
||||
if !active(user) {
|
||||
for item in items {
|
||||
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
|
||||
await MainActor.run {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
}
|
||||
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
|
||||
await MainActor.run {
|
||||
m.decreaseUnreadCounter(user: user)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
for item in items {
|
||||
if let toChatItem = item.toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
|
||||
}
|
||||
if let toChatItem = toChatItem {
|
||||
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
|
||||
} else {
|
||||
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
|
||||
}
|
||||
}
|
||||
case let .receivedGroupInvitation(user, groupInfo, _, _):
|
||||
@@ -1913,7 +1814,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
if let contact = memberContact {
|
||||
await MainActor.run {
|
||||
n.setContactNetworkStatus(contact, .connected)
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
}
|
||||
case let .groupUpdated(user, toGroup):
|
||||
@@ -2094,8 +1995,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.remoteCtrlSession = nil
|
||||
dismissAllSheets() {
|
||||
switch rcStopReason {
|
||||
case .disconnected:
|
||||
()
|
||||
case .connectionFailed(.errorAgent(.RCP(.identity))):
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Connection with desktop stopped",
|
||||
@@ -2138,13 +2037,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
|
||||
func switchToLocalSession() {
|
||||
let m = ChatModel.shared
|
||||
let n = NetworkModel.shared
|
||||
m.remoteCtrlSession = nil
|
||||
do {
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) }
|
||||
n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
m.networkStatuses = Dictionary(uniqueKeysWithValues: statuses)
|
||||
} catch let error {
|
||||
logger.debug("error updating chat data: \(responseError(error))")
|
||||
}
|
||||
@@ -2167,30 +2065,23 @@ func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCallInvitations() async throws {
|
||||
func refreshCallInvitations() throws {
|
||||
let m = ChatModel.shared
|
||||
let callInvitations = try await apiGetCallInvitations()
|
||||
await MainActor.run {
|
||||
m.callInvitations = callsByChat(callInvitations)
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
let callInvitations = try justRefreshCallInvitations()
|
||||
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
|
||||
let invitation = m.callInvitations.removeValue(forKey: chatId) {
|
||||
m.ntfCallInvitationAction = nil
|
||||
CallController.shared.callAction(invitation: invitation, action: ntfAction)
|
||||
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
|
||||
activateCall(invitation)
|
||||
}
|
||||
}
|
||||
|
||||
func justRefreshCallInvitations() throws {
|
||||
let callInvitations = try apiGetCallInvitationsSync()
|
||||
ChatModel.shared.callInvitations = callsByChat(callInvitations)
|
||||
}
|
||||
|
||||
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
|
||||
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
|
||||
result, inv in result[inv.contact.id] = inv
|
||||
}
|
||||
func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
|
||||
let m = ChatModel.shared
|
||||
let callInvitations = try apiGetCallInvitations()
|
||||
m.callInvitations = callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) { result, inv in result[inv.contact.id] = inv }
|
||||
return callInvitations
|
||||
}
|
||||
|
||||
func activateCall(_ callInvitation: RcvCallInvitation) {
|
||||
|
||||
@@ -36,18 +36,6 @@ private func _suspendChat(timeout: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
let seSubscriber = seMessageSubscriber {
|
||||
switch $0 {
|
||||
case let .state(state):
|
||||
switch state {
|
||||
case .inactive:
|
||||
if AppChatState.shared.value.inactive { activateChat() }
|
||||
case .sendingMessage:
|
||||
if AppChatState.shared.value.canSuspend { suspendChat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func suspendChat() {
|
||||
suspendLockQueue.sync {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
|
||||
@@ -58,7 +58,6 @@ struct SimpleXApp: App {
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||
switch (phase) {
|
||||
case .background:
|
||||
// --- authentication
|
||||
@@ -83,11 +82,9 @@ struct SimpleXApp: App {
|
||||
if appState != .stopped {
|
||||
startChatAndActivate {
|
||||
if appState.inactive && chatModel.chatRunning == true {
|
||||
Task {
|
||||
await updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
await updateCallInvitations()
|
||||
}
|
||||
updateChats()
|
||||
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
|
||||
updateCallInvitations()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,16 +129,16 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChats() async {
|
||||
private func updateChats() {
|
||||
do {
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
let chats = try apiGetChats()
|
||||
chatModel.updateChats(with: chats)
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
Task { await loadChat(chat: chat, clearItems: false) }
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
await MainActor.run { chatModel.ntfContactRequest = nil }
|
||||
chatModel.ntfContactRequest = nil
|
||||
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
|
||||
Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) }
|
||||
}
|
||||
@@ -151,9 +148,9 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCallInvitations() async {
|
||||
private func updateCallInvitations() {
|
||||
do {
|
||||
try await refreshCallInvitations()
|
||||
try refreshCallInvitations()
|
||||
} catch let error {
|
||||
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ var systemInDarkThemeCurrently: Bool {
|
||||
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||
}
|
||||
|
||||
func reactOnDarkThemeChanges(_ inDarkNow: Bool) {
|
||||
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == inDarkNow {
|
||||
func reactOnDarkThemeChanges() {
|
||||
if currentThemeDefault.get() == DefaultTheme.SYSTEM_THEME_NAME && CurrentColors.colors.isLight == systemInDarkThemeCurrently {
|
||||
// Change active colors from light to dark and back based on system theme
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
}
|
||||
@@ -102,7 +102,8 @@ extension ThemeWallpaper {
|
||||
public func importFromString() -> ThemeWallpaper {
|
||||
if preset == nil, let image {
|
||||
// Need to save image from string and to save its path
|
||||
if let parsed = UIImage(base64Encoded: image),
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let parsed = UIImage(data: data),
|
||||
let filename = saveWallpaperFile(image: parsed) {
|
||||
var copy = self
|
||||
copy.image = nil
|
||||
|
||||
@@ -53,7 +53,7 @@ class ThemeManager {
|
||||
return perUserTheme
|
||||
}
|
||||
let defaultTheme = defaultActiveTheme(appSettingsTheme)
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper)
|
||||
}
|
||||
|
||||
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||
|
||||
@@ -186,7 +186,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
logger.debug("CallController: started chat")
|
||||
self.shouldSuspendChat = true
|
||||
// There are no invitations in the model, as it was processed by NSE
|
||||
try? justRefreshCallInvitations()
|
||||
_ = try? justRefreshCallInvitations()
|
||||
logger.debug("CallController: updated call invitations chat")
|
||||
// logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))")
|
||||
// Extract the call information from the push notification payload
|
||||
|
||||
@@ -411,15 +411,6 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
|
||||
func endCall() {
|
||||
if #available(iOS 16.0, *) {
|
||||
_endCall()
|
||||
} else {
|
||||
// Fixes `connection.close()` getting locked up in iOS15
|
||||
DispatchQueue.global(qos: .utility).async { self._endCall() }
|
||||
}
|
||||
}
|
||||
|
||||
private func _endCall() {
|
||||
guard let call = activeCall.wrappedValue else { return }
|
||||
logger.debug("WebRTCClient: ending the call")
|
||||
activeCall.wrappedValue = nil
|
||||
|
||||
@@ -28,17 +28,15 @@ struct ChatInfoToolbar: View {
|
||||
color: Color(uiColor: .tertiaryLabel)
|
||||
)
|
||||
.padding(.trailing, 4)
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||
VStack(spacing: 0) {
|
||||
v
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
.padding(.top, -2)
|
||||
}
|
||||
VStack {
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.frame(width: 220)
|
||||
|
||||
@@ -92,23 +92,19 @@ struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var networkModel = NetworkModel.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@State var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
@Binding var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
var onSearch: () -> Void
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var customUserProfile: Profile? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
@@ -116,8 +112,7 @@ struct ChatInfoView: View {
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case queueInfo(info: String)
|
||||
case someAlert(alert: SomeAlert)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -127,12 +122,11 @@ struct ChatInfoView: View {
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .queueInfo(info): return "queueInfo \(info)"
|
||||
case let .someAlert(alert): return "chatInfoSomeAlert \(alert.id)"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
@@ -142,29 +136,12 @@ struct ChatInfoView: View {
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
|
||||
Group {
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.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 {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
@@ -176,38 +153,32 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
Group {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
@@ -224,7 +195,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
@@ -253,12 +224,12 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
@@ -287,24 +258,6 @@ struct ChatInfoView: View {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsContacts
|
||||
}
|
||||
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
|
||||
switch(alertItem) {
|
||||
@@ -314,26 +267,37 @@ struct ChatInfoView: View {
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||
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)
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
|
||||
.destructive(Text("Delete")) { deleteContact(notify: false) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
$0.content
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { deleteContact() },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func contactInfoHeader() -> some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack {
|
||||
let cInfo = chat.chatInfo
|
||||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.vertical, 12)
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if contact.verified {
|
||||
(
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
@@ -362,7 +326,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set contact name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
@@ -379,7 +343,7 @@ struct ChatInfoView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
|
||||
private func setContactAlias() {
|
||||
Task {
|
||||
do {
|
||||
@@ -394,25 +358,6 @@ 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 {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
@@ -442,7 +387,7 @@ struct ChatInfoView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func contactPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
@@ -457,7 +402,7 @@ struct ChatInfoView: View {
|
||||
Label("Contact preferences", systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func sendReceiptsOption() -> some View {
|
||||
Picker(selection: $sendReceipts) {
|
||||
ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in
|
||||
@@ -471,13 +416,13 @@ struct ChatInfoView: View {
|
||||
setSendReceipts()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func setSendReceipts() {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncContactConnection(force: false)
|
||||
@@ -486,7 +431,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
@@ -495,7 +440,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
@@ -503,35 +448,28 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.font(.system(size: 14))
|
||||
Spacer()
|
||||
Text(networkModel.contactNetworkStatus(contact).statusString)
|
||||
Text(chatModel.contactNetworkStatus(contact).statusString)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
serverImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func serverImage() -> some View {
|
||||
let status = networkModel.contactNetworkStatus(contact)
|
||||
let status = chatModel.contactNetworkStatus(contact)
|
||||
return Image(systemName: status.imageName)
|
||||
.foregroundColor(status == .connected ? .green : theme.colors.secondary)
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
|
||||
|
||||
private func deleteContactButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: true,
|
||||
showAlert: { alert = .someAlert(alert: $0) },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
showDeleteContactActionSheet = true
|
||||
} label: {
|
||||
Label("Delete contact", systemImage: "person.badge.minus")
|
||||
Label("Delete contact", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func clearChatButton() -> some View {
|
||||
Button() {
|
||||
alert = .clearChatAlert
|
||||
@@ -540,7 +478,26 @@ struct ChatInfoView: View {
|
||||
.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 {
|
||||
Alert(
|
||||
title: Text("Clear conversation?"),
|
||||
@@ -554,14 +511,14 @@ struct ChatInfoView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func networkStatusAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Network status"),
|
||||
message: Text(networkModel.contactNetworkStatus(contact).statusExplanation)
|
||||
message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func switchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
@@ -580,7 +537,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func abortSwitchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
@@ -598,7 +555,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
@@ -619,160 +576,6 @@ 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 {
|
||||
if CallController.useCallKit() {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else {
|
||||
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
|
||||
dismissAllSheets(animated: true) {
|
||||
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 {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -958,222 +761,15 @@ 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 {
|
||||
static var previews: some View {
|
||||
ChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||
contact: Contact.sampleData,
|
||||
connectionStats: Binding.constant(nil),
|
||||
customUserProfile: Binding.constant(nil),
|
||||
localAlias: "",
|
||||
onSearch: {}
|
||||
connectionCode: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import SwiftUI
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -19,12 +18,11 @@ class AnimatedImageView: UIView {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||
convenience init(image: UIImage) {
|
||||
self.init()
|
||||
self.image = image
|
||||
self.cMode = contentMode
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = contentMode
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
}
|
||||
|
||||
@@ -37,7 +35,7 @@ class AnimatedImageView: UIView {
|
||||
if let subview = self.subviews.first as? UIImageView {
|
||||
if image.imageData != subview.gifImage?.imageData {
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = contentMode
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
self.addSubview(imageView!)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
@@ -49,15 +47,13 @@ class AnimatedImageView: UIView {
|
||||
|
||||
struct SwiftyGif: UIViewRepresentable {
|
||||
private let image: UIImage
|
||||
private let contentMode: UIView.ContentMode
|
||||
|
||||
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||
init(image: UIImage) {
|
||||
self.image = image
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> AnimatedImageView {
|
||||
AnimatedImageView(image: image, contentMode: contentMode)
|
||||
AnimatedImageView(image: image)
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct CIChatFeatureView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var im = ItemsModel.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
@@ -54,8 +53,8 @@ struct CIChatFeatureView: View {
|
||||
var fs: [FeatureInfo] = []
|
||||
var icons: Set<String> = []
|
||||
if var i = m.getChatItemIndex(chatItem) {
|
||||
while i < im.reversedChatItems.count,
|
||||
let f = featureInfo(im.reversedChatItems[i]) {
|
||||
while i < m.reversedChatItems.count,
|
||||
let f = featureInfo(m.reversedChatItems[i]) {
|
||||
if !icons.contains(f.icon) {
|
||||
fs.insert(f, at: 0)
|
||||
icons.insert(f.icon)
|
||||
|
||||
@@ -14,45 +14,39 @@ struct CIFileView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
if smallViewSize != nil {
|
||||
fileIndicator()
|
||||
.onTapGesture(perform: fileAction)
|
||||
} else {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(!itemInteractive)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
|
||||
private var itemInteractive: Bool {
|
||||
@@ -201,22 +195,21 @@ struct CIFileView: View {
|
||||
}
|
||||
|
||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
let size = smallViewSize ?? 30
|
||||
return ZStack(alignment: .center) {
|
||||
ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.frame(width: 30, height: 30)
|
||||
.foregroundColor(color)
|
||||
if let innerIcon = innerIcon,
|
||||
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||
let innerIconSize = innerIconSize {
|
||||
Image(systemName: innerIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxHeight: 16)
|
||||
.frame(width: innerIconSize, height: innerIconSize)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, size / 2.5)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,33 +15,22 @@ struct CIImageView: View {
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
var imgWidth: CGFloat?
|
||||
var smallView: Bool = false
|
||||
@Binding var showFullScreenImage: Bool
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
@State private var showFullScreenImage = false
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||
imageView(uiImage)
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||
}
|
||||
.if(!smallView) { view in
|
||||
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let preview {
|
||||
Group {
|
||||
if smallView {
|
||||
smallViewImageView(preview)
|
||||
} else {
|
||||
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
}
|
||||
imageView(preview)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
@@ -94,9 +83,6 @@ struct CIImageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showFullScreenImage = false
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
@@ -112,26 +98,7 @@ struct CIImageView: View {
|
||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||
.scaledToFit()
|
||||
}
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallViewImageView(_ img: UIImage) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
} else {
|
||||
SwiftyGif(image: img, contentMode: .scaleAspectFill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
}
|
||||
if chatItem.file?.showStatusIconInSmallView == true {
|
||||
loadingIndicator()
|
||||
}
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +145,4 @@ struct CIImageView: View {
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||
switch fileStatus {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,14 @@ import SimpleXChat
|
||||
struct CILinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(linkPreview.title)
|
||||
|
||||
@@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View {
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View {
|
||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,44 +20,45 @@ struct CIVideoView: View {
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
private var videoWidth: CGFloat?
|
||||
private let smallView: Bool
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var urlDecrypted: URL?
|
||||
@State private var decryptionInProgress: Bool = false
|
||||
@Binding private var showFullScreenPlayer: Bool
|
||||
@State private var showFullScreenPlayer = false
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
@State private var publisher: AnyCancellable? = nil
|
||||
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
|
||||
self.chatItem = chatItem
|
||||
self.preview = preview
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self.videoWidth = videoWidth
|
||||
self.smallView = smallView
|
||||
self._showFullScreenPlayer = showFullscreenPlayer
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||
self._urlDecrypted = State(initialValue: decrypted)
|
||||
if let decrypted = decrypted {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
||||
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
||||
}
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||
ZStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
|
||||
smallVideoView(decrypted, file, preview)
|
||||
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
videoView(player, decrypted, file, preview, duration)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
|
||||
smallVideoViewEncrypted(file, defaultPreview)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
} else if let preview, let file {
|
||||
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||
.onTapGesture {
|
||||
} else if let preview {
|
||||
imageView(preview)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
@@ -81,64 +82,21 @@ struct CIVideoView: View {
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !smallView {
|
||||
durationProgress()
|
||||
}
|
||||
}
|
||||
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||
if !smallView {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
} else if !file.showStatusIconInSmallView {
|
||||
}
|
||||
durationProgress()
|
||||
}
|
||||
if let file = file, showDownloadButton(file.fileStatus) {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
.onTapGesture {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
if let decrypted = urlDecrypted {
|
||||
fullScreenPlayer(decrypted)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupPlayer(chatItem.file)
|
||||
}
|
||||
.onChange(of: chatItem.file) { file in
|
||||
// ChatItem can be changed in small view on chat list screen
|
||||
setupPlayer(file)
|
||||
}
|
||||
.onDisappear {
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlayer(_ file: CIFile?) {
|
||||
let newUrl = getLoadedVideo(file)
|
||||
if newUrl == url {
|
||||
return
|
||||
}
|
||||
url = nil
|
||||
urlDecrypted = nil
|
||||
player = nil
|
||||
fullPlayer = nil
|
||||
if let newUrl {
|
||||
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
|
||||
urlDecrypted = decrypted
|
||||
if let decrypted = decrypted {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
fullPlayer = AVPlayer(url: decrypted)
|
||||
}
|
||||
url = newUrl
|
||||
}
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
||||
switch fileStatus {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
@@ -151,6 +109,11 @@ struct CIVideoView: View {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
imageView(defaultPreview)
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
if let decrypted = urlDecrypted {
|
||||
fullScreenPlayer(decrypted)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
@@ -159,22 +122,20 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !blurred {
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
}
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +154,9 @@ struct CIVideoView: View {
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
fullScreenPlayer(url)
|
||||
}
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
@@ -209,7 +172,7 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying && !blurred {
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
@@ -231,53 +194,14 @@ struct CIVideoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
|
||||
return ZStack(alignment: .topLeading) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if file.showStatusIconInSmallView {
|
||||
// Show nothing
|
||||
} else if !decryptionInProgress {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
|
||||
return ZStack(alignment: .topLeading) {
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
|
||||
if !file.showStatusIconInSmallView {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
|
||||
.frame(width: 12, height: 12)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, smallView ? 0 : 4)
|
||||
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||
.padding(.leading, 4)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -285,9 +209,9 @@ struct CIVideoView: View {
|
||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||
.frame(width: 12, height: 12)
|
||||
.tint(color)
|
||||
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -323,23 +247,7 @@ struct CIVideoView: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w)
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
fileStatusIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
if file.showStatusIconInSmallView {
|
||||
fileStatusIcon()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
fileStatusIcon()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +322,7 @@ struct CIVideoView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(smallView ? 0 : padding)
|
||||
.padding(padding)
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
@@ -422,7 +330,7 @@ struct CIVideoView: View {
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 16, height: 16)
|
||||
.tint(.white)
|
||||
.padding(smallView ? 0 : 11)
|
||||
.padding(11)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
@@ -434,7 +342,7 @@ struct CIVideoView: View {
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||
.padding([.trailing, .top], 11)
|
||||
}
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
@@ -474,8 +382,7 @@ struct CIVideoView: View {
|
||||
)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
|
||||
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
|
||||
m.stopPreviousRecPlay = url
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
var played = false
|
||||
@@ -512,12 +419,10 @@ struct CIVideoView: View {
|
||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||
await MainActor.run {
|
||||
if let decrypted = urlDecrypted {
|
||||
if !smallView {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
}
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
fullPlayer = AVPlayer(url: decrypted)
|
||||
}
|
||||
decryptionInProgress = false
|
||||
decryptionInProgress = true
|
||||
completed?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,26 +15,15 @@ struct CIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@State var audioPlayer: AudioPlayer? = nil
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval? = nil
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@Binding var allowMenu: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if smallViewSize != nil {
|
||||
HStack(spacing: 10) {
|
||||
player()
|
||||
playerTime()
|
||||
.allowsHitTesting(false)
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
}
|
||||
} else if chatItem.chatDir.sent {
|
||||
if chatItem.chatDir.sent {
|
||||
VStack (alignment: .trailing, spacing: 6) {
|
||||
HStack {
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
@@ -65,13 +54,7 @@ struct CIVoiceView: View {
|
||||
}
|
||||
|
||||
private func player() -> some View {
|
||||
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
|
||||
voiceMessageSizeBasedOnSquareSize(sz) / 56
|
||||
} else {
|
||||
1
|
||||
}
|
||||
return VoiceMessagePlayer(
|
||||
chat: chat,
|
||||
VoiceMessagePlayer(
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -80,8 +63,7 @@ struct CIVoiceView: View {
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu,
|
||||
sizeMultiplier: sizeMultiplier
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,7 +119,6 @@ struct VoiceMessagePlayerTime: View {
|
||||
}
|
||||
|
||||
struct VoiceMessagePlayer: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
@@ -149,9 +130,7 @@ struct VoiceMessagePlayer: View {
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
var sizeMultiplier: CGFloat
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -211,113 +190,49 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if audioPlayer == nil {
|
||||
let small = sizeMultiplier != 1
|
||||
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
|
||||
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
|
||||
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
|
||||
}
|
||||
seek = { to in audioPlayer?.seek(to) }
|
||||
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
getAppFilePath(recordingSource.filePath)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let chatId = chatModel.chatId
|
||||
let userId = chatModel.currentUser?.userId
|
||||
audioPlayer?.onTimer = {
|
||||
playbackTime = $0
|
||||
notifyStateChange()
|
||||
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
||||
audioPlayer?.onFinishPlayback = {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
|
||||
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
stopPlayback()
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackState) { state in
|
||||
allowMenu = state == .paused || state == .noPlayback
|
||||
// Notify activeContentPreview in ChatPreviewView that playback is finished
|
||||
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
|
||||
chatModel.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
stopPlayback()
|
||||
}
|
||||
.onDisappear {
|
||||
if sizeMultiplier == 1 && chatModel.chatId == nil {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func playbackButton() -> some View {
|
||||
if sizeMultiplier != 1 {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
}
|
||||
case .playing:
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
}
|
||||
case .paused:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
} label: {
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,49 +242,28 @@ struct VoiceMessagePlayer: View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
if recordingTime > 0 {
|
||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
|
||||
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||
Group {
|
||||
if sizeMultiplier != 1 {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
.onTapGesture {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notifyStateChange() {
|
||||
if sizeMultiplier != 1 {
|
||||
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||
} else {
|
||||
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||
} label: {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,96 +288,33 @@ struct VoiceMessagePlayer: View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func loadingIcon() -> some View {
|
||||
ProgressView()
|
||||
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||
let audioPath = getAppFilePath(recordingSource.filePath)
|
||||
let chatId = chatModel.chatId
|
||||
let userId = chatModel.currentUser?.userId
|
||||
chatModel.stopPreviousRecPlay = audioPath
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: {
|
||||
playbackTime = $0
|
||||
notifyStateChange()
|
||||
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||
stopPlayback()
|
||||
}
|
||||
},
|
||||
onTimer: { playbackTime = $0 },
|
||||
onFinishPlayback: {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func stopPlayback() {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
|
||||
let squareToCircleRatio = 0.935
|
||||
return squareSize + squareSize * (1 - squareToCircleRatio)
|
||||
}
|
||||
|
||||
class VoiceItemState {
|
||||
var audioPlayer: AudioPlayer?
|
||||
var playbackState: VoiceMessagePlaybackState
|
||||
var playbackTime: TimeInterval?
|
||||
|
||||
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
|
||||
self.audioPlayer = audioPlayer
|
||||
self.playbackState = playbackState
|
||||
self.playbackTime = playbackTime
|
||||
}
|
||||
|
||||
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
|
||||
"\(chat.id) \(chatItem.id)"
|
||||
}
|
||||
|
||||
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
|
||||
"\(chatInfo.id) \(chatItem.id)"
|
||||
}
|
||||
|
||||
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||
let id = id(chatInfo, chatItem)
|
||||
if let item = smallView[id] {
|
||||
item.audioPlayer?.stop()
|
||||
ChatModel.shared.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
|
||||
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||
let id = id(chatInfo, chatItem)
|
||||
if let item = chatView[id] {
|
||||
item.audioPlayer?.stop()
|
||||
ChatModel.shared.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
|
||||
static var smallView: [String: VoiceItemState] = [:]
|
||||
static var chatView: [String: VoiceItemState] = [:]
|
||||
}
|
||||
|
||||
struct CIVoiceView_Previews: PreviewProvider {
|
||||
@@ -508,12 +339,15 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
audioPlayer: .constant(nil),
|
||||
playbackState: .constant(.playing),
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
||||
@@ -13,23 +13,21 @@ import SimpleXChat
|
||||
|
||||
struct FramedCIVoiceView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
|
||||
@State var audioPlayer: AudioPlayer? = nil
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval? = nil
|
||||
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VoiceMessagePlayer(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -38,8 +36,7 @@ struct FramedCIVoiceView: View {
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu,
|
||||
sizeMultiplier: 1
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -35,8 +35,8 @@ struct MarkedDeletedItemView: View {
|
||||
var blockedByAdmin = 0
|
||||
var deleted = 0
|
||||
var moderatedBy: Set<String> = []
|
||||
while i < ItemsModel.shared.reversedChatItems.count,
|
||||
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
|
||||
while i < m.reversedChatItems.count,
|
||||
let ci = .some(m.reversedChatItems[i]),
|
||||
ci.mergeCategory == ciCategory,
|
||||
let itemDeleted = ci.meta.itemDeleted {
|
||||
switch itemDeleted {
|
||||
|
||||
@@ -21,7 +21,8 @@ struct ChatItemForwardingView: View {
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocused
|
||||
@State private var alert: SomeAlert?
|
||||
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||
@State private var hasSimplexLink_: Bool?
|
||||
private let chatsToForwardTo = filterChatsToForwardTo()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -66,6 +67,50 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
|
||||
let cInfo = chat.chatInfo
|
||||
return switch cInfo {
|
||||
case let .direct(contact):
|
||||
viewNameContains(cInfo, searchStr) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
|
||||
contact.fullName.localizedLowercase.contains(searchStr)
|
||||
default:
|
||||
viewNameContains(cInfo, searchStr)
|
||||
}
|
||||
|
||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
|
||||
private func prohibitedByPref(_ chat: Chat) -> Bool {
|
||||
// preference checks should match checks in compose view
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
|
||||
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
|
||||
return switch chat.chatInfo {
|
||||
case .direct: voiceProhibited
|
||||
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
|
||||
case .local: false
|
||||
case .contactRequest: false
|
||||
case .contactConnection: false
|
||||
case .invalidJSON: false
|
||||
}
|
||||
}
|
||||
|
||||
private var hasSimplexLink: Bool {
|
||||
if let hasSimplexLink_ { return hasSimplexLink_ }
|
||||
let r =
|
||||
if let mcText = ci.content.msgContent?.text,
|
||||
let parsedMsg = parseSimpleXMarkdown(mcText) {
|
||||
parsedMsgHasSimplexLink(parsedMsg)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
hasSimplexLink_ = r
|
||||
return r
|
||||
}
|
||||
|
||||
private func emptyList() -> some View {
|
||||
Text("No filtered chats")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -73,11 +118,7 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
|
||||
let prohibited = chat.prohibitedByPref(
|
||||
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
|
||||
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
|
||||
isVoice: ci.content.msgContent?.isVoice ?? false
|
||||
)
|
||||
let prohibited = prohibitedByPref(chat)
|
||||
Button {
|
||||
if prohibited {
|
||||
alert = SomeAlert(
|
||||
@@ -97,7 +138,7 @@ struct ChatItemForwardingView: View {
|
||||
)
|
||||
} else {
|
||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||
ItemsModel.shared.loadOpenChat(chat.id)
|
||||
chatModel.chatId = chat.id
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -121,6 +162,27 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func filterChatsToForwardTo() -> [Chat] {
|
||||
var filteredChats = ChatModel.shared.chats.filter { c in
|
||||
c.chatInfo.chatType != .local && canForwardToChat(c)
|
||||
}
|
||||
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
|
||||
filteredChats.insert(privateNotes, at: 0)
|
||||
}
|
||||
return filteredChats
|
||||
}
|
||||
|
||||
private func canForwardToChat(_ chat: Chat) -> Bool {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
||||
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
||||
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
||||
case .contactRequest: false
|
||||
case .contactConnection: false
|
||||
case .invalidJSON: false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChatItemForwardingView(
|
||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
@@ -128,4 +190,3 @@ struct ChatItemForwardingView: View {
|
||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||
).environmentObject(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
|
||||
Button {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
|
||||
chatModel.chatId = forwardedFromItem.chatInfo.id
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,7 @@ struct ChatItemInfoView: View {
|
||||
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
||||
HStack{
|
||||
MemberProfileImage(member, size: 30)
|
||||
ProfileImage(imageStr: member.image, size: 30)
|
||||
.padding(.trailing, 2)
|
||||
Text(member.chatViewName)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -16,20 +16,28 @@ struct ChatItemView: View {
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false)
|
||||
allowMenu: Binding<Bool> = .constant(false),
|
||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
|
||||
playbackTime: Binding<TimeInterval?> = .constant(nil)
|
||||
) {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.maxWidth = maxWidth
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
_audioPlayer = audioPlayer
|
||||
_playbackState = playbackState
|
||||
_playbackTime = playbackTime
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -40,7 +48,7 @@ struct ChatItemView: View {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chat: chat, chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
@@ -60,7 +68,9 @@ struct ChatItemView: View {
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
.flatMap { UIImage(base64Encoded: $0) }
|
||||
.map { dropImagePrefix($0) }
|
||||
.flatMap { Data(base64Encoded: $0) }
|
||||
.flatMap { UIImage(data: $0) }
|
||||
let adjustedMaxWidth = {
|
||||
if let preview, preview.size.width <= preview.size.height {
|
||||
maxWidth * 0.75
|
||||
@@ -76,7 +86,10 @@ struct ChatItemView: View {
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
videoWidth: adjustedMaxWidth,
|
||||
allowMenu: $allowMenu
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,8 +32,9 @@ struct ComposeFileView: View {
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.frame(height: 54)
|
||||
.frame(height: 50)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ struct ComposeImageView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let imgs: [UIImage] = images.compactMap { image in
|
||||
UIImage(base64Encoded: image)
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)) {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if imgs.count == 0 {
|
||||
ProgressView()
|
||||
@@ -46,8 +49,8 @@ struct ComposeImageView: View {
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,35 @@ import SwiftUI
|
||||
import LinkPresentation
|
||||
import SimpleXChat
|
||||
|
||||
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
||||
logger.debug("getLinkMetadata: fetching URL preview")
|
||||
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
||||
if let e = error {
|
||||
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
||||
}
|
||||
if let metadata = metadata,
|
||||
let imageProvider = metadata.imageProvider,
|
||||
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
||||
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
||||
var linkPreview: LinkPreview? = nil
|
||||
if let error = error {
|
||||
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
||||
} else {
|
||||
if let image = object as? UIImage,
|
||||
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
|
||||
let title = metadata.title,
|
||||
let uri = metadata.originalURL {
|
||||
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
|
||||
}
|
||||
}
|
||||
cb(linkPreview)
|
||||
}
|
||||
} else {
|
||||
cb(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeLinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview?
|
||||
@@ -34,13 +63,14 @@ struct ComposeLinkView: View {
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
@@ -55,7 +85,7 @@ struct ComposeLinkView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
.frame(maxWidth: .infinity, minHeight: 60)
|
||||
.frame(maxWidth: .infinity, minHeight: 60, maxHeight: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +281,11 @@ struct ComposeView: View {
|
||||
@State private var stopPlayback: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
ContextInvitingContactMemberView()
|
||||
Divider()
|
||||
}
|
||||
// preference checks should match checks in forwarding list
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
@@ -296,13 +293,10 @@ struct ComposeView: View {
|
||||
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||
if simplexLinkProhibited {
|
||||
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
||||
Divider()
|
||||
} else if fileProhibited {
|
||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||
Divider()
|
||||
} else if voiceProhibited {
|
||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||
Divider()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
@@ -365,6 +359,7 @@ struct ComposeView: View {
|
||||
: theme.colors.primary
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.colors.background)
|
||||
.disabled(!chat.userCanSend)
|
||||
|
||||
if chat.userIsObserver {
|
||||
@@ -382,11 +377,6 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background {
|
||||
Color.clear
|
||||
.overlay(ToolbarMaterial.material(toolbarMaterial))
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
.onChange(of: composeState.message) { msg in
|
||||
if composeState.linkPreviewAllowed {
|
||||
if msg.count > 0 {
|
||||
@@ -635,7 +625,6 @@ struct ComposeView: View {
|
||||
cancelPreview: cancelLinkPreview,
|
||||
cancelEnabled: !composeState.inProgress
|
||||
)
|
||||
Divider()
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
ComposeImageView(
|
||||
images: media.map { (img, _) in img },
|
||||
@@ -644,7 +633,6 @@ struct ComposeView: View {
|
||||
chosenMedia = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
Divider()
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
ComposeVoiceView(
|
||||
recordingFileName: recordingFileName,
|
||||
@@ -657,7 +645,6 @@ struct ComposeView: View {
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||
stopPlayback: $stopPlayback
|
||||
)
|
||||
Divider()
|
||||
case let .filePreview(fileName, _):
|
||||
ComposeFileView(
|
||||
fileName: fileName,
|
||||
@@ -665,7 +652,6 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
},
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,9 +661,10 @@ struct ComposeView: View {
|
||||
Text(reason).italic()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 54)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder private func contextItemView() -> some View {
|
||||
@@ -691,7 +678,6 @@ struct ComposeView: View {
|
||||
contextIcon: "arrowshape.turn.up.left",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
Divider()
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
@@ -699,7 +685,6 @@ struct ComposeView: View {
|
||||
contextIcon: "pencil",
|
||||
cancelContextItem: { clearState() }
|
||||
)
|
||||
Divider()
|
||||
case let .forwardingItem(chatItem: forwardedItem, _):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
@@ -708,7 +693,6 @@ struct ComposeView: View {
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||
showSender: false
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,7 +849,6 @@ struct ComposeView: View {
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
@@ -1125,6 +1108,10 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
|
||||
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
|
||||
}
|
||||
|
||||
struct ComposeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
|
||||
@@ -51,8 +51,8 @@ struct ComposeVoiceView: View {
|
||||
.padding(.vertical, 1)
|
||||
.frame(height: ComposeVoiceView.previewHeight)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func recordingMode() -> some View {
|
||||
|
||||
@@ -18,9 +18,10 @@ struct ContextInvitingContactMemberView: View {
|
||||
Text("Send direct message to connect")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 54)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,10 @@ struct ContextItemView: View {
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 54)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(chatItemFrameColor(contextItem, theme))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
|
||||
@@ -44,7 +44,6 @@ struct SendMessageView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
HStack(alignment: .bottom) {
|
||||
ZStack(alignment: .leading) {
|
||||
if case .voicePreview = composeState.preview {
|
||||
@@ -67,6 +66,7 @@ struct SendMessageView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
@@ -84,9 +84,10 @@ struct SendMessageView: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.background(theme.colors.background)
|
||||
.clipShape(composeShape)
|
||||
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
}
|
||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
@@ -257,9 +258,6 @@ struct SendMessageView: View {
|
||||
var body: some View {
|
||||
Button(action: {}) {
|
||||
Image(systemName: "mic.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.disabled(disabled)
|
||||
@@ -313,9 +311,6 @@ struct SendMessageView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "mic")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.disabled(composeState.inProgress)
|
||||
|
||||
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
|
||||
private enum AddGroupMembersAlert: Identifiable {
|
||||
case prohibitedToInviteIncognito
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -47,13 +47,14 @@ struct AddGroupMembersViewCommon: View {
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
addGroupMembersView()
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||
NavigationView {
|
||||
addGroupMembersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addGroupMembersView()
|
||||
}
|
||||
@@ -121,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
|
||||
)
|
||||
case let .error(title, error):
|
||||
return mkAlert(title: title, message: error)
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedContacts) { _ in
|
||||
|
||||
@@ -17,12 +17,10 @@ struct GroupChatInfoView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
var onSearch: () -> Void
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var groupLinkNavLinkActive: Bool = false
|
||||
@State private var addMembersNavLinkActive: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@@ -42,7 +40,7 @@ struct GroupChatInfoView: View {
|
||||
case blockForAllAlert(mem: GroupMember)
|
||||
case unblockForAllAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -70,15 +68,6 @@ struct GroupChatInfoView: View {
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.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 {
|
||||
if groupInfo.canEdit {
|
||||
@@ -169,7 +158,7 @@ struct GroupChatInfoView: View {
|
||||
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -185,6 +174,7 @@ struct GroupChatInfoView: View {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
private func groupInfoHeader() -> some View {
|
||||
@@ -208,95 +198,24 @@ struct GroupChatInfoView: View {
|
||||
.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 {
|
||||
NavigationLink {
|
||||
addMembersDestinationView()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
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 {
|
||||
var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
@@ -307,7 +226,7 @@ struct GroupChatInfoView: View {
|
||||
var body: some View {
|
||||
let member = groupMember.wrapped
|
||||
let v = HStack{
|
||||
MemberProfileImage(member, size: 38)
|
||||
ProfileImage(imageStr: member.image, size: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
@@ -433,7 +352,16 @@ struct GroupChatInfoView: View {
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
groupLinkDestinationView()
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
if groupLink == nil {
|
||||
Label("Create group link", systemImage: "link.badge.plus")
|
||||
@@ -443,19 +371,6 @@ 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 {
|
||||
NavigationLink {
|
||||
GroupProfileView(
|
||||
@@ -662,8 +577,7 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
onSearch: {}
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
|
||||
|
||||
private enum GroupLinkAlert: Identifiable {
|
||||
case deleteLink
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -34,13 +34,14 @@ struct GroupLinkView: View {
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
groupLinkView()
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
NavigationView {
|
||||
groupLinkView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupLinkView()
|
||||
}
|
||||
@@ -112,7 +113,7 @@ struct GroupLinkView: View {
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .error(title, error):
|
||||
return mkAlert(title: title, message: error)
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onChange(of: groupLinkMemberRole) { _ in
|
||||
|
||||
@@ -37,8 +37,7 @@ struct GroupMemberInfoView: View {
|
||||
case syncConnectionForceAlert
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case queueInfo(info: String)
|
||||
case someAlert(alert: SomeAlert)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -53,7 +52,6 @@ struct GroupMemberInfoView: View {
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||
case let .queueInfo(info): return "queueInfo \(info)"
|
||||
case let .someAlert(alert): return "someAlert \(alert.id)"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
@@ -67,11 +65,10 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
|
||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
let contact = chat.chatInfo.contact,
|
||||
contact.directOrUsed == true {
|
||||
return (chat, contact)
|
||||
chat.chatInfo.contact?.directOrUsed == true {
|
||||
return chat
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -79,153 +76,154 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
ZStack {
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
VStack {
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
infoActionButtons(member)
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton()
|
||||
}
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
connectViaAddressButton(contactLink)
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} 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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
.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
|
||||
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 && !$0.canAbortSwitch }
|
||||
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)
|
||||
}
|
||||
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 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 {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
if 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 {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
.onChange(of: member.memberRole) { role in
|
||||
newRole = role
|
||||
}
|
||||
}
|
||||
.onChange(of: member.memberRole) { role in
|
||||
newRole = role
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
@@ -241,8 +239,7 @@ struct GroupMemberInfoView: View {
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||
case let .queueInfo(info): return queueInfoAlert(info)
|
||||
case let .someAlert(a): return a.alert
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
@@ -254,57 +251,6 @@ struct GroupMemberInfoView: View {
|
||||
.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 {
|
||||
Button {
|
||||
planAndConnect(
|
||||
@@ -319,32 +265,36 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
||||
Button {
|
||||
dismissAllSheets(animated: true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||
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))")
|
||||
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(width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
func createMemberContactButton() -> some View {
|
||||
Button {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
@@ -352,10 +302,9 @@ struct GroupMemberInfoView: View {
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
dismissAllSheets(animated: true)
|
||||
chatModel.chatId = memberContact.id
|
||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
@@ -366,12 +315,14 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Send direct message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||
VStack {
|
||||
MemberProfileImage(mem, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
ProfileImage(imageStr: mem.image, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
if mem.verified {
|
||||
@@ -633,21 +584,6 @@ 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 {
|
||||
Alert(
|
||||
title: Text("Block member?"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import Combine
|
||||
|
||||
/// A List, which displays it's items in reverse order - from bottom to top
|
||||
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
let items: Array<Item>
|
||||
|
||||
@Binding var scrollState: ReverseListScrollModel<Item>.State
|
||||
@@ -32,7 +33,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
case let .item(id):
|
||||
controller.scroll(to: items.firstIndex(where: { $0.id == id }), position: .bottom)
|
||||
case .bottom:
|
||||
controller.scroll(to: 0, position: .top)
|
||||
controller.scroll(to: .zero, position: .top)
|
||||
}
|
||||
} else {
|
||||
controller.update(items: items)
|
||||
@@ -44,7 +45,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
private enum Section { case main }
|
||||
private let representer: ReverseList
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||
private var itemCount: Int = 0
|
||||
private var itemCount: Int = .zero
|
||||
private var bag = Set<AnyCancellable>()
|
||||
|
||||
init(representer: ReverseList) {
|
||||
@@ -52,7 +53,6 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
super.init(style: .plain)
|
||||
|
||||
// 1. Style
|
||||
tableView = InvertedTableView()
|
||||
tableView.separatorStyle = .none
|
||||
tableView.transform = .verticalFlip
|
||||
tableView.backgroundColor = .clear
|
||||
@@ -80,7 +80,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
|
||||
if #available(iOS 16.0, *) {
|
||||
cell.contentConfiguration = UIHostingConfiguration { self.representer.content(item) }
|
||||
.margins(.all, 0)
|
||||
.margins(.all, .zero)
|
||||
.minSize(height: 1) // Passing zero will result in system default of 44 points being used
|
||||
} else {
|
||||
if let cell = cell as? HostingCell<Content> {
|
||||
@@ -129,14 +129,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
tableView.clipsToBounds = false
|
||||
parent?.viewIfLoaded?.clipsToBounds = false
|
||||
}
|
||||
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
tableView.setContentOffset(
|
||||
@@ -152,23 +146,18 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
/// Scrolls to Item at index path
|
||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
if let index {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
at: IndexPath(row: index, section: .zero),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
} else {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||
animated: animated
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: Array<Item>) {
|
||||
@@ -178,15 +167,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
dataSource.defaultRowAnimation = .none
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: itemCount != 0 && abs(items.count - itemCount) == 1
|
||||
animatingDifferences: itemCount != .zero && abs(items.count - itemCount) == 1
|
||||
)
|
||||
// Sets content offset on initial load
|
||||
if itemCount == 0 {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: 0, y: -InvertedTableView.inset),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
itemCount = items.count
|
||||
}
|
||||
}
|
||||
@@ -289,28 +271,3 @@ func withConditionalAnimation<Result>(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// SelectableChatItemToolbars.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 30.07.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct SelectedItemsTopToolbar: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
|
||||
var body: some View {
|
||||
let count = selectedChatItems?.count ?? 0
|
||||
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.frame(width: 220)
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectedItemsBottomToolbar: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let chatItems: [ChatItem]
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
var chatInfo: ChatInfo
|
||||
// Bool - delete for everyone is possible
|
||||
var deleteItems: (Bool) -> Void
|
||||
var moderateItems: () -> Void
|
||||
//var shareItems: () -> Void
|
||||
@State var deleteEnabled: Bool = false
|
||||
@State var deleteForEveryoneEnabled: Bool = false
|
||||
|
||||
@State var canModerate: Bool = false
|
||||
@State var moderateEnabled: Bool = false
|
||||
|
||||
@State var allButtonsDisabled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Button {
|
||||
deleteItems(deleteForEveryoneEnabled)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
|
||||
}
|
||||
.disabled(!deleteEnabled || allButtonsDisabled)
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
moderateItems()
|
||||
} label: {
|
||||
Image(systemName: "flag")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
|
||||
}
|
||||
.disabled(!moderateEnabled || allButtonsDisabled)
|
||||
.opacity(canModerate ? 1 : 0)
|
||||
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
//shareItems()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
|
||||
}
|
||||
.disabled(allButtonsDisabled)
|
||||
.opacity(0)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding([.leading, .trailing], 12)
|
||||
}
|
||||
.onAppear {
|
||||
recheckItems(chatInfo, chatItems, selectedChatItems)
|
||||
}
|
||||
.onChange(of: chatInfo) { info in
|
||||
recheckItems(info, chatItems, selectedChatItems)
|
||||
}
|
||||
.onChange(of: chatItems) { items in
|
||||
recheckItems(chatInfo, items, selectedChatItems)
|
||||
}
|
||||
.onChange(of: selectedChatItems) { selected in
|
||||
recheckItems(chatInfo, chatItems, selected)
|
||||
}
|
||||
.frame(height: 55.5)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
|
||||
let count = selectedItems?.count ?? 0
|
||||
allButtonsDisabled = count == 0 || count > 20
|
||||
canModerate = possibleToModerate(chatInfo)
|
||||
if let selected = selectedItems {
|
||||
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,6 +11,7 @@ import SwiftUI
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
@@ -38,7 +39,7 @@ struct ChatHelp: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Tap button ")
|
||||
NewChatMenuButton()
|
||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,56 +9,35 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
typealias DynamicSizes = (
|
||||
rowHeight: CGFloat,
|
||||
profileImageSize: CGFloat,
|
||||
mediaSize: CGFloat,
|
||||
incognitoSize: CGFloat,
|
||||
chatInfoSize: CGFloat,
|
||||
unreadCorner: CGFloat,
|
||||
unreadPadding: CGFloat
|
||||
)
|
||||
|
||||
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
|
||||
.xSmall: (68, 55, 33, 22, 18, 9, 3),
|
||||
.small: (72, 57, 34, 22, 18, 9, 3),
|
||||
.medium: (76, 60, 36, 22, 18, 10, 4),
|
||||
.large: (80, 63, 38, 24, 20, 10, 4),
|
||||
.xLarge: (88, 67, 41, 24, 20, 10, 4),
|
||||
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
|
||||
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
|
||||
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
|
||||
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
|
||||
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
|
||||
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
|
||||
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
|
||||
private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
||||
.xSmall: 68,
|
||||
.small: 72,
|
||||
.medium: 76,
|
||||
.large: 80,
|
||||
.xLarge: 88,
|
||||
.xxLarge: 94,
|
||||
.xxxLarge: 104,
|
||||
.accessibility1: 90,
|
||||
.accessibility2: 100,
|
||||
.accessibility3: 120,
|
||||
.accessibility4: 130,
|
||||
.accessibility5: 140
|
||||
]
|
||||
|
||||
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
|
||||
|
||||
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
|
||||
dynamicSizes[font] ?? defaultDynamicSizes
|
||||
}
|
||||
|
||||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var showContactRequestDialog = false
|
||||
@State private var showJoinGroupDialog = false
|
||||
@State private var showContactConnectionInfo = false
|
||||
@State private var showInvalidJSON = false
|
||||
@State private var alert: SomeAlert? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch chat.chatInfo {
|
||||
@@ -86,24 +65,17 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
|
||||
Group {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: false,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
showDeleteContactActionSheet = true
|
||||
} label: {
|
||||
deleteLabel
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
@@ -114,44 +86,51 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
} else {
|
||||
NavLinkPlain(
|
||||
chatId: chat.chatInfo.id,
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat: chat)
|
||||
ToggleNtfsButton(chat: chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: false,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
if contact.ready || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
} label: {
|
||||
deleteLabel
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { $0.alert }
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
$0.content
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +139,7 @@ struct ChatListNavLink: View {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
if groupInfo.canDelete {
|
||||
@@ -180,7 +159,7 @@ struct ChatListNavLink: View {
|
||||
.disabled(inProgress)
|
||||
case .memAccepted:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
}
|
||||
@@ -194,16 +173,16 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
default:
|
||||
NavLinkPlain(
|
||||
chatId: chat.chatInfo.id,
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
toggleNtfsButton(chat: chat)
|
||||
ToggleNtfsButton(chat: chat)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !chat.chatItems.isEmpty {
|
||||
@@ -221,12 +200,12 @@ struct ChatListNavLink: View {
|
||||
|
||||
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||
NavLinkPlain(
|
||||
chatId: chat.chatInfo.id,
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !noteFolder.ready
|
||||
)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
}
|
||||
@@ -244,7 +223,7 @@ struct ChatListNavLink: View {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI)
|
||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
||||
}
|
||||
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||
}
|
||||
@@ -254,14 +233,14 @@ struct ChatListNavLink: View {
|
||||
Button {
|
||||
Task { await markChatRead(chat) }
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Read", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI)
|
||||
Label("Read", systemImage: "checkmark")
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
} else {
|
||||
Button {
|
||||
Task { await markChatUnread(chat) }
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Unread", comment: "swipe action"), systemImage: "circlebadge.fill", inverted: oneHandUI)
|
||||
Label("Unread", systemImage: "circlebadge.fill")
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
@@ -273,36 +252,24 @@ struct ChatListNavLink: View {
|
||||
Button {
|
||||
toggleChatFavorite(chat, favorite: false)
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Unfav.", comment: "swipe action"), systemImage: "star.slash.fill", inverted: oneHandUI)
|
||||
Label("Unfav.", systemImage: "star.slash")
|
||||
}
|
||||
.tint(.green)
|
||||
} else {
|
||||
Button {
|
||||
toggleChatFavorite(chat, favorite: true)
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Favorite", comment: "swipe action"), systemImage: "star.fill", inverted: oneHandUI)
|
||||
Label("Favorite", systemImage: "star.fill")
|
||||
}
|
||||
.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 {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(clearChatAlert())
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||
Label("Clear", systemImage: "gobackward")
|
||||
}
|
||||
.tint(Color.orange)
|
||||
}
|
||||
@@ -311,7 +278,7 @@ struct ChatListNavLink: View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(clearNoteFolderAlert())
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Clear", comment: "swipe action"), systemImage: "gobackward", inverted: oneHandUI)
|
||||
Label("Clear", systemImage: "gobackward")
|
||||
}
|
||||
.tint(Color.orange)
|
||||
}
|
||||
@@ -320,7 +287,7 @@ struct ChatListNavLink: View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(leaveGroupAlert(groupInfo))
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Leave", comment: "swipe action"), systemImage: "rectangle.portrait.and.arrow.right.fill", inverted: oneHandUI)
|
||||
Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.tint(Color.yellow)
|
||||
}
|
||||
@@ -329,7 +296,7 @@ struct ChatListNavLink: View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||
} label: {
|
||||
deleteLabel
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
@@ -339,23 +306,22 @@ struct ChatListNavLink: View {
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
|
||||
} label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) }
|
||||
} label: { Label("Accept", systemImage: "checkmark") }
|
||||
.tint(theme.colors.primary)
|
||||
Button {
|
||||
Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) }
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI)
|
||||
Label("Accept incognito", systemImage: "theatermasks")
|
||||
}
|
||||
.tint(.indigo)
|
||||
Button {
|
||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Reject", comment: "swipe action"), systemImage: "multiply.fill", inverted: oneHandUI)
|
||||
Label("Reject", systemImage: "multiply")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.contentShape(Rectangle())
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||
@@ -372,18 +338,18 @@ struct ChatListNavLink: View {
|
||||
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
||||
})
|
||||
} label: {
|
||||
deleteLabel
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
|
||||
Button {
|
||||
showContactConnectionInfo = true
|
||||
} label: {
|
||||
SwipeLabel(NSLocalizedString("Name", comment: "swipe action"), systemImage: "pencil", inverted: oneHandUI)
|
||||
Label("Name", systemImage: "pencil")
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
Group {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
@@ -393,16 +359,11 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
showContactConnectionInfo = true
|
||||
}
|
||||
}
|
||||
|
||||
private var deleteLabel: some View {
|
||||
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: oneHandUI)
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete group?"),
|
||||
@@ -451,6 +412,28 @@ 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 {
|
||||
Alert(
|
||||
title: Text("Joining group"),
|
||||
@@ -458,11 +441,35 @@ 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 {
|
||||
Text("invalid chat data")
|
||||
.foregroundColor(.red)
|
||||
.padding(4)
|
||||
.frame(height: dynamicRowHeight)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.appSheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
@@ -472,26 +479,16 @@ struct ChatListNavLink: View {
|
||||
|
||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
||||
if ok {
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
await MainActor.run {
|
||||
chatModel.chatId = contact.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Alert(
|
||||
title: Text("Delete pending connection?"),
|
||||
@@ -518,14 +515,15 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
||||
)
|
||||
}
|
||||
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||
if let alert = alert {
|
||||
showAlert(alert)
|
||||
AlertManager.shared.showAlert(alert)
|
||||
return false
|
||||
} else if let contact = contact {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(contact)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -566,11 +564,18 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorAlert {
|
||||
var title: LocalizedStringKey
|
||||
var message: LocalizedStringKey
|
||||
}
|
||||
|
||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||
if let r = error as? ChatResponse,
|
||||
let alert = getNetworkErrorAlert(r) {
|
||||
return alert
|
||||
} else {
|
||||
switch error as? ChatResponse {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
default:
|
||||
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,10 @@ struct ChatListView: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
@State private var scrollToSearchBar = 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 {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -36,16 +32,21 @@ struct ChatListView: View {
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NavStackCompat(
|
||||
isActive: Binding(
|
||||
get: { chatModel.chatId != nil },
|
||||
set: { active in
|
||||
if !active { chatModel.chatId = nil }
|
||||
}
|
||||
set: { _ in }
|
||||
),
|
||||
destination: chatView
|
||||
) { chatListView }
|
||||
) {
|
||||
VStack {
|
||||
if chatModel.chats.isEmpty {
|
||||
onboardingButtons()
|
||||
}
|
||||
chatListView
|
||||
}
|
||||
}
|
||||
if userPickerVisible {
|
||||
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
|
||||
withAnimation {
|
||||
@@ -65,14 +66,9 @@ struct ChatListView: View {
|
||||
}
|
||||
|
||||
private var chatListView: some View {
|
||||
let tm = ToolbarMaterial.material(toolbarMaterial)
|
||||
return withToolbar(tm) {
|
||||
VStack {
|
||||
chatList
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode || oneHandUI)
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
@@ -90,111 +86,55 @@ struct ChatListView: View {
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
}
|
||||
.safeAreaInset(edge: .top) {
|
||||
if oneHandUI { Divider().background(tm) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if oneHandUI {
|
||||
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var hasHomeIndicator: Bool = {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
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 }
|
||||
}
|
||||
} else {
|
||||
if oneHandUI {
|
||||
content().toolbar { bottomToolbarGroup }
|
||||
} else {
|
||||
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()
|
||||
.listStyle(.plain)
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
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)
|
||||
SubsStatusIndicator()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
} 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 {
|
||||
let cs = filteredChats()
|
||||
ZStack {
|
||||
ScrollViewReader { scrollProxy in
|
||||
VStack {
|
||||
List {
|
||||
if !chatModel.chats.isEmpty {
|
||||
ChatListSearchBar(
|
||||
@@ -204,67 +144,27 @@ struct ChatListView: View {
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||
)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, oneHandUI ? 8 : 0)
|
||||
.id("searchBar")
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.padding(.trailing, -16)
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.offset(x: -8)
|
||||
} else {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
VStack(spacing: .zero) {
|
||||
Divider()
|
||||
.padding(.leading, 16)
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.background { theme.colors.background } // Hides default list selection colour
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
.offset(x: -8)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: chatModel.chatId) { currentChatId in
|
||||
if let chatId = chatModel.chatToTop, currentChatId != chatId {
|
||||
chatModel.chatToTop = nil
|
||||
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") }
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
}
|
||||
}
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||
Text("No filtered chats")
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No filtered chats").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,37 +175,64 @@ struct ChatListView: View {
|
||||
.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 {
|
||||
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
|
||||
ChatView(chat: chat)
|
||||
}
|
||||
}
|
||||
|
||||
func stopAudioPlayer() {
|
||||
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||
VoiceItemState.smallView = [:]
|
||||
}
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
} else {
|
||||
let s = searchString()
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats.filter { chat in
|
||||
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||
}
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { chat in
|
||||
let cInfo = chat.chatInfo
|
||||
switch cInfo {
|
||||
case let .direct(contact):
|
||||
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
|
||||
s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
)
|
||||
return s == ""
|
||||
? filtered(chat)
|
||||
: (viewNameContains(cInfo, s) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(s) ||
|
||||
contact.fullName.localizedLowercase.contains(s))
|
||||
case let .group(gInfo):
|
||||
return s == ""
|
||||
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
|
||||
@@ -340,59 +267,70 @@ struct ChatListView: View {
|
||||
|
||||
struct SubsStatusIndicator: View {
|
||||
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||
@State private var hasSess: Bool = false
|
||||
@State private var task: Task<Void, Never>?
|
||||
@State private var sess: ServerSessions = ServerSessions.newServerSessions
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var timerCounter = 0
|
||||
@State private var showServersSummary = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
|
||||
// Constants for the intervals
|
||||
let initialInterval: TimeInterval = 1.0
|
||||
let regularInterval: TimeInterval = 3.0
|
||||
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showServersSummary = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Chats").foregroundStyle(Color.primary).fixedSize().font(.headline)
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: sess)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(ChatModel.shared.chatRunning != true)
|
||||
.onAppear {
|
||||
startTask()
|
||||
startInitialTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTask()
|
||||
stopTimer()
|
||||
}
|
||||
.appSheet(isPresented: $showServersSummary) {
|
||||
.sheet(isPresented: $showServersSummary) {
|
||||
ServersSummaryView()
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func startTask() {
|
||||
task = Task {
|
||||
while !Task.isCancelled {
|
||||
if AppChatState.shared.value == .active {
|
||||
do {
|
||||
let (subs, hasSess) = try await getAgentSubsTotal()
|
||||
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
|
||||
private func startInitialTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
timerCounter += 1
|
||||
// Switch to the regular timer after the initial phase
|
||||
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
|
||||
switchToRegularTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTask() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
func switchToRegularTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
|
||||
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))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +380,7 @@ struct ChatListSearchBar: View {
|
||||
toggleFilterButton()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
.onChange(of: searchFocussed) { sf in
|
||||
withAnimation { searchMode = sf }
|
||||
@@ -518,21 +457,21 @@ func chatStoppedIcon() -> some View {
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.updateChats([
|
||||
ChatData(
|
||||
chatModel.chats = [
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
),
|
||||
ChatData(
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.group,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
|
||||
),
|
||||
ChatData(
|
||||
Chat(
|
||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||
chatItems: []
|
||||
)
|
||||
|
||||
])
|
||||
]
|
||||
return Group {
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
.environmentObject(chatModel)
|
||||
|
||||
@@ -12,24 +12,18 @@ import SimpleXChat
|
||||
struct ChatPreviewView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressByTimeout: Bool
|
||||
@State var deleting: Bool = false
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
@State private var activeContentPreview: ActiveContentPreview? = nil
|
||||
@State private var showFullscreenGallery: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
|
||||
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
|
||||
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
|
||||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return HStack(spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||
ChatInfoImage(chat: chat, size: 63)
|
||||
chatPreviewImageOverlayIcon()
|
||||
.padding([.bottom, .trailing], 1)
|
||||
}
|
||||
@@ -49,38 +43,11 @@ struct ChatPreviewView: View {
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let chat = activeContentPreview?.chat ?? chat
|
||||
let ci = activeContentPreview?.ci ?? chat.chatItems.last
|
||||
let mc = ci?.content.msgContent
|
||||
HStack(alignment: .top) {
|
||||
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
|
||||
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
|
||||
if let ci, showContentPreview {
|
||||
chatItemContentPreview(chat, ci)
|
||||
}
|
||||
let mcIsVoice = switch mc { case .voice: true; default: false }
|
||||
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
|
||||
let hasFilePreview = if case .file = mc { true } else { false }
|
||||
chatMessagePreview(cItem, hasFilePreview)
|
||||
} else {
|
||||
Spacer()
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: activeContentPreview) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: showFullscreenGallery) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
chatMessagePreview(cItem)
|
||||
chatStatusImage()
|
||||
.padding(.top, dynamicChatInfoSize * 1.44)
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.trailing, 8)
|
||||
|
||||
Spacer()
|
||||
@@ -90,33 +57,6 @@ struct ChatPreviewView: View {
|
||||
.padding(.bottom, -8)
|
||||
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
||||
deleting = contains
|
||||
// Stop voice when deleting the chat
|
||||
if contains, let ci = activeContentPreview?.ci {
|
||||
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
|
||||
}
|
||||
}
|
||||
|
||||
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
|
||||
let playing = chatModel.stopPreviousRecPlay
|
||||
if case .voice = activeContentPreview?.mc, playing == nil {
|
||||
activeContentPreview = nil
|
||||
} else if activeContentPreview == nil {
|
||||
if case .image = mc, let ci, let mc, showFullscreenGallery {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
if case .video = mc, let ci, let mc, showFullscreenGallery {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
} else if case .voice = activeContentPreview?.mc {
|
||||
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
|
||||
activeContentPreview = nil
|
||||
}
|
||||
} else if !showFullscreenGallery {
|
||||
activeContentPreview = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,50 +113,39 @@ struct ChatPreviewView: View {
|
||||
.kerning(-2)
|
||||
}
|
||||
|
||||
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let t = text
|
||||
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, hasFilePreview ? 0 : 8)
|
||||
.padding(.trailing, hasFilePreview ? 38 : 36)
|
||||
.offset(x: hasFilePreview ? -2 : 0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
if !showChatPreviews && !draft {
|
||||
t.privacySensitive(true).redacted(reason: .privacy)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
|
||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSize(userFont).unreadCorner)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||
.padding(.trailing, 1)
|
||||
.foregroundColor(theme.colors.secondary.opacity(0.65))
|
||||
} else {
|
||||
Color.clear.frame(width: 0)
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(10)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.trailing, 1)
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +172,7 @@ struct ChatPreviewView: View {
|
||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
|
||||
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
||||
// can be refactored into a single function if functions calling these are changed to return same type
|
||||
@@ -267,18 +196,18 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
|
||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
|
||||
chatPreviewLayout(messageDraft(draft), draft: true)
|
||||
} else if let cItem = cItem {
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
chatPreviewInfoText("Tap to Connect")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
} else if !contact.sndReady && contact.activeConn != nil {
|
||||
} else if !contact.ready && contact.activeConn != nil {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else if contact.active {
|
||||
@@ -296,54 +225,6 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
|
||||
let mc = ci.content.msgContent
|
||||
switch mc {
|
||||
case let .link(_, preview):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
|
||||
ZStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
.resizable()
|
||||
.foregroundColor(Color.white)
|
||||
.font(.system(size: 15, weight: .black))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
.background(Color.black.opacity(0.25))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(preview.uri)
|
||||
}
|
||||
}
|
||||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
|
||||
}
|
||||
case .file:
|
||||
smallContentPreviewFile(size: dynamicMediaSize) {
|
||||
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
|
||||
groupInfo.membership.memberIncognito
|
||||
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
||||
@@ -372,101 +253,51 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
let size = dynamicSize(userFont).incognitoSize
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if contact.active && contact.activeConn != nil {
|
||||
NetworkStatusView(contact: contact, size: size)
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
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 {
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||
}
|
||||
case .group:
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
|
||||
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
|
||||
if incognito {
|
||||
Image(systemName: "theatermasks")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: size, height: size)
|
||||
.frame(width: 22, height: 22)
|
||||
.foregroundColor(secondaryColor)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(width: size, height: size)
|
||||
.cornerRadius(8)
|
||||
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
|
||||
.padding(.vertical, size / 6)
|
||||
.padding(.leading, 3)
|
||||
.offset(x: 6)
|
||||
}
|
||||
|
||||
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
|
||||
.padding(.vertical, size / 6)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
|
||||
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(width: size, height: size)
|
||||
.padding(.vertical, size / 7)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
|
||||
func unreadCountText(_ n: Int) -> Text {
|
||||
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
|
||||
}
|
||||
|
||||
private struct ActiveContentPreview: Equatable {
|
||||
var chat: Chat
|
||||
var ci: ChatItem
|
||||
var mc: MsgContent
|
||||
|
||||
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
|
||||
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPreviewView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
enum CCInfoAlert: Identifiable {
|
||||
case deleteInvitationAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
|
||||
} success: {
|
||||
dismiss()
|
||||
}
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -13,9 +13,9 @@ struct ContactConnectionView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@State private var localAlias = ""
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var showContactConnectionInfo = false
|
||||
|
||||
var body: some View {
|
||||
if case let .contactConnection(conn) = chat.chatInfo {
|
||||
@@ -31,6 +31,7 @@ struct ContactConnectionView: View {
|
||||
.scaledToFill()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground).asAnotherColorFromSecondaryVariant(theme))
|
||||
.onTapGesture { showContactConnectionInfo = true }
|
||||
}
|
||||
.frame(width: 63, height: 63)
|
||||
.padding(.leading, 4)
|
||||
@@ -61,7 +62,7 @@ struct ContactConnectionView: View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text(contactConnection.description)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize)
|
||||
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
@@ -70,6 +71,9 @@ struct ContactConnectionView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
ContactConnectionInfo(contactConnection: contactConnection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ import SimpleXChat
|
||||
struct ContactRequestView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
var contactRequest: UserContactRequest
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
|
||||
ChatInfoImage(chat: chat, size: 63)
|
||||
.padding(.leading, 4)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// 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,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct ServersSummaryView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var serversSummary: PresentedServersSummary? = nil
|
||||
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
||||
@State private var selectedServerType: PresentedServerType = .smp
|
||||
@@ -59,21 +58,11 @@ struct ServersSummaryView: View {
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
if AppChatState.shared.value == .active {
|
||||
getServersSummary()
|
||||
}
|
||||
getServersSummary()
|
||||
}
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
serversSummary = try getAgentServersSummary()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
@@ -102,8 +91,8 @@ struct ServersSummaryView: View {
|
||||
Group {
|
||||
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
Picker("User selection", selection: $selectedUserCategory) {
|
||||
Text("All profiles").tag(PresentedUserCategory.allUsers)
|
||||
Text("Current profile").tag(PresentedUserCategory.currentUser)
|
||||
Text("All users").tag(PresentedUserCategory.allUsers)
|
||||
Text("Current user").tag(PresentedUserCategory.currentUser)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
@@ -194,22 +183,19 @@ struct ServersSummaryView: View {
|
||||
}
|
||||
} else {
|
||||
Text("No info, try to reload")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.background(theme.colors.background)
|
||||
}
|
||||
}
|
||||
|
||||
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
||||
Section {
|
||||
infoRow("Active connections", numOrDash(totals.subs.ssActive))
|
||||
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
|
||||
infoRow("Total", numOrDash(totals.subs.total))
|
||||
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message reception")
|
||||
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,9 +273,9 @@ struct ServersSummaryView: View {
|
||||
if let subs = srvSumm.subs {
|
||||
Spacer()
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
}
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
} else if let sess = srvSumm.sessions {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.circle")
|
||||
@@ -403,16 +389,24 @@ struct ServersSummaryView: View {
|
||||
Text("Reset all statistics")
|
||||
}
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
serversSummary = try getAgentServersSummary()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionStatusIndicatorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var hasSess: Bool
|
||||
var sess: ServerSessions
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
if #available(iOS 16.0, *) {
|
||||
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||
.foregroundColor(color)
|
||||
@@ -426,18 +420,18 @@ struct SubscriptionStatusIndicatorView: View {
|
||||
struct SubscriptionStatusPercentageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var hasSess: Bool
|
||||
var sess: ServerSessions
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
|
||||
func roundedToQuarter(_ n: Double) -> Double {
|
||||
n >= 1 ? 1
|
||||
: n <= 0 ? 0
|
||||
@@ -448,23 +442,19 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
|
||||
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
|
||||
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
|
||||
|
||||
return !online
|
||||
? noConnColorAndPercent
|
||||
: (
|
||||
subs.total == 0 && !hasSess
|
||||
? (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
|
||||
)
|
||||
return online && subs.total > 0
|
||||
? (
|
||||
subs.ssActive == 0
|
||||
? (
|
||||
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
)
|
||||
: ( // 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 {
|
||||
@@ -507,16 +497,16 @@ struct SMPServerSummaryView: View {
|
||||
|
||||
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
||||
Section {
|
||||
infoRow("Active connections", numOrDash(subs.ssActive))
|
||||
infoRow("Connections subscribed", numOrDash(subs.ssActive))
|
||||
infoRow("Pending", numOrDash(subs.ssPending))
|
||||
infoRow("Total", numOrDash(subs.total))
|
||||
reconnectButton()
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message reception")
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,7 +611,7 @@ struct DetailedSMPStatsView: View {
|
||||
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
|
||||
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
|
||||
}
|
||||
Section("Connections") {
|
||||
Section {
|
||||
infoRow("Created", numOrDash(stats._connCreated))
|
||||
infoRow("Secured", numOrDash(stats._connCreated))
|
||||
infoRow("Completed", numOrDash(stats._connCompleted))
|
||||
@@ -630,12 +620,8 @@ struct DetailedSMPStatsView: View {
|
||||
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
||||
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
|
||||
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
||||
}
|
||||
Section {
|
||||
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
|
||||
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
|
||||
} header: {
|
||||
Text("Connection notifications")
|
||||
Text("Connections")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||
}
|
||||
|
||||
@@ -85,18 +85,15 @@ struct UserPicker: View {
|
||||
.padding(8)
|
||||
.opacity(userPickerVisible ? 1.0 : 0.0)
|
||||
.onAppear {
|
||||
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
|
||||
if case .active = scenePhase {
|
||||
Task {
|
||||
do {
|
||||
let users = try await listUsersAsync()
|
||||
await MainActor.run { m.users = users }
|
||||
} catch {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
do {
|
||||
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
|
||||
if case .active = scenePhase {
|
||||
m.users = try listUsers()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func userView(_ u: UserInfo) -> some View {
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
//
|
||||
// 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):
|
||||
titleText("Incompatible database version")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
@@ -105,6 +105,15 @@ struct DatabaseErrorView: View {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
|
||||
switch err {
|
||||
case let .noDown(dbMigrations):
|
||||
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
|
||||
case let .different(appMigration, dbMigration):
|
||||
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
|
||||
}
|
||||
}
|
||||
|
||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ enum DatabaseAlert: Identifiable {
|
||||
case importArchive
|
||||
case archiveImported
|
||||
case archiveImportedWithErrors(archiveErrors: [ArchiveError])
|
||||
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
|
||||
case deleteChat
|
||||
case chatDeleted
|
||||
case deleteLegacyDatabase
|
||||
@@ -30,7 +29,6 @@ enum DatabaseAlert: Identifiable {
|
||||
case .importArchive: return "importArchive"
|
||||
case .archiveImported: return "archiveImported"
|
||||
case .archiveImportedWithErrors: return "archiveImportedWithErrors"
|
||||
case .archiveExportedWithErrors: return "archiveExportedWithErrors"
|
||||
case .deleteChat: return "deleteChat"
|
||||
case .chatDeleted: return "chatDeleted"
|
||||
case .deleteLegacyDatabase: return "deleteLegacyDatabase"
|
||||
@@ -267,18 +265,10 @@ struct DatabaseView: View {
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database")
|
||||
)
|
||||
case let .archiveImportedWithErrors(errs):
|
||||
case .archiveImportedWithErrors:
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
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])
|
||||
}
|
||||
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.")
|
||||
)
|
||||
case .deleteChat:
|
||||
return Alert(
|
||||
@@ -359,16 +349,9 @@ struct DatabaseView: View {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let (archivePath, archiveErrors) = try await exportChatArchive()
|
||||
if archiveErrors.isEmpty {
|
||||
showShareSheet(items: [archivePath])
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} else {
|
||||
await MainActor.run {
|
||||
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
let archivePath = try await exportChatArchive()
|
||||
showShareSheet(items: [archivePath])
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||
@@ -491,7 +474,7 @@ struct DatabaseView: View {
|
||||
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
|
||||
do {
|
||||
let chats = try apiGetChats()
|
||||
m.updateChats(chats)
|
||||
m.updateChats(with: chats)
|
||||
} catch let error {
|
||||
logger.error("apiGetChats: cannot update chats \(responseError(error))")
|
||||
}
|
||||
@@ -503,17 +486,6 @@ 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 {
|
||||
try await apiStopChat()
|
||||
ChatReceiver.shared.stop()
|
||||
|
||||
@@ -190,7 +190,7 @@ struct MigrateToAppGroupView: View {
|
||||
do {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
_ = try await apiExportArchive(config: config)
|
||||
try await apiExportArchive(config: config)
|
||||
await MainActor.run { setV3DBMigration(.exported) }
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
@@ -222,7 +222,7 @@ struct MigrateToAppGroupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [ArchiveError]) {
|
||||
func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL {
|
||||
let archiveTime = Date.now
|
||||
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
|
||||
let archiveName = "simplex-chat.\(ts).zip"
|
||||
@@ -233,13 +233,13 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
let errs = try await apiExportArchive(config: config)
|
||||
try await apiExportArchive(config: config)
|
||||
if storagePath == nil {
|
||||
deleteOldArchive()
|
||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||
chatArchiveTimeDefault.set(archiveTime)
|
||||
}
|
||||
return (archivePath, errs)
|
||||
return archivePath
|
||||
}
|
||||
|
||||
func deleteOldArchive() {
|
||||
|
||||
@@ -8,21 +8,41 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class AppSheetState: ObservableObject {
|
||||
static let shared = AppSheetState()
|
||||
@Published var scenePhaseActive: Bool = false
|
||||
private struct SheetIsPresented<C>: ViewModifier where C: View {
|
||||
var isPresented: Binding<Bool>
|
||||
var onDismiss: (() -> Void)?
|
||||
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 {
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
// 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
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if !protectScreen {
|
||||
if case .active = scenePhase {
|
||||
content
|
||||
} else {
|
||||
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
|
||||
content.privacySensitive(protectScreen).redacted(reason: .privacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +53,7 @@ extension View {
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
content: @escaping () -> Content
|
||||
) -> some View where Content: View {
|
||||
sheet(isPresented: isPresented, onDismiss: onDismiss) {
|
||||
content().modifier(PrivacySensitive())
|
||||
}
|
||||
modifier(SheetIsPresented(isPresented: isPresented, onDismiss: onDismiss, sheetContent: content))
|
||||
}
|
||||
|
||||
func appSheet<T, Content>(
|
||||
@@ -43,8 +61,6 @@ extension View {
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
content: @escaping (T) -> Content
|
||||
) -> some View where T: Identifiable, Content: View {
|
||||
sheet(item: item, onDismiss: onDismiss) { it in
|
||||
content(it).modifier(PrivacySensitive())
|
||||
}
|
||||
modifier(SheetForItem(item: item, onDismiss: onDismiss, sheetContent: content))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,18 @@ struct ChatInfoImage: View {
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
var body: some View {
|
||||
var iconName: String
|
||||
switch chat.chatInfo {
|
||||
case .direct: iconName = "person.crop.circle.fill"
|
||||
case .group: iconName = "person.2.circle.fill"
|
||||
case .local: iconName = "folder.circle.fill"
|
||||
case .contactRequest: iconName = "person.crop.circle.fill"
|
||||
default: iconName = "circle.fill"
|
||||
}
|
||||
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
|
||||
return ProfileImage(
|
||||
imageStr: chat.chatInfo.image,
|
||||
iconName: chatIconName(chat.chatInfo),
|
||||
iconName: iconName,
|
||||
size: size,
|
||||
color: iconColor
|
||||
)
|
||||
|
||||
@@ -11,31 +11,20 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatViewBackground: ViewModifier {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var image: Image
|
||||
var imageType: WallpaperType
|
||||
var background: Color
|
||||
var tint: Color
|
||||
|
||||
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(
|
||||
Canvas { context, size in
|
||||
var image = context.resolve(image)
|
||||
let rect = CGRectMake(0, 0, size.width, size.height)
|
||||
func repeatDraw(_ imageScale: CGFloat) {
|
||||
// Prevent range bounds crash and dividing by zero
|
||||
if size.height == 0 || size.width == 0 || image.size.height == 0 || image.size.width == 0 { return }
|
||||
image.shading = .color(tint)
|
||||
let scale = imageScale * 2.5 // scale wallpaper for iOS
|
||||
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
|
||||
for h in 0 ... Int(size.height / image.size.height / scale) {
|
||||
for w in 0 ... Int(size.width / image.size.width / scale) {
|
||||
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
|
||||
@@ -90,7 +79,7 @@ struct ChatViewBackground: ViewModifier {
|
||||
case WallpaperType.empty: ()
|
||||
}
|
||||
}
|
||||
).ignoresSafeArea(.all)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +89,7 @@ extension PresetWallpaper {
|
||||
scale
|
||||
} else if let type = ChatModel.shared.currentUser?.uiThemes?.preferredMode(base.mode == DefaultThemeMode.dark)?.wallpaper?.toAppWallpaper().type, type.sameType(WallpaperType.preset(filename, nil)) {
|
||||
type.scale
|
||||
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename && $0.base == base })?.wallpaper?.scale {
|
||||
} else if let scale = themeOverridesDefault.get().first(where: { $0.wallpaper != nil && $0.wallpaper!.preset == filename })?.wallpaper?.scale {
|
||||
scale
|
||||
} else {
|
||||
Float(1.0)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// 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,17 +7,16 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct NavLinkPlain<Label: View>: View {
|
||||
let chatId: ChatId
|
||||
@Binding var selection: ChatId?
|
||||
struct NavLinkPlain<V: Hashable, Label: View>: View {
|
||||
@State var tag: V
|
||||
@Binding var selection: V?
|
||||
@ViewBuilder var label: () -> Label
|
||||
var disabled = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button("") { ItemsModel.shared.loadOpenChat(chatId) }
|
||||
Button("") { DispatchQueue.main.async { selection = tag } }
|
||||
.disabled(disabled)
|
||||
label()
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ struct NavStackCompat <C: View, D: View>: View {
|
||||
if #available(iOS 16, *) {
|
||||
NavigationStack(path: Binding(
|
||||
get: { isActive.wrappedValue ? [true] : [] },
|
||||
set: { path in
|
||||
if path.isEmpty { isActive.wrappedValue = false }
|
||||
}
|
||||
set: { _ in }
|
||||
)) {
|
||||
ZStack {
|
||||
NavigationLink(value: true) { EmptyView() }
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let defaultProfileImageCorner = 22.5
|
||||
|
||||
struct ProfileImage: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var imageStr: String? = nil
|
||||
@@ -16,12 +18,13 @@ struct ProfileImage: View {
|
||||
var size: CGFloat
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
var backgroundColor: Color? = nil
|
||||
var blurred = false
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||
|
||||
var body: some View {
|
||||
if let uiImage = UIImage(base64Encoded: imageStr) {
|
||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
|
||||
if let image = imageStr,
|
||||
let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
|
||||
} else {
|
||||
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
||||
Image(systemName: iconName)
|
||||
@@ -33,6 +36,26 @@ struct ProfileImage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private let squareToCircleRatio = 0.935
|
||||
|
||||
private let radiusFactor = (1 - squareToCircleRatio) / 50
|
||||
|
||||
@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
|
||||
let v = img.resizable()
|
||||
if radius >= 50 {
|
||||
v.frame(width: size, height: size).clipShape(Circle())
|
||||
} else if radius <= 0 {
|
||||
let sz = size * squareToCircleRatio
|
||||
v.frame(width: sz, height: sz).padding((size - sz) / 2)
|
||||
} else {
|
||||
let sz = size * (squareToCircleRatio + radius * radiusFactor)
|
||||
v.frame(width: sz, height: sz)
|
||||
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
|
||||
.padding((size - sz) / 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Color {
|
||||
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
|
||||
return self
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// VideoUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 25.12.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
||||
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
||||
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
||||
s.outputURL = outputUrl
|
||||
s.outputFileType = .mp4
|
||||
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
await s.export()
|
||||
if let err = s.error {
|
||||
logger.error("Failed to export video with error: \(err)")
|
||||
}
|
||||
return s.status == .completed
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -9,45 +9,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
@ViewBuilder func `if`<Content: View>(_ condition: @autoclosure () -> Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition() {
|
||||
transform(self)
|
||||
} else {
|
||||
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,8 @@ struct LocalAuthView: View {
|
||||
deleteAppDatabaseAndFiles()
|
||||
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
|
||||
m.chatId = nil
|
||||
ItemsModel.shared.reversedChatItems = []
|
||||
m.updateChats([])
|
||||
m.reversedChatItems = []
|
||||
m.chats = []
|
||||
m.users = []
|
||||
_ = kcAppPassword.set(password)
|
||||
_ = kcSelfDestructPassword.remove()
|
||||
|
||||
@@ -32,7 +32,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
case keychainError(_ title: LocalizedStringKey = "Keychain error")
|
||||
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
|
||||
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
|
||||
case archiveExportedWithErrors(archivePath: URL, archiveErrors: [ArchiveError])
|
||||
|
||||
case error(title: LocalizedStringKey, error: String = "")
|
||||
|
||||
@@ -46,7 +45,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
|
||||
case .keychainError: return "keychainError"
|
||||
case let .databaseError(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)"
|
||||
}
|
||||
@@ -168,14 +166,6 @@ struct MigrateFromDevice: View {
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
case let .unknownError(title, 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):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
@@ -459,12 +449,15 @@ struct MigrateFromDevice: View {
|
||||
Task {
|
||||
do {
|
||||
try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true)
|
||||
let (archivePath, errs) = try await exportChatArchive(getMigrationTempFilesDirectory())
|
||||
if errs.isEmpty {
|
||||
await uploadArchive(path: archivePath)
|
||||
let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory())
|
||||
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 = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: errs)
|
||||
alert = .error(title: "Exported file doesn't exist")
|
||||
migrationState = .uploadConfirmation
|
||||
}
|
||||
}
|
||||
@@ -476,20 +469,6 @@ 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)? {
|
||||
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
|
||||
@@ -683,7 +662,7 @@ private struct PassphraseConfirmationView: View {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
|
||||
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
|
||||
} else {
|
||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
|
||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
|
||||
case let .migrationError(mtrError):
|
||||
("Incompatible database version",
|
||||
nil,
|
||||
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
|
||||
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
|
||||
nil)
|
||||
}
|
||||
default: ("Error", nil, "Unknown error", nil)
|
||||
|
||||
@@ -30,7 +30,7 @@ struct AddContactLearnMore: View {
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,37 +35,40 @@ struct AddGroupView: View {
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||
}
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
showTitle: true,
|
||||
creatingGroup: true
|
||||
) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||
}
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Group link")
|
||||
}
|
||||
} else {
|
||||
createGroupView()
|
||||
createGroupView().keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
func createGroupView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
Text("Create secret group")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 24)
|
||||
.onTapGesture(perform: hideKeyboard)
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: profile.image, size: 128)
|
||||
@@ -201,14 +204,13 @@ struct AddGroupView: View {
|
||||
chat = c
|
||||
}
|
||||
} catch {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error creating group"),
|
||||
message: Text(responseError(error))
|
||||
)
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error creating group"),
|
||||
message: Text(responseError(error))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,499 +7,53 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum ContactType: Int {
|
||||
case card, request, recent, chatDeleted, unlisted
|
||||
enum NewChatMenuOption: Identifiable {
|
||||
case newContact
|
||||
case scanPaste
|
||||
case newGroup
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
struct NewChatMenuButton: View {
|
||||
@State private var showNewChatSheet = false
|
||||
@State private var alert: SomeAlert? = nil
|
||||
@State private var globalAlert: SomeAlert? = nil
|
||||
@Binding var newChatMenuOption: NewChatMenuOption?
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button {
|
||||
showNewChatSheet = true
|
||||
newChatMenuOption = .newContact
|
||||
} label: {
|
||||
Text("Add contact")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .scanPaste
|
||||
} label: {
|
||||
Text("Scan / Paste link")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .newGroup
|
||||
} label: {
|
||||
Text("Create group")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.appSheet(isPresented: $showNewChatSheet) {
|
||||
NewChatSheet(alert: $alert)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
.alert(item: $alert) { a in
|
||||
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
|
||||
.sheet(item: $newChatMenuOption) { opt in
|
||||
switch opt {
|
||||
case .newContact: NewChatView(selection: .invite)
|
||||
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
case .newGroup: AddGroupView()
|
||||
}
|
||||
}
|
||||
.alert(item: $globalAlert) { a in
|
||||
return a.alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var indent: CGFloat = 36
|
||||
|
||||
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()
|
||||
NewChatMenuButton(
|
||||
newChatMenuOption: Binding.constant(nil)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,19 +17,10 @@ struct SomeAlert: Identifiable {
|
||||
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 {
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case newChatSomeAlert(alert: SomeAlert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||
@@ -56,10 +47,22 @@ struct NewChatView: View {
|
||||
@State private var creatingConnReq = false
|
||||
@State private var pastedLink: String = ""
|
||||
@State private var alert: NewChatViewAlert?
|
||||
@Binding var parentAlert: SomeAlert?
|
||||
|
||||
var body: some View {
|
||||
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) {
|
||||
Label("Add contact", systemImage: "link")
|
||||
.tag(NewChatOption.invite)
|
||||
@@ -85,7 +88,6 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.background(
|
||||
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
|
||||
Rectangle()
|
||||
@@ -108,13 +110,6 @@ struct NewChatView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
InfoSheetButton {
|
||||
AddContactLearnMore(showTitle: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.onChange(of: invitationUsed) { used in
|
||||
if used && !(m.showingInvitation?.connChatUsed ?? true) {
|
||||
@@ -124,22 +119,19 @@ struct NewChatView: View {
|
||||
.onDisappear {
|
||||
if !(m.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
parentAlert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
}
|
||||
))
|
||||
}
|
||||
m.showingInvitation = nil
|
||||
}
|
||||
@@ -845,10 +837,7 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
if ok {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
_ = await connectContactViaAddress(contact.contactId, incognito)
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
@@ -898,11 +887,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
@@ -917,11 +906,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
@@ -972,13 +961,8 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
struct NewChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var parentAlert: SomeAlert?
|
||||
|
||||
NewChatView(
|
||||
selection: .invite,
|
||||
parentAlert: $parentAlert
|
||||
)
|
||||
}
|
||||
#Preview {
|
||||
NewChatView(
|
||||
selection: .invite
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ struct CreateProfile: View {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ struct CreateFirstProfile: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
|
||||
@@ -15,10 +15,9 @@ private struct VersionDescription {
|
||||
}
|
||||
|
||||
private struct FeatureDescription {
|
||||
var icon: String?
|
||||
var icon: String
|
||||
var title: LocalizedStringKey
|
||||
var description: LocalizedStringKey?
|
||||
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
|
||||
var description: LocalizedStringKey
|
||||
}
|
||||
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
@@ -429,44 +428,6 @@ 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
|
||||
@@ -492,37 +453,35 @@ struct WhatsNewView: View {
|
||||
VStack {
|
||||
TabView(selection: $currentVersion) {
|
||||
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("New in \(v.version)")
|
||||
.font(.title)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
ForEach(v.features, id: \.title) { f in
|
||||
featureDescription(f)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
HStack {
|
||||
Text("Read more")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("New in \(v.version)")
|
||||
.font(.title)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
ForEach(v.features, id: \.icon) { f in
|
||||
featureDescription(f.icon, f.title, f.description)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
HStack {
|
||||
Text("Read more")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
}
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
Button("Ok") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
Button("Ok") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.tag(i)
|
||||
}
|
||||
}
|
||||
@@ -536,37 +495,18 @@ struct WhatsNewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func featureDescription(_ f: FeatureDescription) -> some View {
|
||||
private func featureDescription(_ icon: String, _ title: LocalizedStringKey, _ description: LocalizedStringKey) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let icon = f.icon {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
Text(description)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
|
||||
case badInvitationError
|
||||
case badVersionError(version: String?)
|
||||
case desktopDisconnectedError
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
|
||||
case .desktopDisconnectedError:
|
||||
Alert(title: Text("Connection terminated"))
|
||||
case let .error(title, error):
|
||||
mkAlert(title: title, message: error)
|
||||
Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||
|
||||
@@ -24,114 +24,34 @@ enum NetworkSettingsAlert: Identifiable {
|
||||
}
|
||||
|
||||
struct AdvancedNetworkSettings: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@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 currentNetCfg = NetCfg.defaults
|
||||
@State private var cfgLoaded = false
|
||||
@State private var enableKeepAlive = true
|
||||
@State private var keepAliveOpts = KeepAliveOpts.defaults
|
||||
@State private var showSettingsAlert: NetworkSettingsAlert?
|
||||
@State private var onionHosts: OnionHosts = .no
|
||||
@State private var showSaveDialog = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
List {
|
||||
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)
|
||||
Button {
|
||||
updateNetCfgView(NetCfg.defaults)
|
||||
showSettingsAlert = .update
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Private routing")
|
||||
Spacer()
|
||||
Text(netCfg.smpProxyMode.label)
|
||||
}
|
||||
Text("Reset to defaults")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
List {
|
||||
Section {
|
||||
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)
|
||||
.disabled(currentNetCfg == NetCfg.defaults)
|
||||
|
||||
Button {
|
||||
updateNetCfgView(NetCfg.proxyDefaults)
|
||||
showSettingsAlert = .update
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Allow downgrade")
|
||||
Spacer()
|
||||
Text(netCfg.smpProxyFallback.label)
|
||||
}
|
||||
Text("Set timeouts for proxy/VPN")
|
||||
}
|
||||
.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("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)
|
||||
@@ -152,21 +72,24 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset to defaults") {
|
||||
updateNetCfgView(NetCfg.defaults)
|
||||
}
|
||||
.disabled(netCfg == NetCfg.defaults)
|
||||
} header: {
|
||||
Text("")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
HStack {
|
||||
Button {
|
||||
updateNetCfgView(currentNetCfg)
|
||||
} label: {
|
||||
Label("Revert", systemImage: "arrow.counterclockwise").font(.callout)
|
||||
}
|
||||
|
||||
Button("Set timeouts for proxy/VPN") {
|
||||
updateNetCfgView(netCfg.withProxyTimeouts)
|
||||
}
|
||||
.disabled(netCfg.hasProxyTimeouts)
|
||||
|
||||
Button("Save and reconnect") {
|
||||
showSettingsAlert = .update
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showSettingsAlert = .update
|
||||
} label: {
|
||||
Label("Save", systemImage: "checkmark").font(.callout)
|
||||
}
|
||||
}
|
||||
.disabled(netCfg == currentNetCfg)
|
||||
}
|
||||
@@ -188,10 +111,10 @@ struct AdvancedNetworkSettings: View {
|
||||
switch a {
|
||||
case .update:
|
||||
return Alert(
|
||||
title: Text("Update settings?"),
|
||||
title: Text("Update network settings?"),
|
||||
message: Text("Updating settings will re-connect the client to all servers."),
|
||||
primaryButton: .default(Text("Ok")) {
|
||||
_ = saveNetCfg()
|
||||
saveNetCfg()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -202,43 +125,23 @@ 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) {
|
||||
netCfg = cfg
|
||||
onionHosts = OnionHosts(netCfg: netCfg)
|
||||
enableKeepAlive = netCfg.enableKeepAlive
|
||||
keepAliveOpts = netCfg.tcpKeepAlive ?? KeepAliveOpts.defaults
|
||||
}
|
||||
|
||||
private func saveNetCfg() -> Bool {
|
||||
private func saveNetCfg() {
|
||||
do {
|
||||
try setNetworkConfig(netCfg)
|
||||
currentNetCfg = netCfg
|
||||
setNetCfg(netCfg)
|
||||
return true
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
showSettingsAlert = .error(err: err)
|
||||
logger.error("\(err)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,38 +164,6 @@ struct AdvancedNetworkSettings: View {
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -28,14 +28,10 @@ extension AppSettings {
|
||||
privacyAcceptImagesGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
|
||||
}
|
||||
if let val = privacyLinkPreviews {
|
||||
privacyLinkPreviewsGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
||||
}
|
||||
if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_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 = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
|
||||
if let val = privacyMediaBlurRadius { def.setValue(val, forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) }
|
||||
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
|
||||
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
|
||||
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
|
||||
@@ -48,15 +44,11 @@ extension AppSettings {
|
||||
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 = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
|
||||
if let val = uiProfileImageCornerRadius {
|
||||
profileImageCornerRadiusGroupDefault.set(val)
|
||||
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
}
|
||||
if let val = uiProfileImageCornerRadius { def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) }
|
||||
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 = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
|
||||
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 {
|
||||
@@ -70,7 +62,6 @@ extension AppSettings {
|
||||
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
||||
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
||||
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
|
||||
c.privacyMediaBlurRadius = def.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS)
|
||||
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
|
||||
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
@@ -83,12 +74,11 @@ extension AppSettings {
|
||||
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
|
||||
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
|
||||
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
|
||||
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
|
||||
c.uiColorScheme = currentThemeDefault.get()
|
||||
c.uiDarkColorScheme = systemDarkThemeDefault.get()
|
||||
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
||||
c.uiThemes = themeOverridesDefault.get()
|
||||
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ struct AppearanceSettings: View {
|
||||
}()
|
||||
@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(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?)? = {
|
||||
if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil {
|
||||
@@ -64,16 +62,6 @@ 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 {
|
||||
ThemeDestinationPicker(themeUserDestination: $themeUserDestination, themeUserDest: themeUserDestination?.0, customizeThemeIsOpen: $customizeThemeIsOpen)
|
||||
|
||||
@@ -155,8 +143,7 @@ struct AppearanceSettings: View {
|
||||
Text("Themes")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.onChange(of: profileImageCornerRadius) { cornerRadius in
|
||||
profileImageCornerRadiusGroupDefault.set(cornerRadius)
|
||||
.onChange(of: profileImageCornerRadius) { _ in
|
||||
saveThemeToDatabase(nil)
|
||||
}
|
||||
.onChange(of: colorMode) { mode in
|
||||
@@ -303,43 +290,6 @@ 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 {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var base: DefaultTheme
|
||||
|
||||
@@ -13,8 +13,6 @@ struct DeveloperView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
|
||||
@State private var hintsUnchanged = hintDefaultsUnchanged()
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
@@ -35,9 +33,8 @@ struct DeveloperView: View {
|
||||
} label: {
|
||||
settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") }
|
||||
}
|
||||
settingsRow("lightbulb.max", color: theme.colors.secondary) {
|
||||
Button("Reset all hints", action: resetHintDefaults)
|
||||
.disabled(hintsUnchanged)
|
||||
settingsRow("internaldrive", color: theme.colors.secondary) {
|
||||
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
|
||||
}
|
||||
settingsRow("chevron.left.forwardslash.chevron.right", color: theme.colors.secondary) {
|
||||
Toggle("Show developer options", isOn: $developerTools)
|
||||
@@ -48,34 +45,9 @@ struct DeveloperView: View {
|
||||
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
|
||||
.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 {
|
||||
|
||||
@@ -10,10 +10,18 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private enum NetworkAlert: Identifiable {
|
||||
case updateOnionHosts(hosts: OnionHosts)
|
||||
case updateSessionMode(mode: TransportSessionMode)
|
||||
case updateSMPProxyMode(proxyMode: SMPProxyMode)
|
||||
case updateSMPProxyFallback(proxyFallback: SMPProxyFallback)
|
||||
case error(err: String)
|
||||
|
||||
var id: String {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +30,17 @@ private enum NetworkAlert: Identifiable {
|
||||
struct NetworkAndServers: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@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 {
|
||||
VStack {
|
||||
@@ -32,7 +51,7 @@ struct NetworkAndServers: View {
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Message servers")
|
||||
Text("SMP servers")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
@@ -40,12 +59,26 @@ struct NetworkAndServers: View {
|
||||
.navigationTitle("Your XFTP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Media & file servers")
|
||||
Text("XFTP 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 {
|
||||
AdvancedNetworkSettings()
|
||||
.navigationTitle("Advanced settings")
|
||||
.navigationTitle("Network settings")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Advanced network settings")
|
||||
@@ -53,6 +86,35 @@ struct NetworkAndServers: View {
|
||||
} header: {
|
||||
Text("Messages & files")
|
||||
.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)) {
|
||||
@@ -74,6 +136,147 @@ 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,7 +22,6 @@ struct PrivacySettings: View {
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
|
||||
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var privacyMediaBlurRadius: Int = 0
|
||||
@State private var contactReceipts = false
|
||||
@State private var contactReceiptsReset = false
|
||||
@State private var contactReceiptsOverrides = 0
|
||||
@@ -70,9 +69,6 @@ struct PrivacySettings: View {
|
||||
Section {
|
||||
settingsRow("network", color: theme.colors.secondary) {
|
||||
Toggle("Send link previews", isOn: $useLinkPreviews)
|
||||
.onChange(of: useLinkPreviews) { linkPreviews in
|
||||
privacyLinkPreviewsGroupDefault.set(linkPreviews)
|
||||
}
|
||||
}
|
||||
settingsRow("message", color: theme.colors.secondary) {
|
||||
Toggle("Show last messages", isOn: $showChatPreviews)
|
||||
@@ -117,22 +113,6 @@ struct PrivacySettings: View {
|
||||
privacyAcceptImagesGroupDefault.set($0)
|
||||
}
|
||||
}
|
||||
settingsRow("circle.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) {
|
||||
Toggle("Protect IP address", isOn: $askToApproveRelays)
|
||||
}
|
||||
@@ -368,7 +348,6 @@ struct SimplexLockView: View {
|
||||
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
|
||||
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
|
||||
@AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false
|
||||
@State private var performLAToggleReset = false
|
||||
@State private var performLAModeReset = false
|
||||
@State private var performLASelfDestructReset = false
|
||||
@@ -440,12 +419,6 @@ struct SimplexLockView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if performLA {
|
||||
Section("Share to SimpleX") {
|
||||
Toggle("Allow sharing", isOn: $allowShareExtension)
|
||||
}
|
||||
}
|
||||
|
||||
if performLA && laMode == .passcode {
|
||||
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
|
||||
Toggle(isOn: $selfDestruct) {
|
||||
@@ -470,7 +443,6 @@ struct SimplexLockView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: performLA) { performLAToggle in
|
||||
appLocalAuthEnabledGroupDefault.set(performLAToggle)
|
||||
prefLANoticeShown = true
|
||||
if performLAToggleReset {
|
||||
performLAToggleReset = false
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ProtocolServerView: View {
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@State var serverEnabled: Bool
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
@@ -112,10 +113,10 @@ struct ProtocolServerView: View {
|
||||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
.disabled(useForNewDisabled)
|
||||
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
|
||||
Toggle("Use for new connections", isOn: $serverEnabled)
|
||||
.onChange(of: serverEnabled) { enabled in
|
||||
serverToEdit.enabled = enabled ? .enabled : .disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,12 +176,17 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
|
||||
}
|
||||
}
|
||||
|
||||
func serverHostname(_ srv: String) -> String {
|
||||
parseServerAddress(srv)?.hostnames.first ?? srv
|
||||
}
|
||||
|
||||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
serverToEdit: ServerCfg.sampleData.custom,
|
||||
serverEnabled: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ struct ProtocolServersView: View {
|
||||
@Environment(\.editMode) private var editMode
|
||||
let serverProtocol: ServerProtocol
|
||||
@State private var currServers: [ServerCfg] = []
|
||||
@State private var presetServers: [ServerCfg] = []
|
||||
@State private var configuredServers: [ServerCfg] = []
|
||||
@State private var otherServers: [ServerCfg] = []
|
||||
@State private var presetServers: [String] = []
|
||||
@State private var servers: [ServerCfg] = []
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var showScanProtoServer = false
|
||||
@@ -54,53 +53,31 @@ struct ProtocolServersView: View {
|
||||
|
||||
private func protocolServersView() -> some View {
|
||||
List {
|
||||
if !configuredServers.isEmpty {
|
||||
Section {
|
||||
ForEach($configuredServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
configuredServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Configured \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
|
||||
if !otherServers.isEmpty {
|
||||
Section {
|
||||
ForEach($otherServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
otherServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
otherServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Other \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Add server") {
|
||||
ForEach($servers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
servers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
servers.remove(atOffsets: indexSet)
|
||||
}
|
||||
Button("Add server…") {
|
||||
showAddServer = true
|
||||
}
|
||||
} header: {
|
||||
Text("\(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset") { partitionServers(currServers) }
|
||||
.disabled(Set(allServers) == Set(currServers) || testing)
|
||||
Button("Reset") { servers = currServers }
|
||||
.disabled(servers == currServers || testing)
|
||||
Button("Test servers", action: testServers)
|
||||
.disabled(testing || allServersDisabled)
|
||||
Button("Save servers", action: saveServers)
|
||||
@@ -109,17 +86,17 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") {
|
||||
otherServers.append(ServerCfg.empty)
|
||||
selectedServer = allServers.last?.id
|
||||
servers.append(ServerCfg.empty)
|
||||
selectedServer = servers.last?.id
|
||||
}
|
||||
Button("Scan server QR code") { showScanProtoServer = true }
|
||||
Button("Add preset servers", action: addAllPresets)
|
||||
.disabled(hasAllPresets())
|
||||
}
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(servers: $otherServers)
|
||||
ScanProtocolServer(servers: $servers)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
@@ -130,7 +107,7 @@ struct ProtocolServersView: View {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
||||
.confirmationDialog("Save servers?", isPresented: $showSaveDialog) {
|
||||
Button("Save") {
|
||||
saveServers()
|
||||
dismiss()
|
||||
@@ -156,39 +133,27 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
.onAppear {
|
||||
// this condition is needed to prevent re-setting the servers when exiting single server view
|
||||
if justOpened {
|
||||
do {
|
||||
let r = try getUserProtoServers(serverProtocol)
|
||||
currServers = r.protoServers
|
||||
presetServers = r.presetServers
|
||||
partitionServers(currServers)
|
||||
} catch let error {
|
||||
alert = .error(
|
||||
title: "Error loading \(proto) servers",
|
||||
error: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
justOpened = false
|
||||
} else {
|
||||
partitionServers(allServers)
|
||||
if !justOpened { return }
|
||||
do {
|
||||
let r = try getUserProtoServers(serverProtocol)
|
||||
currServers = r.protoServers
|
||||
presetServers = r.presetServers
|
||||
servers = currServers
|
||||
} catch let error {
|
||||
alert = .error(
|
||||
title: "Error loading \(proto) servers",
|
||||
error: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
}
|
||||
|
||||
private func partitionServers(_ servers: [ServerCfg]) {
|
||||
configuredServers = servers.filter { $0.preset || $0.enabled }
|
||||
otherServers = servers.filter { !($0.preset || $0.enabled) }
|
||||
}
|
||||
|
||||
private var allServers: [ServerCfg] {
|
||||
configuredServers + otherServers
|
||||
}
|
||||
|
||||
private var saveDisabled: Bool {
|
||||
allServers.isEmpty ||
|
||||
Set(allServers) == Set(currServers) ||
|
||||
servers.isEmpty ||
|
||||
servers == currServers ||
|
||||
testing ||
|
||||
!allServers.allSatisfy { srv in
|
||||
!servers.allSatisfy { srv in
|
||||
if let address = parseServerAddress(srv.server) {
|
||||
return uniqueAddress(srv, address)
|
||||
}
|
||||
@@ -198,7 +163,7 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
allServers.allSatisfy { !$0.enabled }
|
||||
servers.allSatisfy { $0.enabled != .enabled }
|
||||
}
|
||||
|
||||
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
||||
@@ -207,7 +172,8 @@ struct ProtocolServersView: View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: serverProtocol,
|
||||
server: server,
|
||||
serverToEdit: srv
|
||||
serverToEdit: srv,
|
||||
serverEnabled: srv.enabled == .enabled
|
||||
)
|
||||
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
@@ -221,7 +187,7 @@ struct ProtocolServersView: View {
|
||||
invalidServer()
|
||||
} else if !uniqueAddress(srv, address) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if !srv.enabled {
|
||||
} else if srv.enabled != .enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
showTestStatus(server: srv)
|
||||
@@ -234,7 +200,7 @@ struct ProtocolServersView: View {
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
||||
if srv.enabled {
|
||||
if srv.enabled == .enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(theme.colors.secondary)
|
||||
@@ -261,7 +227,7 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
|
||||
allServers.allSatisfy { srv in
|
||||
servers.allSatisfy { srv in
|
||||
address.hostnames.allSatisfy { host in
|
||||
srv.id == s.id || !srv.server.contains(host)
|
||||
}
|
||||
@@ -275,13 +241,13 @@ struct ProtocolServersView: View {
|
||||
private func addAllPresets() {
|
||||
for srv in presetServers {
|
||||
if !hasPreset(srv) {
|
||||
configuredServers.append(srv)
|
||||
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: .enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasPreset(_ srv: ServerCfg) -> Bool {
|
||||
allServers.contains(where: { $0.server == srv.server })
|
||||
private func hasPreset(_ srv: String) -> Bool {
|
||||
servers.contains(where: { $0.server == srv })
|
||||
}
|
||||
|
||||
private func testServers() {
|
||||
@@ -299,31 +265,19 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
configuredServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
otherServers[i].tested = nil
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled == .enabled {
|
||||
servers[i].tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $configuredServers[i]) {
|
||||
fs[serverHostname(configuredServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $otherServers[i]) {
|
||||
fs[serverHostname(otherServers[i].server)] = f
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled == .enabled {
|
||||
if let f = await testServerConnection(server: $servers[i]) {
|
||||
fs[serverHostname(servers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,9 +287,9 @@ struct ProtocolServersView: View {
|
||||
func saveServers() {
|
||||
Task {
|
||||
do {
|
||||
try await setUserProtoServers(serverProtocol, servers: allServers)
|
||||
try await setUserProtoServers(serverProtocol, servers: servers)
|
||||
await MainActor.run {
|
||||
currServers = allServers
|
||||
currServers = servers
|
||||
editMode?.wrappedValue = .inactive
|
||||
}
|
||||
} catch let error {
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ScanProtocolServer: View {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: .enabled))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
|
||||
@@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
|
||||
|
||||
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
|
||||
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
|
||||
let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group
|
||||
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
|
||||
let DEFAULT_LA_MODE = "localAuthenticationMode"
|
||||
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
|
||||
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
|
||||
@@ -28,13 +28,12 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
|
||||
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
|
||||
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
|
||||
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
|
||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
|
||||
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
|
||||
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
|
||||
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
||||
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
||||
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
||||
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
||||
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
|
||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||
@@ -47,8 +46,6 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
|
||||
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // 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_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||
@@ -63,8 +60,6 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
|
||||
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
|
||||
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
|
||||
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_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
|
||||
|
||||
@@ -92,14 +87,11 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
||||
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
DEFAULT_ENCRYPTION_STARTED: false,
|
||||
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_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
||||
@@ -110,8 +102,6 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
|
||||
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: 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_SUBSCRIPTION_PERCENTAGE: false,
|
||||
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
|
||||
@@ -122,18 +112,6 @@ let appDefaults: [String: Any] = [
|
||||
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
|
||||
enum ConnectViaLinkTab: String {
|
||||
case scan
|
||||
@@ -178,9 +156,6 @@ let onboardingStageDefault = EnumDefault<OnboardingStage>(defaults: UserDefaults
|
||||
|
||||
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 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: [:] )
|
||||
@@ -188,9 +163,6 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def
|
||||
|
||||
func setGroupDefaults() {
|
||||
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 {
|
||||
|
||||
@@ -30,7 +30,7 @@ struct UserAddressView: View {
|
||||
case deleteAddress
|
||||
case profileAddress(on: Bool)
|
||||
case shareOnCreate
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -185,7 +185,7 @@ struct UserAddressView: View {
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .error(title, error):
|
||||
return mkAlert(title: title, message: error)
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ struct UserProfilesView: View {
|
||||
case hiddenProfilesNotice
|
||||
case muteProfileAlert
|
||||
case activateUserError(error: String)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -172,7 +172,7 @@ struct UserProfilesView: View {
|
||||
message: Text(err)
|
||||
)
|
||||
case let .error(title, error):
|
||||
return mkAlert(title: title, message: error)
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="!1 colored!" xml:space="preserve" approved="no">
|
||||
<source>!1 colored!</source>
|
||||
<target state="translated">! 1 مُلوَّن!</target>
|
||||
<target state="translated">! 1 ملون!</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="#secret#" xml:space="preserve" approved="no">
|
||||
@@ -69,7 +69,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is not verified" xml:space="preserve" approved="no">
|
||||
<source>%@ is not verified</source>
|
||||
<target state="translated">%@ لم يتم التحقق منه</target>
|
||||
<target state="translated">%@ لم يتم التحقق منها</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is verified" xml:space="preserve" approved="no">
|
||||
@@ -107,9 +107,8 @@
|
||||
<target state="translated">%d ثانية</target>
|
||||
<note>message ttl</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%d skipped message(s)" xml:space="preserve">
|
||||
<source>%d skipped message(s)</source>
|
||||
<target state="translated">%d الرسائل المتخطية</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld" xml:space="preserve" approved="no">
|
||||
@@ -122,14 +121,12 @@
|
||||
<target state="needs-translation">%lld %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve">
|
||||
<source>%lld contact(s) selected</source>
|
||||
<target state="translated">%lld تم اختيار جهات الاتصال</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%lld file(s) with total size of %@" xml:space="preserve">
|
||||
<source>%lld file(s) with total size of %@</source>
|
||||
<target state="translated">%lld الملفات ذات الحجم الإجمالي %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld members" xml:space="preserve" approved="no">
|
||||
@@ -137,9 +134,8 @@
|
||||
<target state="translated">%lld أعضاء</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld second(s)" xml:space="preserve" approved="no">
|
||||
<trans-unit id="%lld second(s)" xml:space="preserve">
|
||||
<source>%lld second(s)</source>
|
||||
<target state="translated">%lld ثوانى</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldd" xml:space="preserve" approved="no">
|
||||
@@ -189,7 +185,7 @@
|
||||
</trans-unit>
|
||||
<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>
|
||||
<target state="translated">** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بكم.</target>
|
||||
<target state="translated">** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بك.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
||||
@@ -199,12 +195,12 @@
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
@@ -214,7 +210,7 @@
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
@@ -309,7 +305,7 @@
|
||||
</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">
|
||||
<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>
|
||||
</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">
|
||||
@@ -359,29 +355,24 @@
|
||||
<source>Accept requests</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add preset servers" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add preset servers" xml:space="preserve">
|
||||
<source>Add preset servers</source>
|
||||
<target state="translated">إضافة خوادم محددة مسبقا</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add profile" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add profile" xml:space="preserve">
|
||||
<source>Add profile</source>
|
||||
<target state="translated">إضافة الملف الشخصي</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add servers by scanning QR codes." xml:space="preserve">
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<target state="translated">إضافة خوادم عن طريق مسح رموز QR.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server" xml:space="preserve" approved="no">
|
||||
<source>Add server</source>
|
||||
<target state="translated">أضف الخادم</target>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve" approved="no">
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
<source>Add to another device</source>
|
||||
<target state="translated">أضف إلى جهاز آخر</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Admins can create the links to join groups." xml:space="preserve">
|
||||
@@ -3676,7 +3667,7 @@ SimpleX servers cannot see your profile.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="## In reply to" xml:space="preserve" approved="no">
|
||||
<source>## In reply to</source>
|
||||
<target state="translated">## ردًّا على</target>
|
||||
<target state="translated">## ردًا على</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ and %@ connected" xml:space="preserve" approved="no">
|
||||
@@ -3684,208 +3675,6 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">%@ و %@ متصل</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</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>
|
||||
</file>
|
||||
<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,9 +1,6 @@
|
||||
/* Bundle display name */
|
||||
"CFBundleDisplayName" = "SimpleX NSE";
|
||||
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX NSE";
|
||||
|
||||
/* Copyright (human-readable) */
|
||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
SimpleX
|
||||
|
||||
Created by EP on 30/07/2024.
|
||||
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
*/
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
InfoPlist.strings
|
||||
SimpleX
|
||||
|
||||
Created by EP on 30/07/2024.
|
||||
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
*/
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
SimpleX
|
||||
|
||||
Created by EP on 30/07/2024.
|
||||
Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
*/
|
||||
@@ -386,8 +386,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-3
@@ -1,9 +1,6 @@
|
||||
/* Bundle display name */
|
||||
"CFBundleDisplayName" = "SimpleX NSE";
|
||||
|
||||
/* Bundle name */
|
||||
"CFBundleName" = "SimpleX NSE";
|
||||
|
||||
/* Copyright (human-readable) */
|
||||
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
|
||||
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
/*
|
||||
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