mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-31 16:15:55 +00:00
ios: improve new and existing chat interactions - new chat sheet, one hand ui, info views action buttons; new modes of contact deletion (keep conversation, only delete conversation) (#4427)
* ios: added delete contacts, one hand ui, and contact action buttons * remove unused, rework info buttons wip * ios: moved existing buttons to new chat sheet * ios: add basic list of contacts to new chat sheet * ios: add deleted chats section to new chat sheet * group chat info navigation * fix spacing of group info buttons * remove comment * unify spacing logic across info views * info button alerts wip * calls alerts wip * call buttons alerts * fix call button to correctly update on preference change while in view * refactor * fix alert ids * contact list wip * more contact list actions * open chat wip * fix contact list elements clickability * ios: search functionality on new chat sheet * ios: white bg for search box on new chat sheet * ios: don't show empty list when pasted contact is not known * ios: add search and nav title to deleted chats * navigation links wip * fix refreshable * ios: empty states for lists * ios: hide contact cards from chat list * ios: make search bar icon sizes consistent * ios: fix deleted conversation dissapearing from chat list on back * fix pending invitation cleanup in chat sheet * rename search label from open to search * make cleanup alert work on sheet and on dismiss * dismiss all sheets after creation of groups * fix double toolbar on group invite members * fix double toolbar on group link invitation screen * dismiss all on group creation error * comment * show alert in dismissAllSheets completion * fix sheet dismissal on known group * rework contact list with buttons (fixes dark mode) * fix dark mode on new chat view * fix search dark mode * increase search padding * improve new chat title and info button placing * info view background * improve create group title placement * refactor * fix delete dialogue in light mode * change icon * archivebox on contact list --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
@@ -340,7 +340,13 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
m.chatItemStatuses = [:]
|
||||
im.reversedChatItems = []
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
if case let .direct(contact) = chat.chatInfo, !cInfo.chatDeleted, chat.chatInfo.chatDeleted {
|
||||
var updatedContact = contact
|
||||
updatedContact.chatDeleted = false
|
||||
m.updateContact(updatedContact)
|
||||
} else {
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
}
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
@@ -761,22 +767,38 @@ 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)
|
||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
|
||||
} catch let error {
|
||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -786,6 +808,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||
await MainActor.run {
|
||||
switch chatDeleteMode {
|
||||
case .full:
|
||||
ChatModel.shared.removeChat(cInfo.id)
|
||||
case .entity:
|
||||
ChatModel.shared.removeChat(cInfo.id)
|
||||
ChatModel.shared.addChat(Chat(
|
||||
chatInfo: .direct(contact: ct),
|
||||
chatItems: chat.chatItems
|
||||
))
|
||||
case .messages:
|
||||
ChatModel.shared.removeChat(cInfo.id)
|
||||
ChatModel.shared.addChat(Chat(
|
||||
chatInfo: .direct(contact: ct),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
|
||||
return mkAlert(
|
||||
title: "Error deleting chat!",
|
||||
message: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
|
||||
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
|
||||
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
|
||||
@@ -1114,10 +1169,17 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
DispatchQueue.main.async {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||
ChatModel.shared.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
if contact.sndReady {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true) {
|
||||
ChatModel.shared.chatId = chat.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1713,6 +1775,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)
|
||||
|
||||
@@ -94,17 +94,20 @@ struct ChatInfoView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
@State var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
@Binding var customUserProfile: Profile?
|
||||
@State var localAlias: String
|
||||
@Binding var connectionCode: String?
|
||||
var onSearch: () -> Void
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var customUserProfile: Profile? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
@@ -112,6 +115,7 @@ struct ChatInfoView: View {
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case queueInfo(info: String)
|
||||
case someAlert(alert: SomeAlert)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
@@ -122,11 +126,12 @@ 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 {
|
||||
@@ -136,13 +141,28 @@ struct ChatInfoView: View {
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
|
||||
Group {
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
searchButton()
|
||||
Spacer()
|
||||
AudioCallButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) })
|
||||
Spacer()
|
||||
VideoButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) })
|
||||
Spacer()
|
||||
muteButton()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
@@ -153,7 +173,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
Group {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
@@ -173,14 +193,18 @@ struct ChatInfoView: View {
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } 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)
|
||||
@@ -197,7 +221,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
@@ -226,12 +250,12 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
@@ -260,6 +284,24 @@ 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) {
|
||||
@@ -269,31 +311,21 @@ 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(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.sndReady && 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) { $0.actionSheet }
|
||||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { deleteContact() },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
$0.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func contactInfoHeader() -> some View {
|
||||
VStack {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -328,7 +360,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set contact name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
@@ -345,7 +377,7 @@ struct ChatInfoView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
|
||||
private func setContactAlias() {
|
||||
Task {
|
||||
do {
|
||||
@@ -360,6 +392,26 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func searchButton() -> some View {
|
||||
InfoViewActionButtonLayout(image: "magnifyingglass", title: "search")
|
||||
.onTapGesture {
|
||||
dismiss()
|
||||
onSearch()
|
||||
}
|
||||
.disabled(!contact.ready || chat.chatItems.isEmpty)
|
||||
}
|
||||
|
||||
private func muteButton() -> some View {
|
||||
InfoViewActionButtonLayout(
|
||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash" : "speaker.wave.2",
|
||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute"
|
||||
)
|
||||
.onTapGesture {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
@@ -389,7 +441,7 @@ struct ChatInfoView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func contactPreferencesButton() -> some View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
@@ -404,7 +456,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
|
||||
@@ -418,13 +470,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)
|
||||
@@ -433,7 +485,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButtonForce() -> some View {
|
||||
Button {
|
||||
alert = .syncConnectionForceAlert
|
||||
@@ -442,7 +494,7 @@ struct ChatInfoView: View {
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func networkStatusRow() -> some View {
|
||||
HStack {
|
||||
Text("Network status")
|
||||
@@ -455,23 +507,30 @@ struct ChatInfoView: View {
|
||||
serverImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func serverImage() -> some View {
|
||||
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) {
|
||||
showDeleteContactActionSheet = true
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: true,
|
||||
showAlert: { alert = .someAlert(alert: $0) },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
} label: {
|
||||
Label("Delete contact", systemImage: "trash")
|
||||
Label("Delete contact", systemImage: "person.badge.minus")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func clearChatButton() -> some View {
|
||||
Button() {
|
||||
alert = .clearChatAlert
|
||||
@@ -480,26 +539,7 @@ 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?"),
|
||||
@@ -513,14 +553,14 @@ struct ChatInfoView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func networkStatusAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Network status"),
|
||||
message: Text(chatModel.contactNetworkStatus(contact).statusExplanation)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func switchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
@@ -539,7 +579,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func abortSwitchContactAddress() {
|
||||
Task {
|
||||
do {
|
||||
@@ -557,7 +597,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
@@ -578,6 +618,144 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioCallButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
|
||||
var body: some View {
|
||||
CallButton(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
image: "phone",
|
||||
title: "call",
|
||||
mediaType: .audio,
|
||||
showAlert: showAlert
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
|
||||
var body: some View {
|
||||
CallButton(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
image: "video",
|
||||
title: "video",
|
||||
mediaType: .video,
|
||||
showAlert: showAlert
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CallButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
var image: String
|
||||
var title: LocalizedStringKey
|
||||
var mediaType: CallMediaType
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
|
||||
var body: some View {
|
||||
let canCall = contact.ready && contact.active && chat.chatInfo.featureEnabled(.calls) && ChatModel.shared.activeCall == nil
|
||||
|
||||
InfoViewActionButtonLayout(image: image, title: title, disabledLook: !canCall)
|
||||
.onTapGesture {
|
||||
if canCall {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else if contact.nextSendGrpInv {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't call contact",
|
||||
message: "Send message to enable calls."
|
||||
),
|
||||
id: "can't call contact, send message"
|
||||
))
|
||||
} else if !contact.active {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't call contact",
|
||||
message: "Contact is deleted."
|
||||
),
|
||||
id: "can't call contact, contact deleted"
|
||||
))
|
||||
} else if !contact.ready {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't call contact",
|
||||
message: "Connecting to contact, please wait or check later!"
|
||||
),
|
||||
id: "can't call contact, contact not ready"
|
||||
))
|
||||
} else if !chat.chatInfo.featureEnabled(.calls) {
|
||||
switch chat.chatInfo.showEnableCallsAlert {
|
||||
case .userEnable:
|
||||
showAlert(SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Allow calls?"),
|
||||
message: Text("You need to allow your contact to call to be able to call them."),
|
||||
primaryButton: .default(Text("Allow")) {
|
||||
allowFeatureToContact(contact, .calls)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "allow calls"
|
||||
))
|
||||
case .askContact:
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Calls prohibited!",
|
||||
message: "Please ask your contact to enable calls."
|
||||
),
|
||||
id: "calls prohibited, ask contact"
|
||||
))
|
||||
case .other:
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Calls prohibited!",
|
||||
message: "Please check yours and your contact preferences."
|
||||
)
|
||||
, id: "calls prohibited, other"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(title: "Can't call contact"),
|
||||
id: "can't call contact"
|
||||
))
|
||||
}
|
||||
}
|
||||
.disabled(ChatModel.shared.activeCall != nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoViewActionButtonLayout: View {
|
||||
var image: String
|
||||
var title: LocalizedStringKey
|
||||
var disabledLook: Bool = false
|
||||
|
||||
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(12.0)
|
||||
.frame(width: 82, height: 56)
|
||||
.disabled(disabledLook)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatWallpaperEditorSheet: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -763,15 +941,222 @@ func queueInfoAlert(_ info: String) -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
func deleteContactDialog(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
dismissToChatList: Bool,
|
||||
showAlert: @escaping (SomeAlert) -> Void,
|
||||
showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||
showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||
) {
|
||||
if contact.sndReady && contact.active && !contact.chatDeleted {
|
||||
deleteContactOrConversationDialog(chat, contact, dismissToChatList, showAlert, showActionSheet, showSheetContent)
|
||||
} else if contact.sndReady && contact.active && contact.chatDeleted {
|
||||
deleteContactWithoutConversation(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||
} else { // !(contact.sndReady && contact.active)
|
||||
deleteNotReadyContact(chat, contact, dismissToChatList, showAlert, showActionSheet)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactOrConversationDialog(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
_ dismissToChatList: Bool,
|
||||
_ showAlert: @escaping (SomeAlert) -> Void,
|
||||
_ showActionSheet: @escaping (SomeActionSheet) -> Void,
|
||||
_ showSheetContent: @escaping (SomeSheet<AnyView>) -> Void
|
||||
) {
|
||||
showActionSheet(SomeActionSheet(
|
||||
actionSheet: ActionSheet(
|
||||
title: Text("Delete contact?"),
|
||||
buttons: [
|
||||
.destructive(Text("Only delete conversation")) {
|
||||
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: .messages, dismissToChatList, showAlert)
|
||||
},
|
||||
.destructive(Text("Delete contact")) {
|
||||
showSheetContent(SomeSheet(
|
||||
content: { AnyView(
|
||||
DeleteActiveContactDialog(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
dismissToChatList: dismissToChatList,
|
||||
showAlert: showAlert
|
||||
)
|
||||
) },
|
||||
id: "DeleteActiveContactDialog"
|
||||
))
|
||||
},
|
||||
.cancel()
|
||||
]
|
||||
),
|
||||
id: "deleteContactOrConversationDialog"
|
||||
))
|
||||
}
|
||||
|
||||
private func deleteContactMaybeErrorAlert(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
chatDeleteMode: ChatDeleteMode,
|
||||
_ dismissToChatList: Bool,
|
||||
_ showAlert: @escaping (SomeAlert) -> Void
|
||||
) {
|
||||
Task {
|
||||
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
|
||||
if let alert = alert_ {
|
||||
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
|
||||
} else {
|
||||
if dismissToChatList {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.chatId = nil
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true) {
|
||||
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||
AlertManager.shared.showAlert(deleteConversationNotice(contact))
|
||||
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||
AlertManager.shared.showAlert(deleteContactNotice(contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if case .messages = chatDeleteMode, showDeleteConversationNoticeDefault.get() {
|
||||
showAlert(SomeAlert(alert: deleteConversationNotice(contact), id: "deleteContactMaybeErrorAlert, deleteConversationNotice"))
|
||||
} else if chatDeleteMode.isEntity, showDeleteContactNoticeDefault.get() {
|
||||
showAlert(SomeAlert(alert: deleteContactNotice(contact), id: "deleteContactMaybeErrorAlert, deleteContactNotice"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversationNotice(_ contact: Contact) -> Alert {
|
||||
return Alert(
|
||||
title: Text("Conversation deleted!"),
|
||||
message: Text("You can still send messages to \(contact.displayName) from the Deleted chats."),
|
||||
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: "",
|
||||
connectionCode: Binding.constant(nil)
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,32 +179,18 @@ struct ChatView: View {
|
||||
} else 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)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
|
||||
connectionStats = nil
|
||||
customUserProfile = nil
|
||||
connectionCode = nil
|
||||
theme = buildTheme()
|
||||
}) {
|
||||
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
|
||||
.appSheet(isPresented: $showChatInfoSheet) {
|
||||
ChatInfoView(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
localAlias: chat.chatInfo.localAlias,
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
@@ -222,7 +208,8 @@ struct ChatView: View {
|
||||
chat.chatInfo = .group(groupInfo: gInfo)
|
||||
chat.created = Date.now
|
||||
}
|
||||
)
|
||||
),
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
@@ -564,14 +551,18 @@ struct ChatView: View {
|
||||
|
||||
private func searchButton() -> some View {
|
||||
Button {
|
||||
searchMode = true
|
||||
searchFocussed = true
|
||||
searchText = ""
|
||||
focusSearch()
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
|
||||
private func focusSearch() {
|
||||
searchMode = true
|
||||
searchFocussed = true
|
||||
searchText = ""
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
|
||||
@@ -47,14 +47,13 @@ struct AddGroupMembersViewCommon: View {
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
NavigationView {
|
||||
addGroupMembersView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||
}
|
||||
addGroupMembersView()
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Skip") { addedMembersCb(selectedContacts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addGroupMembersView()
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ 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 showAddMembersSheet: Bool = false
|
||||
@State private var groupLinkNavLinkActive: Bool = false
|
||||
@State private var addMembersNavLinkActive: Bool = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@@ -69,6 +71,21 @@ struct GroupChatInfoView: View {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
searchButton()
|
||||
if groupInfo.canAddMembers {
|
||||
Spacer()
|
||||
addMembersActionButton()
|
||||
}
|
||||
Spacer()
|
||||
muteButton()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
@@ -198,24 +215,82 @@ struct GroupChatInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func searchButton() -> some View {
|
||||
InfoViewActionButtonLayout(image: "magnifyingglass", title: "search")
|
||||
.onTapGesture {
|
||||
dismiss()
|
||||
onSearch()
|
||||
}
|
||||
.disabled(!groupInfo.ready || chat.chatItems.isEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private func addMembersActionButton() -> some View {
|
||||
if chat.chatInfo.incognito {
|
||||
ZStack {
|
||||
InfoViewActionButtonLayout(image: "link.badge.plus", title: "invite")
|
||||
.onTapGesture {
|
||||
groupLinkNavLinkActive = true
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $groupLinkNavLinkActive) {
|
||||
groupLinkDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
} else {
|
||||
ZStack {
|
||||
InfoViewActionButtonLayout(image: "person.badge.plus", title: "invite")
|
||||
.onTapGesture {
|
||||
addMembersNavLinkActive = true
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $addMembersNavLinkActive) {
|
||||
addMembersDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
}
|
||||
|
||||
private func muteButton() -> some View {
|
||||
InfoViewActionButtonLayout(
|
||||
image: chat.chatInfo.ntfsEnabled ? "speaker.slash" : "speaker.wave.2",
|
||||
title: chat.chatInfo.ntfsEnabled ? "mute" : "unmute"
|
||||
)
|
||||
.onTapGesture {
|
||||
toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled)
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
NavigationLink {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
addMembersDestinationView()
|
||||
} 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
|
||||
@@ -352,16 +427,7 @@ struct GroupChatInfoView: View {
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
groupLinkDestinationView()
|
||||
} label: {
|
||||
if groupLink == nil {
|
||||
Label("Create group link", systemImage: "link.badge.plus")
|
||||
@@ -371,6 +437,19 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkDestinationView() -> some View {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
|
||||
private func editGroupButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupProfileView(
|
||||
@@ -577,7 +656,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,13 @@ struct GroupLinkView: View {
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
NavigationView {
|
||||
groupLinkView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
}
|
||||
groupLinkView()
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupLinkView()
|
||||
}
|
||||
|
||||
@@ -37,6 +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?)
|
||||
|
||||
var id: String {
|
||||
@@ -52,6 +53,7 @@ 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)"
|
||||
}
|
||||
}
|
||||
@@ -65,10 +67,11 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func knownDirectChat(_ contactId: Int64) -> Chat? {
|
||||
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
|
||||
if let chat = chatModel.getContactChat(contactId),
|
||||
chat.chatInfo.contact?.directOrUsed == true {
|
||||
return chat
|
||||
let contact = chat.chatInfo.contact,
|
||||
contact.directOrUsed == true {
|
||||
return (chat, contact)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -80,21 +83,18 @@ struct GroupMemberInfoView: View {
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
infoActionButtons(member)
|
||||
.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 {
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
@@ -237,6 +237,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)
|
||||
}
|
||||
}
|
||||
@@ -249,6 +250,66 @@ struct GroupMemberInfoView: View {
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
func infoActionButtons(_ member: GroupMember) -> some View {
|
||||
HStack {
|
||||
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
|
||||
Spacer()
|
||||
knownDirectChatButton(chat)
|
||||
Spacer()
|
||||
AudioCallButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) })
|
||||
Spacer()
|
||||
VideoButton(chat: chat, contact: contact, showAlert: { alert = .someAlert(alert: $0) })
|
||||
Spacer()
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
Spacer()
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
Spacer()
|
||||
createMemberContactButton()
|
||||
}
|
||||
Spacer()
|
||||
InfoViewActionButtonLayout(image: "phone", title: "call", disabledLook: true)
|
||||
.onTapGesture { showSendMessageToEnableCallsAlert() }
|
||||
Spacer()
|
||||
InfoViewActionButtonLayout(image: "video", title: "video", disabledLook: true)
|
||||
.onTapGesture { showSendMessageToEnableCallsAlert() }
|
||||
Spacer()
|
||||
} else { // no known contact chat && directMessages are off
|
||||
Spacer()
|
||||
InfoViewActionButtonLayout(image: "message", title: "message", disabledLook: true)
|
||||
.onTapGesture { showDirectMessagesProhibitedAlert("Can't message member") }
|
||||
Spacer()
|
||||
InfoViewActionButtonLayout(image: "phone", title: "call", disabledLook: true)
|
||||
.onTapGesture { showDirectMessagesProhibitedAlert("Can't call member") }
|
||||
Spacer()
|
||||
InfoViewActionButtonLayout(image: "video", title: "video", disabledLook: true)
|
||||
.onTapGesture { showDirectMessagesProhibitedAlert("Can't call member") }
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -264,58 +325,55 @@ 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", 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", 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", 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 groupMemberInfoHeader(_ mem: GroupMember) -> some View {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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() }
|
||||
@@ -39,7 +38,8 @@ struct ChatHelp: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Tap button ")
|
||||
NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
// TODO: Check this.
|
||||
NewChatMenuButton()
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ 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 alert: SomeAlert? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var sheet: SomeSheet<AnyView>? = nil
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
@@ -83,15 +85,22 @@ 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: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
showDeleteContactActionSheet = true
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: false,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
@@ -118,11 +127,14 @@ struct ChatListNavLink: View {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.sndReady || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
deleteContactDialog(
|
||||
chat,
|
||||
contact,
|
||||
dismissToChatList: false,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { actionSheet = $0 },
|
||||
showSheetContent: { sheet = $0 }
|
||||
)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
@@ -131,24 +143,14 @@ struct ChatListNavLink: View {
|
||||
.frame(height: dynamicRowHeight)
|
||||
}
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.sndReady && 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()
|
||||
]
|
||||
)
|
||||
.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 {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
$0.content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,28 +432,6 @@ struct ChatListNavLink: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||
Alert(
|
||||
title: Text("Reject contact request"),
|
||||
message: Text("The sender will NOT be notified"),
|
||||
primaryButton: .destructive(Text("Reject")) {
|
||||
Task { await rejectContactRequest(contactRequest) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func pendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
|
||||
Alert(
|
||||
title: Text("Contact is not connected yet!"),
|
||||
message: Text("Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)."),
|
||||
primaryButton: .cancel(),
|
||||
secondaryButton: .destructive(Text("Delete Contact")) {
|
||||
removePendingContact(chat, contact)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func groupInvitationAcceptedAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Joining group"),
|
||||
@@ -459,30 +439,6 @@ 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)
|
||||
@@ -497,16 +453,28 @@ struct ChatListNavLink: View {
|
||||
|
||||
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito)
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
if ok {
|
||||
await MainActor.run {
|
||||
chatModel.chatId = contact.id
|
||||
}
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||
Alert(
|
||||
title: Text("Reject contact request"),
|
||||
message: Text("The sender will NOT be notified"),
|
||||
primaryButton: .destructive(Text("Reject")) {
|
||||
Task { await rejectContactRequest(contactRequest) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, showError: @escaping (ErrorAlert) -> Void, success: @escaping () -> Void = {}) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete pending connection?"),
|
||||
@@ -533,15 +501,14 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
||||
)
|
||||
}
|
||||
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool) async -> Bool {
|
||||
func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: (Alert) -> Void) async -> Bool {
|
||||
let (contact, alert) = await apiConnectContactViaAddress(incognito: incognito, contactId: contactId)
|
||||
if let alert = alert {
|
||||
AlertManager.shared.showAlert(alert)
|
||||
showAlert(alert)
|
||||
return false
|
||||
} else if let contact = contact {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContact(contact)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ 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
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
@@ -42,9 +42,6 @@ struct ChatListView: View {
|
||||
destination: chatView
|
||||
) {
|
||||
VStack {
|
||||
if chatModel.chats.isEmpty {
|
||||
onboardingButtons()
|
||||
}
|
||||
chatListView
|
||||
}
|
||||
}
|
||||
@@ -69,7 +66,9 @@ struct ChatListView: View {
|
||||
private var chatListView: some View {
|
||||
VStack {
|
||||
chatList
|
||||
toolbar
|
||||
}
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.onDisappear() { withAnimation { userPickerVisible = false } }
|
||||
.refreshable {
|
||||
AlertManager.shared.showAlert(Alert(
|
||||
@@ -91,7 +90,10 @@ struct ChatListView: View {
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.toolbar {
|
||||
}
|
||||
|
||||
@ViewBuilder private var toolbar: some View {
|
||||
let t = VStack{}.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
let user = chatModel.currentUser ?? User.sampleData
|
||||
ZStack(alignment: .topTrailing) {
|
||||
@@ -124,12 +126,20 @@ struct ChatListView: View {
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
switch chatModel.chatRunning {
|
||||
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
|
||||
case .some(true): NewChatMenuButton()
|
||||
case .some(false): chatStoppedIcon()
|
||||
case .none: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #unavailable(iOS 16) {
|
||||
t
|
||||
} else if oneHandUI {
|
||||
t.toolbarBackground(.visible, for: .navigationBar)
|
||||
} else {
|
||||
t.toolbarBackground(.visible, for: .bottomBar)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var chatList: some View {
|
||||
@@ -145,12 +155,14 @@ struct ChatListView: View {
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
|
||||
)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
.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))
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -169,7 +181,9 @@ struct ChatListView: View {
|
||||
stopAudioPlayer()
|
||||
}
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||
Text("No filtered chats").foregroundColor(theme.colors.secondary)
|
||||
Text("No filtered chats")
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,42 +194,6 @@ 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)
|
||||
@@ -233,16 +211,20 @@ struct ChatListView: View {
|
||||
} else {
|
||||
let s = searchString()
|
||||
return s == "" && !showUnreadAndFavorites
|
||||
? chatModel.chats
|
||||
? chatModel.chats.filter { chat in
|
||||
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
|
||||
}
|
||||
: 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 && chatContactType(chat: chat) != ContactType.card && (
|
||||
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)
|
||||
|
||||
@@ -275,7 +275,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(theme.colors.primary)
|
||||
} else if !contact.sndReady && contact.activeConn != nil {
|
||||
|
||||
272
apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
Normal file
272
apps/ios/Shared/Views/Contacts/ContactListNavLink.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
//
|
||||
// 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) {
|
||||
ChatModel.shared.chatId = 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 {
|
||||
var updatedContact = contact
|
||||
updatedContact.chatDeleted = false
|
||||
ChatModel.shared.updateContact(updatedContact)
|
||||
dismissAllSheets(animated: true) {
|
||||
ChatModel.shared.chatId = 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 {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.chatId = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ struct AddContactLearnMore: View {
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.modifier(ThemedBackground())
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,24 +35,28 @@ struct AddGroupView: View {
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
showTitle: false,
|
||||
creatingGroup: true
|
||||
) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Group link")
|
||||
}
|
||||
} else {
|
||||
createGroupView().keyboardPadding()
|
||||
@@ -62,13 +66,6 @@ struct AddGroupView: View {
|
||||
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)
|
||||
@@ -204,13 +201,14 @@ struct AddGroupView: View {
|
||||
chat = c
|
||||
}
|
||||
} catch {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error creating group"),
|
||||
message: Text(responseError(error))
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(
|
||||
Alert(
|
||||
title: Text("Error creating group"),
|
||||
message: Text(responseError(error))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,53 +7,454 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum NewChatMenuOption: Identifiable {
|
||||
case newContact
|
||||
case scanPaste
|
||||
case newGroup
|
||||
|
||||
var id: Self { self }
|
||||
enum ContactType: Int {
|
||||
case card, request, recent, chatDeleted, unlisted
|
||||
}
|
||||
|
||||
struct NewChatMenuButton: View {
|
||||
@Binding var newChatMenuOption: NewChatMenuOption?
|
||||
@State private var showNewChatSheet = false
|
||||
@State private var alert: SomeAlert? = nil
|
||||
@State private var globalAlert: SomeAlert? = nil
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button {
|
||||
newChatMenuOption = .newContact
|
||||
} label: {
|
||||
Text("Add contact")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .scanPaste
|
||||
} label: {
|
||||
Text("Scan / Paste link")
|
||||
}
|
||||
Button {
|
||||
newChatMenuOption = .newGroup
|
||||
} label: {
|
||||
Text("Create group")
|
||||
}
|
||||
showNewChatSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.sheet(item: $newChatMenuOption) { opt in
|
||||
switch opt {
|
||||
case .newContact: NewChatView(selection: .invite)
|
||||
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
case .newGroup: AddGroupView()
|
||||
.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
|
||||
}
|
||||
}
|
||||
.alert(item: $globalAlert) { a in
|
||||
return a.alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewChatMenuButton(
|
||||
newChatMenuOption: Binding.constant(nil)
|
||||
)
|
||||
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?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
viewBody()
|
||||
.navigationTitle("New Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> 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 {
|
||||
NewChatView(selection: .invite, parentAlert: $alert)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Add contact", systemImage: "link.badge.plus")
|
||||
}
|
||||
NavigationLink {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Scan / Paste link", systemImage: "qrcode")
|
||||
}
|
||||
NavigationLink {
|
||||
AddGroupView()
|
||||
.navigationTitle("Create secret group")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Create group", systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
|
||||
if (!filterContactTypes(chats: chatModel.chats, contactTypes: [.chatDeleted]).isEmpty) {
|
||||
Section {
|
||||
NavigationLink {
|
||||
DeletedChats()
|
||||
} label: {
|
||||
newChatActionButton("archivebox", color: theme.colors.secondary) { Text("Deleted chats") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContactsList(
|
||||
baseContactTypes: $baseContactTypes,
|
||||
searchMode: $searchMode,
|
||||
searchText: $searchText,
|
||||
header: "Your Contacts",
|
||||
searchFocussed: $searchFocussed,
|
||||
searchShowingSimplexLink: $searchShowingSimplexLink,
|
||||
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
|
||||
showDeletedChatIcon: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func newChatActionButton<Content : View>(_ icon: String, color: Color/* = .secondary*/, content: @escaping () -> Content) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).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(theme.colors.isLight ? theme.colors.background : theme.colors.secondaryVariant)
|
||||
.cornerRadius(10.0)
|
||||
|
||||
if searchFocussed {
|
||||
Text("Cancel")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.onTapGesture {
|
||||
searchText = ""
|
||||
searchFocussed = false
|
||||
}
|
||||
} else if m.chats.count > 0 {
|
||||
toggleFilterButton()
|
||||
}
|
||||
}
|
||||
.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("Deleted chats")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(searchMode)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewChatMenuButton()
|
||||
}
|
||||
|
||||
@@ -17,10 +17,19 @@ 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)"
|
||||
@@ -47,22 +56,10 @@ 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)
|
||||
@@ -88,6 +85,7 @@ 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()
|
||||
@@ -110,6 +108,13 @@ 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) {
|
||||
@@ -119,19 +124,22 @@ struct NewChatView: View {
|
||||
.onDisappear {
|
||||
if !(m.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
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: []
|
||||
))
|
||||
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: []
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
}
|
||||
m.showingInvitation = nil
|
||||
}
|
||||
@@ -837,7 +845,10 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
_ = await connectContactViaAddress(contact.contactId, incognito)
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
if ok {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
@@ -961,8 +972,13 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
)
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewChatView(
|
||||
selection: .invite
|
||||
)
|
||||
struct NewChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var parentAlert: SomeAlert?
|
||||
|
||||
NewChatView(
|
||||
selection: .invite,
|
||||
parentAlert: $parentAlert
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ extension AppSettings {
|
||||
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 { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) }
|
||||
}
|
||||
|
||||
public static var current: AppSettings {
|
||||
@@ -81,6 +82,7 @@ extension AppSettings {
|
||||
c.uiDarkColorScheme = systemDarkThemeDefault.get()
|
||||
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
||||
c.uiThemes = themeOverridesDefault.get()
|
||||
c.oneHandUI = def.bool(forKey: DEFAULT_ONE_HAND_UI)
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ 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(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
|
||||
|
||||
@State var themeUserDestination: (Int64, ThemeModeOverrides?)? = {
|
||||
if let currentUser = ChatModel.shared.currentUser, let uiThemes = currentUser.uiThemes, uiThemes.preferredMode(!CurrentColors.colors.isLight) != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
@@ -33,9 +34,6 @@ struct DeveloperView: View {
|
||||
} label: {
|
||||
settingsRow("terminal", color: theme.colors.secondary) { Text("Chat console") }
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -45,6 +43,19 @@ 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)
|
||||
}
|
||||
settingsRow("hand.wave", color: theme.colors.secondary) {
|
||||
Toggle("One-hand UI", isOn: $oneHandUI)
|
||||
}
|
||||
} header: {
|
||||
Text("Developer options")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ 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 = "oneHandUI"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
|
||||
@@ -61,6 +62,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 DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
|
||||
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
|
||||
|
||||
@@ -94,6 +97,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
DEFAULT_ENCRYPTION_STARTED: false,
|
||||
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
|
||||
DEFAULT_ONE_HAND_UI: false,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
|
||||
@@ -104,6 +108,8 @@ 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,
|
||||
@@ -158,6 +164,9 @@ 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: [:] )
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
|
||||
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
|
||||
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
|
||||
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
|
||||
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
|
||||
CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; };
|
||||
@@ -531,6 +532,7 @@
|
||||
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
|
||||
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
|
||||
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
|
||||
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
|
||||
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
|
||||
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
|
||||
@@ -685,6 +687,7 @@
|
||||
5C2E260D27A30E2400F70299 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B76E6C2F2C5C41C300EC11AA /* Contacts */,
|
||||
5CB0BA8C282711BC00B3292C /* Onboarding */,
|
||||
3C714775281C080100CB4D4B /* Call */,
|
||||
5C971E1F27AEBF7000C8A3CE /* Helpers */,
|
||||
@@ -1076,6 +1079,14 @@
|
||||
path = Theme;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B76E6C2F2C5C41C300EC11AA /* Contacts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */,
|
||||
);
|
||||
path = Contacts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CEE723A82C3BD3D70009AE93 /* SimpleX SE */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1382,6 +1393,7 @@
|
||||
64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */,
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
|
||||
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
|
||||
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */,
|
||||
|
||||
@@ -101,7 +101,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)
|
||||
@@ -266,11 +266,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)) \(chatDeleteMode.cmdString)"
|
||||
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))"
|
||||
@@ -505,10 +501,6 @@ public enum ChatCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func onOff(_ b: Bool) -> String {
|
||||
b ? "on" : "off"
|
||||
}
|
||||
|
||||
private func onOffParam(_ param: String, _ b: Bool?) -> String {
|
||||
if let b = b {
|
||||
return " \(param)=\(onOff(b))"
|
||||
@@ -521,6 +513,10 @@ public enum ChatCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private func onOff(_ b: Bool) -> String {
|
||||
b ? "on" : "off"
|
||||
}
|
||||
|
||||
public struct APIResponse: Decodable {
|
||||
var resp: ChatResponse
|
||||
}
|
||||
@@ -1048,6 +1044,27 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatDeleteMode: Codable {
|
||||
case full(notify: Bool)
|
||||
case entity(notify: Bool)
|
||||
case messages
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .full(notify): "full notify=\(onOff(notify))"
|
||||
case let .entity(notify): "entity notify=\(onOff(notify))"
|
||||
case .messages: "messages"
|
||||
}
|
||||
}
|
||||
|
||||
public var isEntity: Bool {
|
||||
switch self {
|
||||
case .entity: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectionPlan: Decodable, Hashable {
|
||||
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
|
||||
case contactAddress(contactAddressPlan: ContactAddressPlan)
|
||||
@@ -2141,7 +2158,8 @@ public struct AppSettings: Codable, Equatable, Hashable {
|
||||
public var uiDarkColorScheme: String? = nil
|
||||
public var uiCurrentThemeIds: [String: String]? = nil
|
||||
public var uiThemes: [ThemeOverrides]? = nil
|
||||
|
||||
public var oneHandUI: Bool? = nil
|
||||
|
||||
public func prepareForExport() -> AppSettings {
|
||||
var empty = AppSettings()
|
||||
let def = AppSettings.defaults
|
||||
@@ -2171,6 +2189,7 @@ public struct AppSettings: Codable, Equatable, Hashable {
|
||||
if uiDarkColorScheme != def.uiDarkColorScheme { empty.uiDarkColorScheme = uiDarkColorScheme }
|
||||
if uiCurrentThemeIds != def.uiCurrentThemeIds { empty.uiCurrentThemeIds = uiCurrentThemeIds }
|
||||
if uiThemes != def.uiThemes { empty.uiThemes = uiThemes }
|
||||
if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
|
||||
return empty
|
||||
}
|
||||
|
||||
@@ -2201,7 +2220,8 @@ public struct AppSettings: Codable, Equatable, Hashable {
|
||||
uiColorScheme: DefaultTheme.SYSTEM_THEME_NAME,
|
||||
uiDarkColorScheme: DefaultTheme.SIMPLEX.themeName,
|
||||
uiCurrentThemeIds: nil as [String: String]?,
|
||||
uiThemes: nil as [ThemeOverrides]?
|
||||
uiThemes: nil as [ThemeOverrides]?,
|
||||
oneHandUI: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,6 +1289,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var chatDeleted: Bool {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.chatDeleted
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var sendMsgEnabled: Bool {
|
||||
get {
|
||||
@@ -1401,6 +1410,27 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ShowEnableCallsAlert: Hashable {
|
||||
case userEnable
|
||||
case askContact
|
||||
case other
|
||||
}
|
||||
|
||||
public var showEnableCallsAlert: ShowEnableCallsAlert {
|
||||
switch self {
|
||||
case let .direct(contact):
|
||||
if contact.mergedPreferences.calls.userPreference.preference.allow == .no {
|
||||
return .userEnable
|
||||
} else if contact.mergedPreferences.calls.contactPreference.allow == .no {
|
||||
return .askContact
|
||||
} else {
|
||||
return .other
|
||||
}
|
||||
default:
|
||||
return .other
|
||||
}
|
||||
}
|
||||
|
||||
public var ntfsEnabled: Bool {
|
||||
self.chatSettings?.enableNtfs == .all
|
||||
}
|
||||
@@ -1508,7 +1538,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
var contactGroupMemberId: Int64?
|
||||
var contactGrpInvSent: Bool
|
||||
public var uiThemes: ThemeModeOverrides?
|
||||
|
||||
public var chatDeleted: Bool
|
||||
|
||||
public var id: ChatId { get { "@\(contactId)" } }
|
||||
public var apiId: Int64 { get { contactId } }
|
||||
public var ready: Bool { get { activeConn?.connStatus == .ready } }
|
||||
@@ -1575,13 +1606,15 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
mergedPreferences: ContactUserPreferences.sampleData,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
contactGrpInvSent: false
|
||||
contactGrpInvSent: false,
|
||||
chatDeleted: false
|
||||
)
|
||||
}
|
||||
|
||||
public enum ContactStatus: String, Decodable, Hashable {
|
||||
case active = "active"
|
||||
case deleted = "deleted"
|
||||
case deletedByUser = "deletedByUser"
|
||||
}
|
||||
|
||||
public struct ContactRef: Decodable, Equatable, Hashable {
|
||||
|
||||
Reference in New Issue
Block a user