ios: UI redesign, contacts (#4116)

* ios: chat delete mode api

* redesign wip

* wip

* filter button right of search

* rework navigation (mostly works?)

* remove modifier

* search in bottom bar

* make filter button easier to press

* increase button size

* customizable search position

* reverse chat list wip

* change material

* list going behind toolbars

* increase spacing

* rework wip

* rework, sheets

* more scale effect

* remove search buttons, rework filter button

* remove onboarding buttons

* scan/paste menu

* wip

* contacts wip

* sizes

* remove unnecessary modifier

* contacts navigation wip

* paddings

* rework chat info view approach

* comment

* comment

* verified marker

* comment

* fix list not updating

* delete contact/conversation

* delete via chat list (has bugs)

* comment

* swipe on contact list

* fixes

* buttons wip

* message button to open chat

* buttons disabled

* call buttons work from sheet

* call button from contacts

* fix buttons

* show keyboard attempts

* Revert "show keyboard attempts"

This reverts commit daa50d1aa9.

* comment

* mark contact chat as not deleted when opening from contacts

* move to old view

* dont reverse contacts in one-hand mode

* change icons

* simplify call buttons (revert to make calls from chat view)

* top bar, reduce padding

* increase filter button size

* support for contact cards

* fix some delete conversation bugs

* fix chat not being removed from list on deleting conversation

* add to app settings

* member view buttons

* icons

* remove unused code

* padding

* avatar

* resize avatar

* button

* add open button for deleted contact

* add deletedByUser status

* rework delete actions

* filter button in contacts list

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy
2024-05-13 17:04:46 +04:00
committed by GitHub
parent f40ba6f04d
commit 8f8601eaa4
20 changed files with 1210 additions and 334 deletions
+1 -1
View File
@@ -230,7 +230,7 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
HomeView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
+58 -4
View File
@@ -732,21 +732,37 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert)
}
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
let chatId = type.rawValue + id.description
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, notify: notify), bgTask: false)
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
throw r
}
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
@@ -757,6 +773,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
}
}
func deleteChatContact(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
// removeChat forces list update (for ContactsView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
// removeChat forces list update (for ChatListView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteChatContact apiDeleteContact error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
}
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 }
@@ -1607,6 +1656,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cItem = aChatItem.chatItem
await MainActor.run {
if active(user) {
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
m.updateContact(updatedContact)
}
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
+238 -32
View File
@@ -88,21 +88,36 @@ enum SendReceipts: Identifiable, Hashable {
}
}
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 ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var openedFromChatView: Bool
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@State private var showDeleteContactActionSheet = false
@State private var actionSheet: ChatInfoViewActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
@@ -110,6 +125,7 @@ struct ChatInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
case deleteContactNotice
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
var id: String {
@@ -119,11 +135,24 @@ struct ChatInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case .deleteContactNotice: return "deleteContactNotice"
case let .error(title, _): return "error \(title)"
}
}
}
enum ChatInfoViewActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
NavigationView {
List {
@@ -140,6 +169,23 @@ struct ChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
connectButton()
} else if !contact.active && !contact.chatDeleted {
openButton()
} else {
messageButton()
}
Spacer()
callButton()
Spacer()
videoButton()
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if let customUserProfile = customUserProfile {
Section("Incognito") {
HStack {
@@ -159,9 +205,9 @@ struct ChatInfoView: View {
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
@@ -235,6 +281,23 @@ 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) {
@@ -243,27 +306,46 @@ struct ChatInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case .deleteContactNotice: return deleteContactNotice(contact)
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.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()
]
.actionSheet(item: $actionSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
} else {
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true)) },
.destructive(Text("Delete without notification")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
}
}
}
}
@@ -334,6 +416,83 @@ struct ChatInfoView: View {
}
}
// when contact is a "contact card"
private func connectButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "connect")
.onTapGesture {
showConnectContactViaAddressDialog = true
}
.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)
if ok {
await MainActor.run {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
}
}
}
private func openButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "open")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
chatModel.chatId = chat.id
}
}
}
// TODO show keyboard
private func messageButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
.disabled(!contact.sendMsgEnabled)
}
private func callButton() -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton() -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
@@ -437,9 +596,9 @@ struct ChatInfoView: View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
showDeleteContactActionSheet = true
actionSheet = .deleteContactActionSheet
} label: {
Label("Delete contact", systemImage: "trash")
Label("Delete contact", systemImage: "person.badge.minus")
.foregroundColor(Color.red)
}
}
@@ -453,17 +612,34 @@ struct ChatInfoView: View {
}
}
private func deleteContact(notify: Bool? = nil) {
private func deleteContact(chatDeleteMode: ChatDeleteMode) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
let ct = try await apiDeleteContact(id: chat.chatInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
switch chatDeleteMode {
case .full:
chatModel.removeChat(chat.chatInfo.id)
case .entity:
chatModel.updateContact(ct)
// dismissing sheet when opened from ChatView closes deleteContactNotice alert,
// also it makes less sense to show this alert as user is already in Chats tab
if showDeleteContactNotice && !openedFromChatView {
alert = .deleteContactNotice
}
case .messages:
logger.warning("ChatInfoView deleteContact case .messages should be unreachable")
chatModel.removeChat(chat.chatInfo.id)
chatModel.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
logger.error("ChatInfoView deleteContact apiDeleteContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
@@ -548,6 +724,38 @@ struct ChatInfoView: View {
}
}
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
}
struct InfoViewActionButtonLayout: View {
var image: String
var title: LocalizedStringKey
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12.0)
.frame(width: 90, height: 60)
}
}
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
@@ -580,12 +788,10 @@ func syncConnectionForceAlert(_ syncConnectionForce: @escaping () -> Void) -> Al
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
openedFromChatView: true,
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
localAlias: ""
)
}
}
+8 -26
View File
@@ -23,9 +23,6 @@ struct ChatView: View {
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var keyboardVisible = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -111,32 +108,17 @@ struct ChatView: View {
ToolbarItem(placement: .principal) {
if case let .direct(contact) = cInfo {
Button {
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))")
}
await MainActor.run { showChatInfoSheet = true }
}
showChatInfoSheet = true
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
connectionCode = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
.sheet(isPresented: $showChatInfoSheet) {
ChatInfoView(
openedFromChatView: true,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
}
} else if case let .group(groupInfo) = cInfo {
Button {
@@ -0,0 +1,210 @@
//
// ContactListNavLink.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactListNavLink: View {
@ObservedObject var chat: Chat
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
switch chat.chatInfo {
case let .direct(contact):
NavigationLink {
ChatInfoView(
openedFromChatView: false,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
} label: {
HStack{
ZStack(alignment: .bottomTrailing) {
ProfileImage(imageStr: contact.image, size: 38)
chatPreviewImageOverlayIcon(contact)
.padding([.bottom, .trailing], 1)
}
.padding(.trailing, 2)
previewTitle(contact)
Spacer()
HStack {
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))
}
if contact.contactConnIncognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
}
}
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
toggleFavoriteButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "person.badge.minus")
}
.tint(.red)
}
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.destructive(Text("Delete without notification")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
}
}
}
default:
EmptyView()
}
}
@ViewBuilder private func previewTitle(_ contact: Contact) -> some View {
let t = Text(chat.chatInfo.chatViewName)
(
contact.verified == true
? verifiedIcon + t
: t
)
.lineLimit(1)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ViewBuilder private func chatPreviewImageOverlayIcon(_ contact: Contact) -> some View {
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
}
private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
@ViewBuilder private func toggleFavoriteButton() -> some View {
if chat.chatInfo.chatSettings?.favorite == true {
Button {
toggleChatFavorite(chat, favorite: false)
} label: {
Label("Unfav.", systemImage: "star.slash")
}
.tint(.green)
} else {
Button {
toggleChatFavorite(chat, favorite: true)
} label: {
Label("Favorite", systemImage: "star.fill")
}
.tint(.green)
}
}
}
#Preview {
ContactListNavLink(chat: Chat.sampleData)
}
@@ -0,0 +1,199 @@
//
// ContactsView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactsView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
} else {
viewBody
}
}
private var viewBody: some View {
VStack {
contactList
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listStyle(.plain)
}
@ViewBuilder private var contactList: some View {
let contactChats = contactChats()
let filteredContactChats = filteredContactChats(contactChats)
ZStack {
VStack {
List {
if !contactChats.isEmpty {
ContactsSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
ForEach(filteredContactChats, id: \.viewId) { chat in
switch chat.chatInfo {
case let .direct(contact):
ContactListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(contact.id))
default:
EmptyView()
}
}
}
}
if filteredContactChats.isEmpty && !contactChats.isEmpty {
Text("No filtered contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
} else if contactChats.isEmpty {
Text("No contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
private func contactChats() -> [Chat] {
return chatModel.chats.filter { chat in
switch chat.chatInfo {
case .direct: true
default: false
}
}
}
private func filteredContactChats(_ contactChats: [Chat]) -> [Chat] {
let s = searchString()
return (
s == "" && !showUnreadAndFavorites
? contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact): return contact.contactStatus != .deletedByUser
default: return false
}
}
: contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact):
return contact.contactStatus != .deletedByUser && (
s == ""
? (chat.chatInfo.chatSettings?.favorite ?? false)
: (viewNameContains(contact, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
default: return false
}
}
)
.sorted{ $0.chatInfo.displayName.lowercased() < $1.chatInfo.displayName.lowercased() }
func searchString() -> String {
searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func viewNameContains(_ contact: Contact, _ s: String) -> Bool {
contact.chatViewName.localizedLowercase.contains(s)
}
}
}
struct ContactsSearchBar: View {
@EnvironmentObject var m: ChatModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
searchText = ""
}
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(.accentColor)
.onTapGesture {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
}
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 ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
}
struct ContactsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chats = [
Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: []
)
]
return Group {
ContactsView()
.environmentObject(chatModel)
ContactsView()
.environmentObject(ChatModel())
}
}
}
@@ -62,10 +62,11 @@ struct GroupMemberInfoView: View {
}
}
private func knownDirectChat(_ contactId: Int64) -> Chat? {
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directOrUsed == true {
return chat
let contact = chat.chatInfo.contact,
contact.directOrUsed == true {
return (chat, contact)
} else {
return nil
}
@@ -78,18 +79,35 @@ struct GroupMemberInfoView: View {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat)
Spacer()
callButton(contact)
Spacer()
videoButton(contact)
} 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()
}
Spacer()
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.disabled(true)
Spacer()
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.disabled(true)
}
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
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 {
@@ -249,58 +267,71 @@ struct GroupMemberInfoView: View {
}
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)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
}
}
func createMemberContactButton() -> some View {
Button {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
}
}
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func callButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
@@ -63,7 +63,6 @@ struct ChatHelp: View {
struct ChatHelp_Previews: PreviewProvider {
static var previews: some View {
@State var showSettings = false
return ChatHelp(showSettings: $showSettings)
return ChatHelp(showSettings: Binding.constant(false))
}
}
@@ -32,11 +32,25 @@ struct ChatListNavLink: View {
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) private var showDeleteConversationNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
Group {
switch chat.chatInfo {
@@ -67,12 +81,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "trash")
}
@@ -100,7 +114,7 @@ struct ChatListNavLink: View {
}
Button {
if contact.ready || !contact.active {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
}
@@ -112,24 +126,53 @@ struct ChatListNavLink: View {
.frame(height: rowHeights[dynamicTypeSize])
}
}
.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 {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
if contact.contactStatus == .deletedByUser {
return ActionSheet(
title: Text("Delete conversation?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete conversation")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet },
.destructive(Text("Only delete conversation")) {
Task {
await deleteChatContact(chat, chatDeleteMode: .messages)
if showDeleteConversationNotice {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
}
}
},
.cancel()
]
)
}
case .confirmDeleteContactActionSheet:
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: true)) } },
.destructive(Text("Delete without notification")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
}
}
}
}
@@ -437,6 +480,17 @@ struct ChatListNavLink: View {
)
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can still send messages to \(contact.displayName) from the Contacts tab. "),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
+37 -181
View File
@@ -11,16 +11,15 @@ import SimpleXChat
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var searchMode = false
@FocusState private var searchFocussed
@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
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true
var body: some View {
if #available(iOS 16.0, *) {
@@ -31,44 +30,10 @@ struct ChatListView: View {
}
private var viewBody: some View {
ZStack(alignment: .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView
) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
}
chatListView
}
}
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
private var chatListView: some View {
VStack {
chatList
}
.onDisappear() { withAnimation { userPickerVisible = false } }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -86,58 +51,6 @@ struct ChatListView: View {
))
}
.listStyle(.plain)
.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)
if chatModel.chats.count > 0 {
toggleFilterButton()
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
}
}
}
private func toggleFilterButton() -> some View {
Button {
showUnreadAndFavorites = !showUnreadAndFavorites
} label: {
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
.foregroundColor(.accentColor)
}
}
@ViewBuilder private var chatList: some View {
@@ -153,11 +66,13 @@ struct ChatListView: View {
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
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))
}
@@ -171,7 +86,9 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(.secondary)
Text("No filtered chats")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
@@ -182,66 +99,24 @@ struct ChatListView: View {
.foregroundColor(.accentColor)
}
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(Color.accentColor)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact
}
Spacer()
Text("You have no chats")
.foregroundColor(.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(Color.accentColor)
.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).onAppear {
loadChat(chat: chat)
}
}
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
? chatModel.chats
? chatModel.chats.filter { chat in !chat.chatInfo.chatDeleted }
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
case let .direct(contact):
return s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
return !contact.chatDeleted && (
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)
@@ -282,9 +157,9 @@ struct ChatListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
@State private var showScanCodeSheet = 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 {
VStack(spacing: 12) {
@@ -301,26 +176,6 @@ struct ChatListSearchBar: View {
.onTapGesture {
searchText = ""
}
} else if !searchFocussed {
HStack(spacing: 24) {
if m.pasteboardHasStrings {
Image(systemName: "doc")
.onTapGesture {
if let str = UIPasteboard.general.string {
searchText = str
}
}
}
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.onTapGesture {
showScanCodeSheet = true
}
}
.padding(.trailing, 2)
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
@@ -335,13 +190,10 @@ struct ChatListSearchBar: View {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
Divider()
}
.sheet(isPresented: $showScanCodeSheet) {
NewChatView(selection: .connect, showQRCodeScanner: true)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
@@ -376,6 +228,21 @@ struct ChatListSearchBar: View {
}
}
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 ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -389,17 +256,6 @@ struct ChatListSearchBar: View {
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
@@ -419,9 +275,9 @@ struct ChatListView_Previews: PreviewProvider {
]
return Group {
ChatListView(showSettings: Binding.constant(false))
ChatListView()
.environmentObject(chatModel)
ChatListView(showSettings: Binding.constant(false))
ChatListView()
.environmentObject(ChatModel())
}
}
@@ -204,7 +204,7 @@ struct ChatPreviewView: View {
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(.accentColor)
} else if !contact.ready && contact.activeConn != nil {
+219
View File
@@ -0,0 +1,219 @@
//
// HomeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 01.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum HomeTab {
case contacts
case chats
}
struct HomeView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var homeTab: HomeTab = .chats
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State private var newChatMenuOption: NewChatMenuOption? = nil
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true
var body: some View {
ZStack(alignment: .bottomLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView
) {
VStack {
switch homeTab {
case .contacts:
contactsView()
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Contacts")
case .chats:
chatListView()
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Chats")
}
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
settingsButton()
Spacer()
contactsButton()
Spacer()
chatsButton()
Spacer()
newChatButton()
}
}
}
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
@ViewBuilder private func settingsButton() -> some View {
let user = chatModel.currentUser ?? User.sampleData
let multiUser = chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1
Button {
if multiUser {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
} label: {
VStack(spacing: 2) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 38, color: Color(uiColor: .quaternaryLabel))
.padding(.top, 2)
.padding(.trailing, 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
userUnreadBadge(size: 12)
}
}
}
}
.foregroundColor(.secondary)
}
private func userUnreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(.accentColor)
}
private func contactsButton() -> some View {
Button {
homeTab = .contacts
} label: {
iconLabel(homeTab == .contacts ? "book.fill" : "book", "Contacts")
}
.foregroundColor(.secondary)
}
private func chatsButton() -> some View {
Button {
homeTab = .chats
} label: {
iconLabel(homeTab == .chats ? "message.fill" : "message", "Chats")
}
.foregroundColor(.secondary)
}
@ViewBuilder private func newChatButton() -> some View {
if case .some(false) = chatModel.chatRunning {
chatsStoppedButton()
} else {
Menu {
Button {
newChatMenuOption = .newGroup
} label: {
Text("Create group")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newContact
} label: {
Text("Add contact")
}
} label: {
iconLabel("square.and.pencil", "New chat")
}
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
}
}
func chatsStoppedButton() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
VStack(spacing: 4) {
Image(systemName: "exclamationmark.octagon.fill")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.frame(width: 24, height: 24)
Text("Stopped")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func iconLabel(_ image: String, _ title: LocalizedStringKey) -> some View {
VStack(spacing: 2) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption2)
}
.padding(.top, 3)
}
@ViewBuilder private func contactsView() -> some View {
ContactsView()
.padding(.vertical, oneHandUI ? 1 : 0)
}
@ViewBuilder private func chatListView() -> some View {
// TODO reverse scale effect for swipe actions
ChatListView()
.padding(.vertical, oneHandUI ? 1 : 0)
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
}
#Preview {
HomeView(showSettings: Binding.constant(false))
}
@@ -10,6 +10,7 @@ import SwiftUI
enum NewChatMenuOption: Identifiable {
case newContact
case scanPaste
case newGroup
var id: Self { self }
@@ -25,6 +26,11 @@ struct NewChatMenuButton: View {
} label: {
Text("Add contact")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newGroup
} label: {
@@ -39,6 +45,7 @@ struct NewChatMenuButton: View {
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
@@ -43,6 +43,7 @@ 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 = oneHandUI { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) }
}
public static var current: AppSettings {
@@ -67,6 +68,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.oneHandUI = def.bool(forKey: DEFAULT_ONE_HAND_UI)
return c
}
}
@@ -25,6 +25,7 @@ struct AppearanceSettings: View {
@State private var userInterfaceStyle = getUserInterfaceStyleDefault()
@State private var uiTintColor = getUIAccentColorDefault()
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true
var body: some View {
VStack{
@@ -39,6 +40,12 @@ struct AppearanceSettings: View {
}
}
Section("Interface") {
settingsRow("hand.wave") {
Toggle("One-hand UI", isOn: $oneHandUI)
}
}
Section("App icon") {
HStack {
updateAppIcon(image: "icon-light", icon: nil, tapped: $iconLightTapped)
@@ -46,6 +46,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen"
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_ONE_HAND_UI = "oneHandUI"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
@@ -60,6 +61,8 @@ 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 ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
@@ -89,6 +92,7 @@ let appDefaults: [String: Any] = [
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
DEFAULT_USER_INTERFACE_STYLE: 0,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_ONE_HAND_UI: true,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
@@ -99,7 +103,9 @@ let appDefaults: [String: Any] = [
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true,
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true
]
// not used anymore
@@ -446,9 +452,8 @@ struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
@State var showSettings = false
return SettingsView(showSettings: $showSettings)
return SettingsView(showSettings: Binding.constant(false))
.environmentObject(chatModel)
}
}
@@ -403,6 +403,6 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true))
UserProfilesView(showSettings: Binding.constant(false))
}
}
@@ -155,6 +155,7 @@
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642CE4942BE2651E00AD7757 /* HomeView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
@@ -172,9 +173,11 @@
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
@@ -450,6 +453,7 @@
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642CE4942BE2651E00AD7757 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
@@ -467,10 +471,12 @@
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
@@ -562,6 +568,7 @@
5C2E260D27A30E2400F70299 /* Views */ = {
isa = PBXGroup;
children = (
642CE4932BE2650500AD7757 /* Home */,
5CB0BA8C282711BC00B3292C /* Onboarding */,
3C714775281C080100CB4D4B /* Call */,
5C971E1F27AEBF7000C8A3CE /* Helpers */,
@@ -581,6 +588,7 @@
5C5F4AC227A5E9AF00B51EF1 /* Chat */ = {
isa = PBXGroup;
children = (
6485F7C42BE8DB4F00FAE413 /* Contacts */,
6440CA01288AEC770062C672 /* Group */,
5CE4407427ADB657007B033A /* ChatItem */,
5CEACCE527DE977C000BD591 /* ComposeMessage */,
@@ -899,6 +907,14 @@
path = Database;
sourceTree = "<group>";
};
642CE4932BE2650500AD7757 /* Home */ = {
isa = PBXGroup;
children = (
642CE4942BE2651E00AD7757 /* HomeView.swift */,
);
path = Home;
sourceTree = "<group>";
};
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
@@ -913,6 +929,15 @@
path = Group;
sourceTree = "<group>";
};
6485F7C42BE8DB4F00FAE413 /* Contacts */ = {
isa = PBXGroup;
children = (
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */,
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */,
);
path = Contacts;
sourceTree = "<group>";
};
8C7D94982B8894D300B7B9E1 /* Migration */ = {
isa = PBXGroup;
children = (
@@ -1148,6 +1173,7 @@
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
@@ -1168,6 +1194,7 @@
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
@@ -1212,6 +1239,7 @@
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */,
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
+12 -7
View File
@@ -97,7 +97,7 @@ public enum ChatCommand {
case apiConnectPlan(userId: Int64, connReq: String)
case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?)
case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode)
case apiClearChat(type: ChatType, id: Int64)
case apiListContacts(userId: Int64)
case apiUpdateProfile(userId: Int64, profile: Profile)
@@ -251,11 +251,7 @@ public enum ChatCommand {
case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
case let .apiDeleteChat(type, id, notify): if let notify = notify {
return "/_delete \(ref(type, id)) notify=\(onOff(notify))"
} else {
return "/_delete \(ref(type, id))"
}
case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(encodeJSON(chatDeleteMode))"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiListContacts(userId): return "/_contacts \(userId)"
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
@@ -988,6 +984,12 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
}
}
public enum ChatDeleteMode: Codable {
case full(notify: Bool)
case entity(notify: Bool)
case messages
}
public enum ConnectionPlan: Decodable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -2004,6 +2006,7 @@ public struct AppSettings: Codable, Equatable {
public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
public var iosCallKitEnabled: Bool? = nil
public var iosCallKitCallsInRecents: Bool? = nil
public var oneHandUI: Bool? = nil
public func prepareForExport() -> AppSettings {
var empty = AppSettings()
@@ -2027,6 +2030,7 @@ public struct AppSettings: Codable, Equatable {
if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
return empty
}
@@ -2050,7 +2054,8 @@ public struct AppSettings: Codable, Equatable {
confirmDBUpgrades: false,
androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
iosCallKitEnabled: true,
iosCallKitCallsInRecents: false
iosCallKitCallsInRecents: false,
oneHandUI: true
)
}
}
+13 -1
View File
@@ -1287,6 +1287,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var chatDeleted: Bool {
get {
switch self {
case let .direct(contact): return contact.chatDeleted
default: return false
}
}
}
public var sendMsgEnabled: Bool {
get {
switch self {
@@ -1504,6 +1513,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatDeleted: Bool
public var id: ChatId { get { "@\(contactId)" } }
public var apiId: Int64 { get { contactId } }
@@ -1565,13 +1575,15 @@ public struct Contact: Identifiable, Decodable, NamedChat {
mergedPreferences: ContactUserPreferences.sampleData,
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false
contactGrpInvSent: false,
chatDeleted: false
)
}
public enum ContactStatus: String, Decodable {
case active = "active"
case deleted = "deleted"
case deletedByUser = "deletedByUser"
}
public struct ContactRef: Decodable, Equatable {