Compare commits

..

22 Commits

Author SHA1 Message Date
Evgeny Poberezkin 02dc8a1597 Merge branch 'master' into contacts-ui-feature-branch 2024-07-01 11:30:47 +01:00
Evgeny Poberezkin 5907d8bd0c core: remove legacy agent stats (#4375) 2024-07-01 11:25:27 +01:00
sh 231082860e flatpak: verify app (#4379) 2024-07-01 09:34:38 +01:00
Evgeny Poberezkin 95c4e77075 Merge branch 'master' into contacts-ui-feature-branch 2024-06-30 23:42:48 +01:00
Evgeny Poberezkin c6366cb269 Merge branch 'stable' 2024-06-29 16:53:30 +01:00
sergeyshadok d066c66282 Update XFTP-SERVER.md (#4184)
Systemd itself uses %<something> tokens so %% needs to be used for specifiers

(https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html)
2024-06-29 16:50:28 +01:00
Evgeny Poberezkin fab3827697 core: fix markdown for trailing / and ) characters in the links (#4357) 2024-06-29 08:41:21 +01:00
Evgeny Poberezkin d8126e0cd0 website: update join team 2024-06-28 19:09:01 +01:00
sh 5baf8789d6 flatpak: add desktop and metainfo (#4366)
* flatpak: add desktop and metainfo

* metainfo: update

* corrections

* ampersand

* shorter

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-06-28 10:09:34 +01:00
Evgeny Poberezkin dd5454f390 Merge branch 'stable' 2024-06-28 00:12:10 +01:00
Evgeny Poberezkin fca098f535 docs: update donation addresses 2024-06-28 00:10:51 +01:00
spaced4ndy fba0478e50 docs: more additions to chat protocol (#4355)
* docs: more additions to chat protocol

* schema wip

* schema

* update diagram

* remove comments

* punctuation

* diagram text

* update threat model

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2024-06-25 08:37:54 +01:00
spaced4ndy d951003191 core: servers summary api (#4319)
* core: servers summary api

* rework

* server known types

* set stats file path

* rename

* local simplexmq

* update

* rfc

* update

* update

* get servers

* compile summary

* remove sort

* rename

* rename, refactor

* refactor attempt

* refactor attempt 2

* refactor

* fix

* fix2

* remove space

* refactor xftp

* update

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2024-06-25 09:51:55 +04:00
Evgeny Poberezkin 2a18770b96 Merge branch 'master' into contacts-ui-feature-branch 2024-06-11 11:18:39 +01:00
Evgeny Poberezkin dfdb3ca4a2 Merge branch 'master' into contacts-ui-feature-branch 2024-06-03 20:12:27 +01:00
spaced4ndy bd85b1063e multiplatform: contacts ui (#4214)
* types

* move stuff around

* move more stuff around

* padding

* tabs

* contacts list

* contact nav links, previews

* desktop list, better icon

* info

* refactor

* comments

* chat info buttons

* call buttons

* delete conversation dialogues

* change remember (contacts still don't update)

* contacts page refreshes on update

* delete contact dialogues

* move user picker to bottom left

* group member info buttons

* divider

* center on desktop

* comment

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2024-06-03 14:13:22 +04:00
spaced4ndy 55c1200007 Merge branch 'master' into contacts-ui-feature-branch 2024-06-03 12:51:30 +04:00
spaced4ndy 7abcb6a449 Merge branch 'master' into contacts-ui-feature-branch 2024-05-20 18:59:56 +04:00
spaced4ndy a7cc8348fd ios: move Contacts views 2024-05-20 15:54:55 +04:00
spaced4ndy d1b44719f2 Merge branch 'master' into contacts-ui-feature-branch 2024-05-20 10:31:58 +04:00
spaced4ndy 995863d78b ios: contacts UI improvements 2024-05-20 10:22:03 +04:00
spaced4ndy 8f8601eaa4 ios: UI redesign, contacts (#4116)
* ios: chat delete mode api

* redesign wip

* wip

* filter button right of search

* rework navigation (mostly works?)

* remove modifier

* search in bottom bar

* make filter button easier to press

* increase button size

* customizable search position

* reverse chat list wip

* change material

* list going behind toolbars

* increase spacing

* rework wip

* rework, sheets

* more scale effect

* remove search buttons, rework filter button

* remove onboarding buttons

* scan/paste menu

* wip

* contacts wip

* sizes

* remove unnecessary modifier

* contacts navigation wip

* paddings

* rework chat info view approach

* comment

* comment

* verified marker

* comment

* fix list not updating

* delete contact/conversation

* delete via chat list (has bugs)

* comment

* swipe on contact list

* fixes

* buttons wip

* message button to open chat

* buttons disabled

* call buttons work from sheet

* call button from contacts

* fix buttons

* show keyboard attempts

* Revert "show keyboard attempts"

This reverts commit daa50d1aa9.

* comment

* mark contact chat as not deleted when opening from contacts

* move to old view

* dont reverse contacts in one-hand mode

* change icons

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

* top bar, reduce padding

* increase filter button size

* support for contact cards

* fix some delete conversation bugs

* fix chat not being removed from list on deleting conversation

* add to app settings

* member view buttons

* icons

* remove unused code

* padding

* avatar

* resize avatar

* button

* add open button for deleted contact

* add deletedByUser status

* rework delete actions

* filter button in contacts list

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-13 17:04:46 +04:00
84 changed files with 3751 additions and 1229 deletions
+8 -9
View File
@@ -158,20 +158,19 @@ We are prioritizing users privacy and security - it would be impossible without
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
Your donations help us raise more funds any amount, even the price of the cup of coffee, would make a big difference for us.
Your donations help us raise more funds - any amount, even the price of the cup of coffee, would make a big difference for us.
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT:
- BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW
- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins.
Thank you,
+1 -1
View File
@@ -230,7 +230,7 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
HomeView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
+58 -4
View File
@@ -744,21 +744,37 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert)
}
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
throw r
}
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
@@ -769,6 +785,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
}
}
func deleteChatContact(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
// removeChat forces list update (for ContactsView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
// removeChat forces list update (for ChatListView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteChatContact apiDeleteContact error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
@@ -1655,6 +1704,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cItem = aChatItem.chatItem
await MainActor.run {
if active(user) {
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
m.updateContact(updatedContact)
}
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
+238 -32
View File
@@ -88,21 +88,36 @@ enum SendReceipts: Identifiable, Hashable {
}
}
enum ContactDeleteMode {
case full
case entity
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
switch self {
case .full: .full(notify: notify)
case .entity: .entity(notify: notify)
}
}
}
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var openedFromChatView: Bool
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@State private var showDeleteContactActionSheet = false
@State private var actionSheet: ChatInfoViewActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
@@ -110,6 +125,7 @@ struct ChatInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
case deleteContactNotice
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
@@ -120,12 +136,25 @@ struct ChatInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case .deleteContactNotice: return "deleteContactNotice"
case let .queueInfo(info): return "queueInfo \(info)"
case let .error(title, _): return "error \(title)"
}
}
}
enum ChatInfoViewActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
NavigationView {
List {
@@ -142,6 +171,23 @@ struct ChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
connectButton()
} else if !contact.active && !contact.chatDeleted {
openButton()
} else {
messageButton()
}
Spacer()
callButton()
Spacer()
videoButton()
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if let customUserProfile = customUserProfile {
Section("Incognito") {
HStack {
@@ -161,9 +207,9 @@ struct ChatInfoView: View {
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
@@ -249,6 +295,23 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
}
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
@@ -257,28 +320,47 @@ struct ChatInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case .deleteContactNotice: return deleteContactNotice(contact)
case let .queueInfo(info): return queueInfoAlert(info)
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
.actionSheet(item: $actionSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
} else {
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true)) },
.destructive(Text("Delete without notification")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
}
}
}
}
@@ -349,6 +431,83 @@ struct ChatInfoView: View {
}
}
// when contact is a "contact card"
private func connectButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "connect")
.onTapGesture {
showConnectContactViaAddressDialog = true
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
Button("Use current profile") { connectContactViaAddress_(contact, false) }
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
}
}
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok {
await MainActor.run {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
}
}
}
private func openButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "open")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
chatModel.chatId = chat.id
}
}
}
// TODO show keyboard
private func messageButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
.disabled(!contact.sendMsgEnabled)
}
private func callButton() -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton() -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
@@ -452,9 +611,9 @@ struct ChatInfoView: View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
showDeleteContactActionSheet = true
actionSheet = .deleteContactActionSheet
} label: {
Label("Delete contact", systemImage: "trash")
Label("Delete contact", systemImage: "person.badge.minus")
.foregroundColor(Color.red)
}
}
@@ -468,17 +627,34 @@ struct ChatInfoView: View {
}
}
private func deleteContact(notify: Bool? = nil) {
private func deleteContact(chatDeleteMode: ChatDeleteMode) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
let ct = try await apiDeleteContact(id: chat.chatInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
switch chatDeleteMode {
case .full:
chatModel.removeChat(chat.chatInfo.id)
case .entity:
chatModel.updateContact(ct)
// dismissing sheet when opened from ChatView closes deleteContactNotice alert,
// also it makes less sense to show this alert as user is already in Chats tab
if showDeleteContactNotice && !openedFromChatView {
alert = .deleteContactNotice
}
case .messages:
logger.warning("ChatInfoView deleteContact case .messages should be unreachable")
chatModel.removeChat(chat.chatInfo.id)
chatModel.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
logger.error("ChatInfoView deleteContact apiDeleteContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
@@ -563,6 +739,38 @@ struct ChatInfoView: View {
}
}
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
}
struct InfoViewActionButtonLayout: View {
var image: String
var title: LocalizedStringKey
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12.0)
.frame(width: 90, height: 60)
}
}
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
@@ -611,12 +819,10 @@ func queueInfoAlert(_ info: String) -> Alert {
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
openedFromChatView: true,
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
localAlias: ""
)
}
}
+9 -26
View File
@@ -23,9 +23,6 @@ struct ChatView: View {
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var keyboardVisible = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -45,6 +42,7 @@ struct ChatView: View {
var body: some View {
if #available(iOS 16.0, *) {
viewBody
.toolbarBackground(.visible, for: .navigationBar)
.scrollDismissesKeyboard(.immediately)
.keyboardPadding()
} else {
@@ -111,32 +109,17 @@ struct ChatView: View {
ToolbarItem(placement: .principal) {
if case let .direct(contact) = cInfo {
Button {
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
await MainActor.run { showChatInfoSheet = true }
}
showChatInfoSheet = true
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
connectionCode = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
.appSheet(isPresented: $showChatInfoSheet) {
ChatInfoView(
openedFromChatView: true,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
}
} else if case let .group(groupInfo) = cInfo {
Button {
@@ -64,10 +64,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,18 +81,35 @@ struct GroupMemberInfoView: View {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat)
Spacer()
callButton(contact)
Spacer()
videoButton(contact)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
Spacer()
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.disabled(true)
Spacer()
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.disabled(true)
}
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if member.memberActive {
Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@@ -264,58 +282,71 @@ struct GroupMemberInfoView: View {
}
func knownDirectChatButton(_ chat: Chat) -> some View {
Button {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
}
}
func createMemberContactButton() -> some View {
Button {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
}
}
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func callButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
@@ -63,7 +63,6 @@ struct ChatHelp: View {
struct ChatHelp_Previews: PreviewProvider {
static var previews: some View {
@State var showSettings = false
return ChatHelp(showSettings: $showSettings)
return ChatHelp(showSettings: Binding.constant(false))
}
}
@@ -32,11 +32,25 @@ struct ChatListNavLink: View {
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) private var showDeleteConversationNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
Group {
switch chat.chatInfo {
@@ -67,12 +81,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "trash")
}
@@ -100,7 +114,7 @@ struct ChatListNavLink: View {
}
Button {
if contact.ready || !contact.active {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
}
@@ -112,24 +126,53 @@ struct ChatListNavLink: View {
.frame(height: rowHeights[dynamicTypeSize])
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
if contact.contactStatus == .deletedByUser {
return ActionSheet(
title: Text("Delete conversation?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete conversation")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet },
.destructive(Text("Only delete conversation")) {
Task {
await deleteChatContact(chat, chatDeleteMode: .messages)
if showDeleteConversationNotice {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
}
}
},
.cancel()
]
)
}
case .confirmDeleteContactActionSheet:
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: true)) } },
.destructive(Text("Delete without notification")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
}
}
}
}
@@ -439,6 +482,17 @@ struct ChatListNavLink: View {
)
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can still send messages to \(contact.displayName) from the Contacts tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
+37 -181
View File
@@ -11,16 +11,15 @@ import SimpleXChat
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
if #available(iOS 16.0, *) {
@@ -31,44 +30,10 @@ struct ChatListView: View {
}
private var viewBody: some View {
ZStack(alignment: .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView
) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
}
chatListView
}
}
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
private var chatListView: some View {
VStack {
chatList
}
.onDisappear() { withAnimation { userPickerVisible = false } }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -86,58 +51,6 @@ struct ChatListView: View {
))
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
unreadBadge(size: 12)
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
toggleFilterButton()
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
}
}
}
private func toggleFilterButton() -> some View {
Button {
showUnreadAndFavorites = !showUnreadAndFavorites
} label: {
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
.foregroundColor(.accentColor)
}
}
@ViewBuilder private var chatList: some View {
@@ -153,11 +66,13 @@ struct ChatListView: View {
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
@@ -171,7 +86,9 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(.secondary)
Text("No filtered chats")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
@@ -182,66 +99,24 @@ struct ChatListView: View {
.foregroundColor(.accentColor)
}
private func onboardingButtons() -> some View {
VStack(alignment: .trailing, spacing: 0) {
Path { p in
p.move(to: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: 16, y: 10))
p.addLine(to: CGPoint(x: 0, y: 10))
p.addLine(to: CGPoint(x: 8, y: 0))
}
.fill(Color.accentColor)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact
}
Spacer()
Text("You have no chats")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.trailing, 6)
.frame(maxHeight: .infinity)
}
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.padding(.vertical, 10)
.padding(.horizontal, 20)
}
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
? chatModel.chats
? chatModel.chats.filter { chat in !chat.chatInfo.chatDeleted }
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
case let .direct(contact):
return s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
return !contact.chatDeleted && (
s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
case let .group(gInfo):
return s == ""
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
@@ -282,9 +157,9 @@ struct ChatListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
@State private var showScanCodeSheet = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
@@ -301,26 +176,6 @@ struct ChatListSearchBar: View {
.onTapGesture {
searchText = ""
}
} else if !searchFocussed {
HStack(spacing: 24) {
if m.pasteboardHasStrings {
Image(systemName: "doc")
.onTapGesture {
if let str = UIPasteboard.general.string {
searchText = str
}
}
}
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.onTapGesture {
showScanCodeSheet = true
}
}
.padding(.trailing, 2)
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
@@ -335,13 +190,10 @@ struct ChatListSearchBar: View {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
Divider()
}
.sheet(isPresented: $showScanCodeSheet) {
NewChatView(selection: .connect, showQRCodeScanner: true)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
@@ -376,6 +228,21 @@ struct ChatListSearchBar: View {
}
}
private func toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -389,17 +256,6 @@ struct ChatListSearchBar: View {
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
@@ -419,9 +275,9 @@ struct ChatListView_Previews: PreviewProvider {
]
return Group {
ChatListView(showSettings: Binding.constant(false))
ChatListView()
.environmentObject(chatModel)
ChatListView(showSettings: Binding.constant(false))
ChatListView()
.environmentObject(ChatModel())
}
}
@@ -204,7 +204,7 @@ struct ChatPreviewView: View {
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(.accentColor)
} else if !contact.ready && contact.activeConn != nil {
@@ -0,0 +1,210 @@
//
// ContactListNavLink.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactListNavLink: View {
@ObservedObject var chat: Chat
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
switch chat.chatInfo {
case let .direct(contact):
NavigationLink {
ChatInfoView(
openedFromChatView: false,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
} label: {
HStack{
ZStack(alignment: .bottomTrailing) {
ProfileImage(imageStr: contact.image, size: 38)
chatPreviewImageOverlayIcon(contact)
.padding([.bottom, .trailing], 1)
}
.padding(.trailing, 2)
previewTitle(contact)
Spacer()
HStack {
if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
}
if contact.contactConnIncognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
}
}
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
toggleFavoriteButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "person.badge.minus")
}
.tint(.red)
}
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.destructive(Text("Delete without notification")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
}
}
}
default:
EmptyView()
}
}
@ViewBuilder private func previewTitle(_ contact: Contact) -> some View {
let t = Text(chat.chatInfo.chatViewName)
(
contact.verified == true
? verifiedIcon + t
: t
)
.lineLimit(1)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ViewBuilder private func chatPreviewImageOverlayIcon(_ contact: Contact) -> some View {
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
}
private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
@ViewBuilder private func toggleFavoriteButton() -> some View {
if chat.chatInfo.chatSettings?.favorite == true {
Button {
toggleChatFavorite(chat, favorite: false)
} label: {
Label("Unfav.", systemImage: "star.slash")
}
.tint(.green)
} else {
Button {
toggleChatFavorite(chat, favorite: true)
} label: {
Label("Favorite", systemImage: "star.fill")
}
.tint(.green)
}
}
}
#Preview {
ContactListNavLink(chat: Chat.sampleData)
}
@@ -0,0 +1,199 @@
//
// ContactsView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactsView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
} else {
viewBody
}
}
private var viewBody: some View {
VStack {
contactList
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listStyle(.plain)
}
@ViewBuilder private var contactList: some View {
let contactChats = contactChats()
let filteredContactChats = filteredContactChats(contactChats)
ZStack {
VStack {
List {
if !contactChats.isEmpty {
ContactsSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
ForEach(filteredContactChats, id: \.viewId) { chat in
switch chat.chatInfo {
case let .direct(contact):
ContactListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(contact.id))
default:
EmptyView()
}
}
}
}
if filteredContactChats.isEmpty && !contactChats.isEmpty {
Text("No filtered contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
} else if contactChats.isEmpty {
Text("No contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
private func contactChats() -> [Chat] {
return chatModel.chats.filter { chat in
switch chat.chatInfo {
case .direct: true
default: false
}
}
}
private func filteredContactChats(_ contactChats: [Chat]) -> [Chat] {
let s = searchString()
return (
s == "" && !showUnreadAndFavorites
? contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact): return contact.contactStatus != .deletedByUser
default: return false
}
}
: contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact):
return contact.contactStatus != .deletedByUser && (
s == ""
? (chat.chatInfo.chatSettings?.favorite ?? false)
: (viewNameContains(contact, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
default: return false
}
}
)
.sorted{ $0.chatInfo.displayName.lowercased() < $1.chatInfo.displayName.lowercased() }
func searchString() -> String {
searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func viewNameContains(_ contact: Contact, _ s: String) -> Bool {
contact.chatViewName.localizedLowercase.contains(s)
}
}
}
struct ContactsSearchBar: View {
@EnvironmentObject var m: ChatModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
searchText = ""
}
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(.accentColor)
.onTapGesture {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
}
private func toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
}
struct ContactsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chats = [
Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: []
)
]
return Group {
ContactsView()
.environmentObject(chatModel)
ContactsView()
.environmentObject(ChatModel())
}
}
}
+229
View File
@@ -0,0 +1,229 @@
//
// HomeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 01.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum HomeTab {
case contacts
case chats
}
struct HomeView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var homeTab: HomeTab = .chats
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var toolbarHeight: CGFloat = 0
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
ZStack(alignment: .bottomLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView,
content: homeView
)
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
@ViewBuilder private func homeView() -> some View {
let v = VStack {
switch homeTab {
case .contacts: withToolbar("Contacts", contactsView)
case .chats: withToolbar("Chats", chatListView)
}
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
settingsButton()
Spacer()
contactsButton()
Spacer()
chatsButton()
Spacer()
newChatButton()
}
}
if #unavailable(iOS 16) {
v
} else if oneHandUI {
v.toolbarBackground(.visible, for: .navigationBar, .bottomBar)
} else {
v.toolbarBackground(.visible, for: .bottomBar)
}
}
func withToolbar<V: View>(_ title: LocalizedStringKey, _ content: () -> V) -> some View {
content()
.navigationBarTitleDisplayMode(oneHandUI ? .inline : .large)
.navigationTitle(title)
}
@ViewBuilder private func settingsButton() -> some View {
let user = chatModel.currentUser ?? User.sampleData
let multiUser = chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1
Button {
if multiUser {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
} label: {
VStack(spacing: 2) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 42, color: Color(uiColor: .quaternaryLabel))
.padding(.top, 3)
.padding(.trailing, 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
userUnreadBadge(size: 12)
}
}
}
}
.foregroundColor(.secondary)
}
private func userUnreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(.accentColor)
}
private func contactsButton() -> some View {
Button {
homeTab = .contacts
} label: {
iconLabel(homeTab == .contacts ? "person.crop.circle.fill" : "person.crop.circle", "Contacts")
}
.foregroundColor(.secondary)
}
private func chatsButton() -> some View {
Button {
homeTab = .chats
} label: {
iconLabel(homeTab == .chats ? "message.fill" : "message", "Chats")
}
.foregroundColor(.secondary)
}
@ViewBuilder private func newChatButton() -> some View {
if case .some(false) = chatModel.chatRunning {
chatsStoppedButton()
} else {
Menu {
Button {
newChatMenuOption = .newGroup
} label: {
Text("Create group")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newContact
} label: {
Text("Add contact")
}
} label: {
iconLabel("square.and.pencil", "New chat")
}
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
}
}
func chatsStoppedButton() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
VStack(spacing: 4) {
Image(systemName: "exclamationmark.octagon.fill")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.frame(width: 24, height: 24)
Text("Stopped")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func iconLabel(_ image: String, _ title: LocalizedStringKey) -> some View {
VStack(spacing: 2) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption2)
}
.padding(.top, 3)
}
@ViewBuilder private func contactsView() -> some View {
ContactsView()
}
@ViewBuilder private func chatListView() -> some View {
// TODO reverse scale effect for swipe actions
ChatListView()
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
}
#Preview {
HomeView(showSettings: Binding.constant(false))
}
@@ -10,6 +10,7 @@ import SwiftUI
enum NewChatMenuOption: Identifiable {
case newContact
case scanPaste
case newGroup
var id: Self { self }
@@ -25,6 +26,11 @@ struct NewChatMenuButton: View {
} label: {
Text("Add contact")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newGroup
} label: {
@@ -39,6 +45,7 @@ struct NewChatMenuButton: View {
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
@@ -44,6 +44,7 @@ extension AppSettings {
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
if let val = oneHandUI { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) }
}
public static var current: AppSettings {
@@ -69,6 +70,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.oneHandUI = def.bool(forKey: DEFAULT_ONE_HAND_UI)
return c
}
}
@@ -25,6 +25,7 @@ struct AppearanceSettings: View {
@State private var userInterfaceStyle = getUserInterfaceStyleDefault()
@State private var uiTintColor = getUIAccentColorDefault()
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
VStack{
@@ -39,6 +40,12 @@ struct AppearanceSettings: View {
}
}
// Section("Interface") {
// settingsRow("hand.wave") {
// Toggle("One-hand UI", isOn: $oneHandUI)
// }
// }
Section("App icon") {
HStack {
updateAppIcon(image: "icon-light", icon: nil, tapped: $iconLightTapped)
@@ -12,6 +12,7 @@ import SimpleXChat
struct DeveloperView: View {
@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 {
@@ -31,9 +32,6 @@ struct DeveloperView: View {
} label: {
settingsRow("terminal") { Text("Chat console") }
}
settingsRow("internaldrive") {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
settingsRow("chevron.left.forwardslash.chevron.right") {
Toggle("Show developer options", isOn: $developerTools)
}
@@ -42,6 +40,19 @@ struct DeveloperView: View {
} footer: {
(developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")
}
if developerTools {
Section {
settingsRow("internaldrive") {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
settingsRow("hand.wave") {
Toggle("One-hand UI", isOn: $oneHandUI)
}
} header: {
Text("Developer options")
}
}
}
}
}
@@ -46,6 +46,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen"
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_ONE_HAND_UI = "oneHandUI"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
@@ -60,6 +61,8 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
@@ -90,6 +93,7 @@ let appDefaults: [String: Any] = [
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
DEFAULT_USER_INTERFACE_STYLE: 0,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_ONE_HAND_UI: false,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
@@ -100,6 +104,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,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
]
@@ -448,9 +454,8 @@ struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
@State var showSettings = false
return SettingsView(showSettings: $showSettings)
return SettingsView(showSettings: Binding.constant(false))
.environmentObject(chatModel)
}
}
@@ -403,6 +403,6 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true))
UserProfilesView(showSettings: Binding.constant(false))
}
}
@@ -150,6 +150,7 @@
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642CE4942BE2651E00AD7757 /* HomeView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
@@ -167,9 +168,11 @@
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
@@ -445,6 +448,7 @@
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642CE4942BE2651E00AD7757 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
@@ -462,10 +466,12 @@
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
@@ -562,11 +568,13 @@
5C2E260D27A30E2400F70299 /* Views */ = {
isa = PBXGroup;
children = (
642CE4932BE2650500AD7757 /* Home */,
5CB0BA8C282711BC00B3292C /* Onboarding */,
3C714775281C080100CB4D4B /* Call */,
5C971E1F27AEBF7000C8A3CE /* Helpers */,
5C5F4AC227A5E9AF00B51EF1 /* Chat */,
5CB9250B27A942F300ACCCDD /* ChatList */,
6485F7C42BE8DB4F00FAE413 /* Contacts */,
5CB924DD27A8622200ACCCDD /* NewChat */,
5CFA59C22860B04D00863A68 /* Database */,
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
@@ -899,6 +907,14 @@
path = Database;
sourceTree = "<group>";
};
642CE4932BE2650500AD7757 /* Home */ = {
isa = PBXGroup;
children = (
642CE4942BE2651E00AD7757 /* HomeView.swift */,
);
path = Home;
sourceTree = "<group>";
};
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
@@ -913,6 +929,15 @@
path = Group;
sourceTree = "<group>";
};
6485F7C42BE8DB4F00FAE413 /* Contacts */ = {
isa = PBXGroup;
children = (
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */,
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */,
);
path = Contacts;
sourceTree = "<group>";
};
8C7D94982B8894D300B7B9E1 /* Migration */ = {
isa = PBXGroup;
children = (
@@ -1148,6 +1173,7 @@
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
@@ -1168,6 +1194,7 @@
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
@@ -1212,6 +1239,7 @@
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */,
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
+24 -11
View File
@@ -99,7 +99,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)
@@ -257,11 +257,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))"
@@ -485,10 +481,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))"
@@ -501,6 +493,10 @@ public enum ChatCommand {
}
}
private func onOff(_ b: Bool) -> String {
b ? "on" : "off"
}
public struct APIResponse: Decodable {
var resp: ChatResponse
}
@@ -1010,6 +1006,20 @@ 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 enum ConnectionPlan: Decodable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -2071,6 +2081,7 @@ public struct AppSettings: Codable, Equatable {
public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
public var iosCallKitEnabled: Bool? = nil
public var iosCallKitCallsInRecents: Bool? = nil
public var oneHandUI: Bool? = nil
public func prepareForExport() -> AppSettings {
var empty = AppSettings()
@@ -2095,6 +2106,7 @@ public struct AppSettings: Codable, Equatable {
if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
return empty
}
@@ -2119,7 +2131,8 @@ public struct AppSettings: Codable, Equatable {
confirmDBUpgrades: false,
androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
iosCallKitEnabled: true,
iosCallKitCallsInRecents: false
iosCallKitCallsInRecents: false,
oneHandUI: false
)
}
}
+13 -1
View File
@@ -1287,6 +1287,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var chatDeleted: Bool {
get {
switch self {
case let .direct(contact): return contact.chatDeleted
default: return false
}
}
}
public var sendMsgEnabled: Bool {
get {
switch self {
@@ -1504,6 +1513,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatDeleted: Bool
public var id: ChatId { get { "@\(contactId)" } }
public var apiId: Int64 { get { contactId } }
@@ -1570,13 +1580,15 @@ public struct Contact: Identifiable, Decodable, NamedChat {
mergedPreferences: ContactUserPreferences.sampleData,
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false
contactGrpInvSent: false,
chatDeleted: false
)
}
public enum ContactStatus: String, Decodable {
case active = "active"
case deleted = "deleted"
case deletedByUser = "deletedByUser"
}
public struct ContactRef: Decodable, Equatable {
@@ -1,4 +1,4 @@
package chat.simplex.common.views.chatlist
package chat.simplex.common.views.home
import android.app.Activity
import androidx.compose.foundation.*
@@ -4,16 +4,12 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
@@ -28,6 +24,7 @@ import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.home.*
import chat.simplex.common.views.localauth.VerticalDivider
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.usersettings.*
@@ -287,7 +284,7 @@ fun StartPartOfScreen(settingsState: SettingsViewState) {
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
HomeView(chatModel, settingsState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, settingsState, stopped)
}
@@ -779,6 +779,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -859,6 +860,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val chatDeleted get() = contact.chatDeleted
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
@@ -883,6 +885,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val chatDeleted get() = groupInfo.chatDeleted
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
@@ -907,6 +910,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = noteFolder.id
override val apiId get() = noteFolder.apiId
override val ready get() = noteFolder.ready
override val chatDeleted get() = noteFolder.chatDeleted
override val sendMsgEnabled get() = noteFolder.sendMsgEnabled
override val ntfsEnabled get() = noteFolder.ntfsEnabled
override val incognito get() = noteFolder.incognito
@@ -931,6 +935,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val chatDeleted get() = contactRequest.chatDeleted
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
@@ -955,6 +960,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactConnection.id
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val chatDeleted get() = contactConnection.chatDeleted
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
@@ -980,6 +986,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = ""
override val apiId get() = 0L
override val ready get() = false
override val chatDeleted get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
@@ -1056,6 +1063,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
@@ -1129,6 +1137,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1137,7 +1146,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1276,6 +1286,7 @@ data class GroupInfo (
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val chatDeleted get() = false
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito
@@ -1581,6 +1592,7 @@ class NoteFolder(
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = false
@@ -1617,6 +1629,7 @@ class UserContactRequest (
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -1656,6 +1669,7 @@ class PendingContactConnection(
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -204,6 +204,8 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true)
val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
@@ -380,6 +382,8 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
@@ -1108,16 +1112,16 @@ object ChatController {
}
}
suspend fun deleteChat(chat: Chat, notify: Boolean? = null) {
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val cInfo = chat.chatInfo
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) {
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
chatModel.removeChat(chat.remoteHostId, cInfo.id)
}
}
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean {
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify))
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val success = when {
r is CR.ContactDeleted && type == ChatType.Direct -> true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true
@@ -1138,6 +1142,22 @@ object ChatController {
return success
}
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
val type = ChatType.Direct
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val contact = when {
r is CR.ContactDeleted -> r.contact
else -> {
val titleId = MR.strings.error_deleting_contact
apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
null
}
}
chatModel.deletedChats.value -= rh to type.type + id
return contact
}
fun clearChat(chat: Chat, close: (() -> Unit)? = null) {
withBGApi {
val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId)
@@ -1876,6 +1896,10 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (active(r.user)) {
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
chatModel.updateContact(rhId, updatedContact)
}
chatModel.addChatItem(rhId, cInfo, cItem)
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
@@ -2585,7 +2609,7 @@ sealed class CC {
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -2732,11 +2756,7 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -2949,8 +2969,6 @@ sealed class CC {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
@@ -2960,6 +2978,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -4781,6 +4801,19 @@ fun chatError(r: CR): ChatErrorType? {
)
}
@Serializable
sealed class ChatDeleteMode {
@Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("messages") class Messages: ChatDeleteMode()
val cmdString: String get() = when (this) {
is ChatDeleteMode.Full -> "full notify=${onOff(notify)}"
is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}"
is ChatDeleteMode.Messages -> "messages"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -12,12 +12,14 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
@@ -35,7 +37,8 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@@ -45,9 +48,21 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.File
sealed class ContactDeleteMode {
class Full: ContactDeleteMode()
class Entity: ContactDeleteMode()
fun toChatDeleteMode(notify: Boolean): ChatDeleteMode =
when (this) {
is Full -> ChatDeleteMode.Full(notify)
is Entity -> ChatDeleteMode.Entity(notify)
}
}
@Composable
fun ChatInfoView(
chatModel: ChatModel,
openedFromChatView: Boolean,
contact: Contact,
connectionStats: ConnectionStats?,
customUserProfile: Profile?,
@@ -68,6 +83,7 @@ fun ChatInfoView(
val chatRh = chat.remoteHostId
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
openedFromChatView = openedFromChatView,
chat,
contact,
currentUser,
@@ -166,7 +182,8 @@ fun ChatInfoView(
)
}
}
}
},
close = close,
)
}
}
@@ -201,53 +218,127 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
buttons = {
Column {
if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
// Delete and notify contact
if (chatInfo is ChatInfo.Direct) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
buttons = {
Column {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Full())
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
if (!chatInfo.contact.chatDeleted) {
// Delete contact, keep conversation
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Entity())
}) {
Text(generalGetString(MR.strings.delete_contact_keep_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = false)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
fun notifyDeleteContactDialog(
chat: Chat,
chatModel: ChatModel,
close: (() -> Unit)? = null,
contactDeleteMode: ContactDeleteMode = ContactDeleteMode.Full()
) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactActive = chatInfo.contact.ready && chatInfo.contact.active
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactActive) generalGetString(MR.strings.notify_delete_contact_question) else generalGetString(MR.strings.confirm_delete_contact_question),
text = when (contactDeleteMode) {
is ContactDeleteMode.Full -> generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)
is ContactDeleteMode.Entity -> generalGetString(MR.strings.delete_contact_cannot_undo_warning)
},
buttons = {
Column {
if (contactActive) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = true))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteContactNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.contact_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteContactNotice.set(false)
},
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val chatInfo = chat.chatInfo
withBGApi {
val chatRh = chat.remoteHostId
val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatRh, chatInfo.id)
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
chatModel.removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
chatModel.updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
chatModel.clearChat(chatRh, ChatInfo.Direct(ct))
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -280,6 +371,7 @@ fun clearNoteFolderDialog(chat: Chat, close: (() -> Unit)? = null) {
@Composable
fun ChatInfoLayout(
openedFromChatView: Boolean,
chat: Chat,
contact: Contact,
currentUser: User,
@@ -300,6 +392,7 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -319,7 +412,31 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (contact.activeConn == null && contact.profile.contactLink != null && contact.active) {
ConnectButton(openedFromChatView, chat, contact, close)
} else if (!contact.active && !contact.chatDeleted) {
OpenButton(openedFromChatView, chat, contact, close)
} else {
MessageButton(openedFromChatView, chat, contact, close)
}
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -535,6 +652,155 @@ fun LocalAliasEditor(
}
}
// when contact is a "contact card"
@Composable
private fun ConnectButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_connect_button),
disabled = false,
onClick = {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName),
buttons = {
Column {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = false, close)
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = true, close)
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
hostDevice = hostDevice(chat.remoteHostId),
)
}
)
}
private fun infoConnectContactViaAddress(openedFromChatView: Boolean, chat: Chat, contact: Contact, incognito: Boolean, close: () -> Unit) {
withBGApi {
val ok = connectContactViaAddress(chatModel, chat.remoteHostId, contact.contactId, incognito = incognito)
if (ok) {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
}
@Composable
private fun OpenButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_open_button),
disabled = false,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
private fun MessageButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = !contact.sendMsgEnabled,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_call_filled),
title = generalGetString(MR.strings.info_view_call_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Audio)
}
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_videocam_filled),
title = generalGetString(MR.strings.info_view_video_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Video)
}
)
}
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, onClick: () -> Unit) {
Surface(
Modifier
.width(96.dp)
.height(66.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colors.secondaryVariant,
) {
val modifier = if (disabled) Modifier else Modifier.clickable { onClick () }
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(26.dp),
tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -823,6 +1089,7 @@ fun queueInfoText(info: Pair<RcvMsgInfo?, QueueInfo>): String {
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
openedFromChatView = false,
chat = Chat(
remoteHostId = null,
chatInfo = ChatInfo.Direct.sampleData,
@@ -847,6 +1114,7 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
)
}
}
@@ -189,7 +189,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
ChatInfoView(chatModel, openedFromChatView = true, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -306,18 +306,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
startCall = out@{ media -> startChatCall(chat, media) },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
@@ -521,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
fun startChatCall(chat: Chat, media: CallMediaType) {
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
}
@Composable
fun ChatLayout(
chat: Chat,
@@ -36,6 +36,7 @@ import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.openLoadedChat
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -246,10 +247,10 @@ fun GroupMemberInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
fun knownDirectChat(contactId: Long): Pair<Chat, Contact>? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
chat to chat.chatInfo.contact
} else {
null
}
@@ -309,17 +310,37 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
OpenChatButton(onClick = { openDirectChat(contact.contactId) })
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_call_filled), generalGetString(MR.strings.info_view_call_button), disabled = true, onClick = {})
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_videocam_filled), generalGetString(MR.strings.info_view_video_button), disabled = true, onClick = {})
}
}
SectionSpacer()
if (member.memberActive) {
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
OpenChatButton(onClick = { createMemberContact() })
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@@ -513,12 +534,11 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_chat),
stringResource(MR.strings.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
onClick = onClick
)
}
@@ -172,7 +172,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
}
@Composable
private fun ErrorChatListItem() {
fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
@@ -407,13 +407,73 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
deleteContactConversationDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun deleteContactConversationDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactDeletedByUser = chatInfo.contact.contactStatus == ContactStatus.DeletedByUser
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_question) else generalGetString(MR.strings.delete_contact_question),
text = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_all_messages_deleted_cannot_undo_warning) else null,
buttons = {
Column {
if (contactDeletedByUser) {
// Delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Full(notify = false))
}) {
Text(generalGetString(MR.strings.delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteConversationNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.conversation_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteConversationNotice.set(false)
},
)
}
@Composable
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -3,336 +3,46 @@ package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.TextRange
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.net.URI
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
VideoPlayerHolder.stopAll()
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) {
OnboardingButtons(showNewChatSheet)
}
}
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
}
}
@Composable
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
val color = MaterialTheme.colors.primaryVariant
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
val trianglePath = Path().apply {
moveTo(0.dp.toPx(), 0f)
lineTo(16.dp.toPx(), 0.dp.toPx())
lineTo(8.dp.toPx(), 10.dp.toPx())
lineTo(0.dp.toPx(), 0.dp.toPx())
}
drawPath(
color = color,
path = trianglePath
)
})
Spacer(Modifier.height(62.dp))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
onClick,
shape = RoundedCornerShape(21.dp),
colors = ButtonDefaults.textButtonColors(
backgroundColor = MaterialTheme.colors.primaryVariant
),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp)
) {
Text(text, color = Color.White)
}
}
@Composable
private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
val scope = rememberCoroutineScope()
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.chats.size > 0) {
val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } }
if (enabled.value) {
ToggleFilterEnabledButton()
} else {
ToggleFilterDisabledButton()
}
}
}
},
onTitleClick = null,
showSearch = false,
onSearchValueChanged = {},
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 37.dp,
color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
text ?: "",
color = MaterialTheme.colors.onPrimary,
fontSize = 6.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.align(Alignment.TopEnd)
)
}
@Composable
private fun ToggleFilterEnabledButton() {
fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
private fun ToggleFilterDisabledButton() {
IconButton({}, enabled = false) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri
} else {
withBGApi {
planAndConnect(rhId, uri, incognito = null, close = null)
}
}
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -359,28 +69,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
val clipboard = LocalClipboardManager.current
val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value
if (clipboardHasText) {
IconButton(
onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) },
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary)
}
}
Spacer(Modifier.width(padding))
IconButton(
onClick = {
val fixedRhId = chatModel.currentRemoteHost.value
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close ->
NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close)
}
},
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary)
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
@@ -437,17 +127,10 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
}
}
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
private var lazyListState = 0 to 0
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
@@ -508,17 +191,18 @@ private fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats
chats.filter { chat -> !chat.chatInfo.chatDeleted }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Direct -> !chat.chatInfo.chatDeleted && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
@@ -198,7 +198,7 @@ fun ChatPreviewView(
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) {
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary)
} else if (!cInfo.ready && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -16,6 +16,7 @@ import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.home.UserProfileButton
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@@ -153,6 +153,8 @@ fun UserPicker(
) {
Column(
Modifier
.align(Alignment.BottomStart)
.padding(bottom = BottomAppBarHeight)
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
@@ -0,0 +1,104 @@
package chat.simplex.common.views.contacts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
val view = LocalMultiplatformView()
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
val chatRh = chat.remoteHostId
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
// return@ChatLayout -- TODO what's this for?
}
hideKeyboard(view)
withBGApi {
var preloadedContactInfo: Pair<ConnectionStats?, Profile?>? = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
var preloadedCode: String? = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
ModalManager.end.showModalCloseable(true) { close ->
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, openedFromChatView = false, chat.chatInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
)
}
else -> {}
}
}
@Composable
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
if (contact.activeConn != null) {
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
}
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat),
if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star),
onClick = {
toggleChatFavorite(chat, !favorite, chatModel)
showMenu.value = false
}
)
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@@ -0,0 +1,117 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_SPACE_AFTER_ICON
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
@Composable
fun inactiveIcon() {
Icon(
painterResource(MR.images.ic_cancel_filled),
stringResource(MR.strings.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
tint = MaterialTheme.colors.secondary
)
}
@Composable
fun chatPreviewImageOverlayIcon() {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.contact.active) {
inactiveIcon()
}
else -> {}
}
}
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
fun chatPreviewTitle() {
val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) }
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (deleting) MaterialTheme.colors.secondary else Color.Unspecified
)
}
else -> {}
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 42.dp)
Box(Modifier.padding(end = 2.dp, bottom = 2.dp)) {
chatPreviewImageOverlayIcon()
}
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Box(modifier = Modifier.weight(10f, fill = true)) {
chatPreviewTitle()
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (chat.chatInfo.chatSettings?.favorite == true) {
Icon(
painterResource(MR.images.ic_star_filled),
contentDescription = generalGetString(MR.strings.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(17.dp)
)
if (chat.chatInfo.incognito) {
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
}
}
if (chat.chatInfo.incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
}
@@ -0,0 +1,160 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.ToggleFilterButton
import chat.simplex.common.views.home.contactChats
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
private fun ContactsSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>) {
Row(verticalAlignment = Alignment.CenterVertically) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_verb),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
}
}
}
private var lazyListState = 0 to 0
@Composable
fun ContactsList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allContactChats = remember(chatModel.chats.toList()) { contactChats(chatModel.chats) }
// See "IndexOutOfBoundsException" comment in ChatListView
val filteredContactChats = filteredContactChats(showUnreadAndFavorites, searchText.value.text, allContactChats.toList())
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
stickyHeader {
Column(
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background)
) {
ContactsSearchBar(listState, searchText)
Divider()
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) { derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ContactListNavLinkView(chat, nextChatSelected)
}
}
if (filteredContactChats.isEmpty() && allContactChats.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_contacts), color = MaterialTheme.colors.secondary)
}
}
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val s = searchText.trim().lowercase()
return (
if (s.isEmpty() && !showUnreadAndFavorites)
contactChats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser
else -> false
}
}
else {
contactChats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value ||
(cInfo.chatSettings?.favorite ?: false)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
else -> false
}
}
}
).sortedBy { it.chatInfo.displayName.lowercase() }
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -15,7 +15,7 @@ import chat.simplex.res.MR
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
@@ -125,5 +125,6 @@ private fun TopAppBar(
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeOverrides
import chat.simplex.common.views.chatlist.connectIfOpenedViaUri
import chat.simplex.common.views.home.connectIfOpenedViaUri
import chat.simplex.res.MR
import com.charleskorn.kaml.decodeFromStream
import dev.icerock.moko.resources.StringResource
@@ -0,0 +1,372 @@
package chat.simplex.common.views.home
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.contacts.ContactsList
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import java.net.URI
sealed class HomeTab {
class Chats: HomeTab()
class Contacts: HomeTab()
}
@Composable
fun HomeView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val homeTab = remember { mutableStateOf<HomeTab>(HomeTab.Chats()) }
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
VideoPlayerHolder.stopAll()
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(
topBar = { Box(Modifier.padding(end = endPadding)) { HomeTopBar(homeTab, stopped) } },
bottomBar = { Box(Modifier.padding(end = endPadding)) { HomeBottomBar(scaffoldState.drawerState, userPickerState, homeTab, stopped) } },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
when (homeTab.value) {
is HomeTab.Chats -> ChatsView(searchText)
is HomeTab.Contacts -> ContactsView(searchText)
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
}
}
@Composable
private fun ChatsView(searchText: MutableState<TextFieldValue>) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats),
Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
}
@Composable
private fun ContactsView(searchText: MutableState<TextFieldValue>) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ContactsList(chatModel, searchText = searchText)
}
if (remember(chatModel.chats.toList()) { contactChats(chatModel.chats) }.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.no_contacts),
Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
}
fun contactChats(c: List<Chat>): List<Chat> {
return c.filter { it.chatInfo is ChatInfo.Direct }
}
@Composable
private fun HomeTopBar(homeTab: MutableState<HomeTab>, stopped: Boolean) {
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
val title = when (homeTab.value) {
is HomeTab.Chats -> stringResource(MR.strings.your_chats)
is HomeTab.Contacts -> stringResource(MR.strings.contacts)
}
DefaultTopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
title,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
},
onTitleClick = null,
showSearch = false,
onSearchValueChanged = {},
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
private fun HomeBottomBar(
drawerState: DrawerState,
userPickerState: MutableStateFlow<AnimatedViewState>,
homeTab: MutableState<HomeTab>,
stopped: Boolean) {
Box(
Modifier
.fillMaxWidth()
.height(BottomAppBarHeight)
.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
) {
Divider()
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
SettingsButton(drawerState, userPickerState, stopped)
HomeTabButton(
icon = painterResource(if (homeTab.value is HomeTab.Chats) MR.images.ic_chat_bubble_filled else MR.images.ic_chat_bubble),
title = generalGetString(MR.strings.your_chats),
onClick = { homeTab.value = HomeTab.Chats() }
)
HomeTabButton(
icon = if (homeTab.value is HomeTab.Contacts) rememberVectorPainter(AccountCircleFilled) else painterResource(MR.images.ic_account_circle_filled),
title = generalGetString(MR.strings.contacts),
onClick = { homeTab.value = HomeTab.Contacts() }
)
}
}
}
@Composable
fun SettingsButton(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
val scope = rememberCoroutineScope()
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 56.dp,
color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
text ?: "",
color = MaterialTheme.colors.onPrimary,
fontSize = 6.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.align(Alignment.TopEnd)
)
}
@Composable
fun HomeTabButton(icon: Painter, title: String, onClick: () -> Unit) {
Surface(
Modifier
.size(56.dp),
shape = RoundedCornerShape(10.dp),
color = Color.Transparent,
) {
Column(
Modifier
.clickable { onClick () },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp),
tint = MaterialTheme.colors.secondary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal, fontSize = 12.sp),
color = MaterialTheme.colors.secondary
)
}
}
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri
} else {
withBGApi {
planAndConnect(rhId, uri, incognito = null, close = null)
}
}
}
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
@@ -44,6 +44,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
@@ -55,15 +60,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_group)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
) {
@@ -102,7 +109,7 @@ private fun NewChatSheetLayout(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, createGroup) }
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
@@ -145,7 +152,7 @@ private fun NewChatSheetLayout(
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + BottomAppBarHeight),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -264,6 +271,7 @@ private fun PreviewNewChatSheet() {
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
@@ -336,6 +336,7 @@
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Chats</string>
<string name="contacts">Contacts</string>
<string name="contact_connection_pending">connecting…</string>
<string name="member_contact_send_direct_message">send direct message</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
@@ -346,6 +347,8 @@
<string name="you_have_no_chats">You have no chats</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_contacts">No contacts</string>
<string name="no_filtered_contacts">No filtered contacts</string>
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
@@ -427,10 +430,28 @@
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="info_view_connect_button">connect</string>
<string name="info_view_open_button">open</string>
<string name="info_view_message_button">message</string>
<string name="info_view_call_button">call</string>
<string name="info_view_video_button">video</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_cannot_undo_warning">Contact will be deleted - this cannot be undone!</string>
<string name="delete_conversation_question">Delete conversation?</string>
<string name="delete_conversation_all_messages_deleted_cannot_undo_warning">Conversation and all messages will be deleted - this cannot be undone!</string>
<string name="delete_conversation">Delete conversation</string>
<string name="only_delete_conversation">Only delete conversation</string>
<string name="delete_contact_keep_conversation">Delete contact, keep conversation</string>
<string name="notify_delete_contact_question">Notify contact?</string>
<string name="confirm_delete_contact_question">Confirm contact deletion?</string>
<string name="delete_and_notify_contact">Delete and notify contact</string>
<string name="delete_without_notification">Delete without notification</string>
<string name="button_delete_contact">Delete contact</string>
<string name="conversation_deleted">Conversation deleted!</string>
<string name="you_can_still_send_messages_to_contact">You can still send messages to %1$s from the Contacts tab.</string>
<string name="contact_deleted">Contact deleted!</string>
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the Chats tab.</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -617,6 +638,7 @@
<!-- NewChatView.kt -->
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M241.78-244.5 134-136.5q-13.5 13.5-31.25 6.52Q85-136.97 85-156.5V-818q0-22.97 17.27-40.23 17.26-17.27 40.23-17.27h675q22.97 0 40.23 17.27Q875-840.97 875-818v516q0 22.97-17.27 40.23-17.26 17.27-40.23 17.27H241.78Z"/></svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -1,74 +0,0 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}
}
@@ -0,0 +1,76 @@
package chat.simplex.common.views.home
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.NoIndication
import chat.simplex.common.views.chatlist.openChat
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 8a3b72458f917e9867f4e3640dda0fa1827ff6cf
tag: 6a54a58a0d47aeca9d5482b0d27e451731b692f1
source-repository-package
type: git
+25 -30
View File
@@ -15,29 +15,32 @@ We want to add up to 3 people to the team.
## Who we are looking for
### Product/UI designer
You will be designing the user experience and the interface of both the app and the website in collaboration with the team.
The current focus of the app is privacy and security, but we hope to have the design that would support the feeling of psychological safety, enabling people to achieve the results in the smallest amount of time.
You are an experienced and innovative product designer with:
- 8+ years of user experience and visual design.
- Expertise in typography and high sensitivity to colors.
- Exceptional precision and attention to details.
- Strong opinions (weakly held).
- A strong empathy.
### Application Haskell engineer
You will work with the Haskell core of the client applications and with the network servers.
You are an expert in language models, databases and Haskell:
- expert knowledge of SQL.
- exception handling, concurrency, STM.
- type systems - we use ad hoc dependent types a lot.
- experience integrating open-source language models.
- experience developing community-centric applications.
- interested to build the next generation of messaging network.
You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell.
### iOS / Mac engineer
You are an expert in Apple platforms, including:
- iOS and Mac platform architecture.
- Swift and Objective-C.
- SwiftUI and UIKit.
- extensions, including notification service extension and sharing extension.
- low level inter-process communication primitives for concurrency.
- interested about creating the next generation of UX for a communication/social network.
Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps.
- Haskell exception handling, concurrency, STM, type systems.
- 8y+ of software engineering experience in complex projects,
- deep understanding of the common programming principles:
- data structures, bits and bytes, text encoding.
- software design and algorithms.
- concurrency.
- networking.
## About you
@@ -53,20 +56,10 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J
- focus on solving only today's problems and resist engineering for the future (aka over-engineering) see [The Duct Tape Programmer](https://www.joelonsoftware.com/2009/09/23/the-duct-tape-programmer/) and [Why I Hate Frameworks](https://medium.com/@johnfliu/why-i-hate-frameworks-6af8cbadba42).
- do not suffer from "not invented here" syndrome, at the same time interested to design and implement protocols and systems from the ground up when appropriate.
- **Love software engineering**:
- have 5y+ of software engineering experience in complex projects,
- great understanding of the common principles:
- data structures, bits and byte manipulation
- text encoding and manipulation
- software design and algorithms
- concurrency
- networking
- **Want to join a very early stage startup**:
- high pace and intensity, longer hours.
- a substantial part of the compensation is stock options.
- full transparency we believe that too much [autonomy](https://twitter.com/KentBeck/status/851459129830850561) hurts learning and slows down progress.
- full transparency - we believe that too much [autonomy](https://twitter.com/KentBeck/status/851459129830850561) hurts learning and slows down progress.
## How to join the team
@@ -75,3 +68,5 @@ Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin J
2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test.
3. [Connect to us](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FKBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) via SimpleX Chat to chat about what you want to contribute and about joining the team.
4. You can also email [jobs@simplex.chat](mailto:jobs@simplex.chat?subject=Join%20SimpleX%20Chat%20team)
+1 -1
View File
@@ -75,7 +75,7 @@ Manual installation requires some preliminary actions:
Group=xftp
Type=simple
ExecStart=/usr/local/bin/xftp-server start +RTS -N -RTS
ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex-xftp/file-server-store.log" ] && cp "/var/opt/simplex-xftp/file-server-store.log" "/var/opt/simplex-xftp/file-server-store.log.$(date +'%FT%T')"'
ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex-xftp/file-server-store.log" ] && cp "/var/opt/simplex-xftp/file-server-store.log" "/var/opt/simplex-xftp/file-server-store.log.$(date +'%%FT%%T')"'
LimitNOFILE=65535
KillSignal=SIGINT
TimeoutStopSec=infinity
+22 -4
View File
@@ -4,9 +4,16 @@ sequenceDiagram
participant B as Bob
participant C as Existing<br>contact
note over A, B: 1. send and accept group invitation
A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection)
B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection
alt invite contact
note over A, B: 1a. send and accept group invitation
A ->> B: x.grp.inv<br>invite Bob to group<br>(via contact connection)
B ->> A: x.grp.acpt<br>accept invitation<br>(via member connection)<br>establish group member connection
else join via group link
note over A, B: 1b. join via group link and accept request
B ->> A: join via group link<br>SimpleX contact address
A ->> B: x.grp.link.inv in SMP confirmation<br>accept joining member request,<br>sending group profile, etc.<br>establish group member connection
A ->> B: x.grp.link.mem<br>send inviting member profile
end
note over M, B: 2. introduce new member Bob to all existing members
A ->> M: x.grp.mem.new<br>"announce" Bob<br>to existing members<br>(via member connections)
@@ -20,14 +27,25 @@ sequenceDiagram
end
A ->> M: x.grp.mem.fwd<br>forward "invitations" and<br>Bob's chat protocol version<br>to all members<br>(via member connections)
note over M, B: group message forwarding<br>(while connections between members are being established)
M -->> B: messages between members and Bob are forwarded by Alice
B -->> M:
note over M, B: 3. establish direct and group member connections
M ->> B: establish group member connection
opt chat protocol compatible version < 2
M ->> B: establish direct connection
note over M, C: 4. deduplicate new contact
note over M, C: 3*. deduplicate new contact
B ->> M: x.info.probe<br>"probe" is sent to all new members
B ->> C: x.info.probe.check<br>"probe" hash,<br>in case contact and<br>member profiles match
C ->> B: x.info.probe.ok<br> original "probe",<br> in case contact and member<br>are the same user
note over B: merge existing and new contacts if received and sent probe hashes match
end
note over M, B: 4. notify inviting member that connection is established
M ->> A: x.grp.mem.con
B ->> A: x.grp.mem.con
note over A: stops forwarding messages
M -->> B: messages are sent via group connection without forwarding
B -->> M:
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+70 -7
View File
@@ -2,7 +2,7 @@
title: SimpleX Chat Protocol
revision: 08.08.2022
---
DRAFT Revision 0.1, 2022-08-08
Revision 2, 2024-06-24
Evgeny Poberezkin
@@ -157,7 +157,7 @@ This message is sent by both sides of the connection during the connection hands
### Probing for duplicate contacts
As there are no globally unique user identitifiers, when the contact a user is already connected to is added to the group by some other group member, this contact will be added to user's list of contacts as a new contact. To allow merging such contacts, "a probe" (random base64url-encoded 32 bytes) SHOULD be sent to all new members as part of `x.info.probe` message and, in case there is a contact with the same profile, the hash of the probe MAY be sent to it as part of `x.info.probe.check` message. In case both the new member and the existing contact are the same user (they would receive both the probe and its hash), the contact would send back the original probe as part of `x.info.probe.ok` message via the previously existing contact connection proving to the sender that this new member and the existing contact are the same user, in which case the sender SHOULD merge these two contacts.
As there are no globally unique user identifiers, when the contact a user is already connected to is added to the group by some other group member, this contact will be added to user's list of contacts as a new contact. To allow merging such contacts, "a probe" (random base64url-encoded 32 bytes) SHOULD be sent to all new members as part of `x.info.probe` message and, in case there is a contact with the same profile, the hash of the probe MAY be sent to it as part of `x.info.probe.check` message. In case both the new member and the existing contact are the same user (they would receive both the probe and its hash), the contact would send back the original probe as part of `x.info.probe.ok` message via the previously existing contact connection proving to the sender that this new member and the existing contact are the same user, in which case the sender SHOULD merge these two contacts.
Sending clients MAY disable this functionality, and receiving clients MAY ignore probe messages.
@@ -210,23 +210,23 @@ File attachment can optionally include connection address to receive the file -
### Decentralized design for chat groups
SimpleX Chat groups are fully decentralized and do not have any globally unique group identifiers - they are only defined on client devices as a group profile and a set of bi-directional SimpleX connections with other group members. When a new member accepts group invitation, the inviting member introduces a new member to all existing members and forwards the connection addresses so that they can establish direct and group member connections.
SimpleX Chat groups are fully decentralized and do not have any globally unique group identifiers - they are only defined on client devices as a group profile and a set of bi-directional SimpleX connections with other group members. When a new member accepts group invitation or joins via group link, the inviting member introduces a new member to all existing members and forwards the connection addresses so that they can establish direct and group member connections.
There is a possibility of the attack here: as the introducing member forwards the addresses, they can substitute them with other addresses, performing MITM attack on the communication between existing and introduced members - this is similar to the communication operator being able to perform MITM on any connection between the users. To mitigate this attack this group sub-protocol will be extended to allow validating security of the connection by sending connection verification out-of-band.
Clients are RECOMMENDED to indicate in the UI whether the connection to a group member or contact was made directly or via annother user.
Clients are RECOMMENDED to indicate in the UI whether the connection to a group member or contact was made directly or via another user.
Each member in the group is identified by a group-wide unique identifier used by all members in the group. This is to allow referencing members in the messages and to allow group message integrity validation.
The diagram below shows the sequence of messages sent between the users' clients to add the new member to the group.
![Adding member to the group](./diagrams/group.svg)
While introduced members establish connection inside group, inviting member forwards messages between them by sending `x.grp.msg.forward` messages. When introduced members finalize connection, they notify inviting member to stop forwarding via `x.grp.mem.con` message.
![Adding member to the group](./diagrams/group.svg)
### Member roles
Currently members can have one of three roles - `owner`, `admin` and `member`. The user that created the group is self-assigned owner role, the new members are assigned role by the member who adds them - only `owner` and `admin` members can add new members; only `owner` members can add members with `owner` role.
Currently members can have one of three roles - `owner`, `admin`, `member` and `observer`. The user that created the group is self-assigned owner role, the new members are assigned role by the member who adds them - only `owner` and `admin` members can add new members; only `owner` members can add members with `owner` role. `Observer` members only receive messages and aren't allowed to send messages.
### Messages to manage groups and add members
@@ -279,3 +279,66 @@ These message are used for WebRTC calls:
3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message.
4. `x.call.end` message is sent to notify the other party that the call is terminated.
## Threat model
This threat model compliments SMP, XFTP, push notifications and XRCP protocols threat models:
- [SimpleX Messaging Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#threat-model);
- [SimpleX File Transfer Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xftp.md#threat-model);
- [Push notifications threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/push-notifications.md#threat-model);
- [SimpleX Remote Control Protocol threat model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/xrcp.md#threat-model).
#### A user's contact
*can:*
- send messages prohibited by user's preferences or otherwise act non-compliantly with user's preferences (for example, if message with updated preferences was lost or failed to be processed, or with modified client), in which case user client should treat such messages and actions as prohibited.
- by exchanging special messages with user's client, match user's contact with existing group members and/or contacts that have identical user profile (see [Probing for duplicate contacts](#probing-for-duplicate-contacts)).
- identify that and when a user is using SimpleX, in case user has delivery receipts enabled, or based on other automated client responses.
*cannot:*
- match user's contact with existing group members and/or contacts with different or with incognito profiles.
- match user's contact without communicating with the user's client.
#### A group member
*can:*
- send messages prohibited by group's preferences and member restrictions or otherwise act non-compliantly with preferences and restrictions (for example, if decentralized group state diverged, or with modified client), in which case user client should treat such messages and actions as prohibited.
- create a direct contact with a user if group permissions allow it.
- by exchanging special messages with user's client, match user's group member record with the existing group members and/or contacts that have identical user profile.
- undetectably send different messages to different group members, or selectively send messages to some members and not send to others.
- identify that and when a user is using SimpleX, in case user has delivery receipts enabled, or based on other automated client responses.
- join the same group several times, from the same or from different user profile, and pretend to be different members.
*cannot:*
- match user's contact with existing group members and/or contacts with different or with incognito profiles.
- match user's group member record with existing group members and/or contacts without communication of user's client.
- determine whether two group members with different or with incognito profiles are the same user.
#### A group admin
*can:*
- carry out MITM attack between user and other group member(s) when forwarding invitations for group connections (user can detect such attack by verifying connection security codes out-of-band).
- undetectably forward different messages to different group members, selectively adding, modifying, and dropping forwarded messages.
- disrupt decentralized group state by sending different messages that change group state (such as adding or removing members, member role changes, etc.) to different group members, or sending such messages selectively.
*cannot:*
- prove that two group members with incognito profiles is the same user.
+269 -12
View File
@@ -8,7 +8,7 @@
"displayName": {
"type": "string",
"metadata": {
"format": "non-empty string without spaces, the first character must not be # or @"
"format": "non-empty string, the first character must not be # or @"
}
},
"fullName": {"type": "string"}
@@ -19,6 +19,39 @@
"metadata": {
"format": "data URI format for base64 encoded image"
}
},
"contactLink": {"ref": "connReqUri"},
"preferences": {
"type": "string",
"metadata": {
"format": "JSON encoded user preferences"
}
}
},
"additionalProperties": true
},
"groupProfile": {
"properties": {
"displayName": {
"type": "string",
"metadata": {
"format": "non-empty string, the first character must not be # or @"
}
},
"fullName": {"type": "string"}
},
"optionalProperties": {
"image": {
"type": "string",
"metadata": {
"format": "data URI format for base64 encoded image"
}
},
"groupPreferences": {
"type": "string",
"metadata": {
"format": "JSON encoded user preferences"
}
}
},
"additionalProperties": true
@@ -29,6 +62,8 @@
},
"optionalProperties": {
"file": {"ref": "fileInvitation"},
"ttl": {"type": "integer"},
"live": {"type": "boolean"},
"quote": {
"properties": {
"msgRef": {"ref": "msgRef"},
@@ -56,17 +91,47 @@
}
},
"image": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
"image": {"ref": "base64url"}
"properties": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
"image": {"ref": "base64url"}
}
},
"video": {
"properties": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
"image": {"ref": "base64url"},
"duration": {"type": "integer"}
}
},
"voice": {
"properties": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}},
"duration": {"type": "integer"}
}
},
"file": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}}
"properties": {
"text": {"type": "string", "metadata": {"comment": "can be empty"}}
}
}
},
"metadata": {
"comment": "it is RECOMMENDED that the clients support other values in `type` properties showing them as text messages in case `text` property is present"
}
},
"msgReaction" : {
"discriminator": "type",
"mapping": {
"emoji": {
"properties": {
"emoji": {
"type": "string",
"metadata": {"comment": "emoji character"}
}
}
}
}
},
"msgRef": {
"properties": {
"msgId": {"ref": "base64url"},
@@ -91,7 +156,31 @@
"fileSize": {"type": "uint32"}
},
"optionalProperties": {
"fileConnReq": {"ref": "connReqUri"}
"fileDigest": {"ref": "base64url"},
"fileConnReq": {"ref": "connReqUri"},
"fileDescr": {"ref": "fileDescription"}
}
},
"fileDescription": {
"properties": {
"fileDescrText": {
"type": "string",
"metadata": {
"format": "XFTP file description part text"
}
},
"fileDescrPartNo": {
"type": "integer",
"metadata": {
"format": "XFTP file description part number"
}
},
"fileDescrComplete": {
"type": "boolean",
"metadata": {
"format": "XFTP file description completion marker"
}
}
}
},
"linkPreview": {
@@ -100,6 +189,21 @@
"title": {"type": "string"},
"description": {"type": "string"},
"image": {"ref": "base64url"}
},
"optionalProperties": {
"content": {"ref": "linkContent"}
}
},
"linkContent": {
"discriminator": "type",
"mapping": {
"page": {},
"image": {},
"video": {
"optionalProperties": {
"duration": {"type": "integer"}
}
}
}
},
"groupInvitation": {
@@ -107,15 +211,27 @@
"fromMember": {"ref": "memberIdRole"},
"invitedMember": {"ref": "memberIdRole"},
"connRequest": {"ref": "connReqUri"},
"groupProfile": {"ref": "profile"}
"groupProfile": {"ref": "groupProfile"}
},
"optionalProperties": {
"groupLinkId": {"ref": "base64url"},
"groupSize": {"type": "integer"},
"metadata": {
"comment": "used to identify invitation via group link"
"comment": "groupLinkId is used to identify invitation via group link"
}
}
},
"groupLinkInvitation": {
"properties": {
"fromMember": {"ref": "memberIdRole"},
"fromMemberName": {"type": "string"},
"invitedMember": {"ref": "memberIdRole"},
"groupProfile": {"ref": "groupProfile"}
},
"optionalProperties": {
"groupSize": {"type": "integer"}
}
},
"memberIdRole": {
"properties": {
"memberId": {"ref": "base64url"},
@@ -127,16 +243,35 @@
"memberId": {"ref": "base64url"},
"memberRole": {"ref": "groupMemberRole"},
"profile": {"ref": "profile"}
},
"optionalProperties": {
"v": {"ref": "chatVersionRange"}
}
},
"memberRestrictions": {
"properties": {
"restriction": {"ref": "memberRestrictionStatus"}
}
},
"memberRestrictionStatus": {
"enum": ["blocked", "unrestricted"]
},
"chatVersionRange": {
"type": "string",
"metadata": {
"format": "chat version range string encoded as `<min>-<max>`, or as `<number>` if min = max"
}
},
"introInvitation": {
"properties": {
"groupConnReq": {"ref": "connReqUri"},
"groupConnReq": {"ref": "connReqUri"}
},
"optionalProperties": {
"directConnReq": {"ref": "connReqUri"}
}
},
"groupMemberRole": {
"enum": ["author", "member", "admin", "owner"]
"enum": ["observer", "author", "member", "admin", "owner"]
},
"callInvitation": {
"properties": {
@@ -257,6 +392,17 @@
"params": {"ref": "msgContainer"}
}
},
"x.msg.file.descr": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"msgId": {"ref": "base64url"},
"fileDescr": {"ref": "fileDescription"}
}
}
}
},
"x.msg.update": {
"properties": {
"msgId": {"ref": "base64url"},
@@ -264,6 +410,10 @@
"properties": {
"msgId": {"ref": "base64url"},
"content": {"ref": "msgContent"}
},
"optionalProperties": {
"ttl": {"type": "integer"},
"live": {"type": "boolean"}
}
}
}
@@ -274,6 +424,24 @@
"params": {
"properties": {
"msgId": {"ref": "base64url"}
},
"optionalProperties": {
"memberId": {"ref": "base64url"}
}
}
}
},
"x.msg.react": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"msgId": {"ref": "base64url"},
"reaction": {"ref": "msgReaction"},
"add": {"type": "boolean"}
},
"optionalProperties": {
"memberId": {"ref": "base64url"}
}
}
}
@@ -294,8 +462,10 @@
"params": {
"properties": {
"msgId": {"ref": "base64url"},
"fileConnReq": {"ref": "connReqUri"},
"fileName": {"type": "string"}
},
"optionalProperties": {
"fileConnReq": {"ref": "connReqUri"}
}
}
}
@@ -310,6 +480,14 @@
}
}
},
"x.direct.del": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {}
}
}
},
"x.grp.inv": {
"properties": {
"msgId": {"ref": "base64url"},
@@ -330,6 +508,26 @@
}
}
},
"x.grp.link.inv": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"groupLinkInvitation": {"ref": "groupLinkInvitation"}
}
}
}
},
"x.grp.link.mem": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"profile": {"ref": "profile"}
}
}
}
},
"x.grp.mem.new": {
"properties": {
"msgId": {"ref": "base64url"},
@@ -346,6 +544,9 @@
"params": {
"properties": {
"memberInfo": {"ref": "memberInfo"}
},
"optionalProperties": {
"memberRestrictions": {"ref": "memberRestrictions"}
}
}
}
@@ -394,6 +595,27 @@
}
}
},
"x.grp.mem.restrict": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"memberId": {"ref": "base64url"},
"memberRestrictions": {"ref": "memberRestrictions"}
}
}
}
},
"x.grp.mem.con": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"memberId": {"ref": "base64url"}
}
}
}
},
"x.grp.mem.del": {
"properties": {
"msgId": {"ref": "base64url"},
@@ -425,7 +647,42 @@
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"groupProfile": {"ref": "profile"}
"groupProfile": {"ref": "groupProfile"}
}
}
}
},
"x.grp.direct.inv": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"connReq": {"ref": "connReqUri"}
},
"optionalProperties": {
"content": {"ref": "msgContent"}
}
}
}
},
"x.grp.msg.forward": {
"properties": {
"msgId": {"ref": "base64url"},
"params": {
"properties": {
"memberId": {"ref": "base64url"},
"msg": {
"type": "string",
"metadata": {
"format": "JSON encoded chat message"
}
},
"msgTs": {
"type": "string",
"metadata": {
"format": "ISO8601 UTC time of the message"
}
}
}
}
}
@@ -436,7 +693,7 @@
"params": {
"properties": {
"callId": {"ref": "base64url"},
"invitation": {}
"invitation": {"ref": "callInvitation"}
}
}
}
@@ -1,43 +0,0 @@
# Optimized subscription
## Problem
The `subscribeUserConnections` function has a few problems that affect UX on app start:
1. It loads entity data that isn't used until result processing. This produces a memory spike and takes CPU time to parse all the data.
2. Subscription results are processed synchronously after the agent finishes all the batches for user. The app wouldn't see connections as active until the slowest server responds or timeouts.
3. User subscriptions are processed sequentially. A currently active user is given a first round of subs, but the remaining are blocked. If a user profile is switched right away, the new user may start receiving updates with even more lag.
## Solution
Functions that fetch connections and entities are reduced to return only connection IDs. The filters should be moved from Haskell into specialized SQL queries that only return `[ConnId]`.
With the connection list on hands the agent subscriber thread forks off to do its thing and process batch results. This allows outer loop to start collecting connections for the remaining users.
Successful results are communicated with a new `UP srv conns` message emitted from agent when a batch finishes its processing. The `conns` payload would be a list of connections that actually have just switched subs from "pending" to "active". The UP handling machinery processes status updates just as it would in a server reconnect event. This would update connection state in chat apps as soon as the server responds, keeping app and core in tighter sync.
`reconnectSMPClient` should stop sending UPs to prevent double processing of the same result. The `okConns` membership test it currently uses is the same "did not belong to an active connection" that the batch result would use.
Sending results with UP allows to reduce summary responses to a bunch of counters so no entity data would be needed for CLI here:
```haskell
| CRContactSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRUserGroupLinksSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRMemberSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRPendingSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
```
Subscription errors are reported to API as `CRNetworkStatuses` as ususal, but the active subs are removed from the list as they are already handled by `UP`.
Subscription errors for CLI (when connection error reporting is enabled) are reported with the types reduced to a textual name:
```haskell
| CRContactSubError {user :: User, contactName :: ContactName, chatError :: ChatError}
| CRMemberSubError {user :: User, groupName :: GroupName, contactName :: ContactName, chatError :: ChatError}
| CRSndFileSubError {user :: User, sndFileTransfer :: Text, chatError :: ChatError}
| CRRcvFileSubError {user :: User, rcvFileTransfer :: Text, chatError :: ChatError}
```
> A generic constructor could be used instead, but then it would contain extra fields to form a message in View if matching the current output is desired.
The textual names for connections can be requested for the subset of all connections that needs them with the same procedure that's used in `UP` handling.
@@ -0,0 +1,87 @@
# Agent stats persistence
## Problem
State/state tracked in agent are lost on app restart, which makes it difficult to debug user bugs.
## Solution
Persist stats between sessions.
App terminal signals may vary per platform / be absent (?) -> persist stats periodically.
Stats would have `<userId, server>` key, so we don't want to store them in a plaintext file to not leak used servers locally -> persist in encrypted db.
There's couple of orthogonal design decision to be made:
- persist in chat or in agent db
- pros for chat:
- possibly less contention for db than agent
- pros for agent:
- no unnecessary back and forth, especially if agent starts accumulating from past sessions and has to be parameterized with past stats (see below)
- agent to start accumulating from past sessions stats, or keep past separately and only accumulate for current session from zeros
- pros for accumulating from past sessions:
- easier to maintain stats - e.g. user deletion has to remove keys, which is more convoluted if past stats are not stored in memory
- simpler UI - overall stats, no differentiation for past/current session (or less logic in backend preparing presentation data)
- pros for accumulating from zeros:
- simpler start logic - no need to restore stats from agent db / pass initial stats from chat db
- can differentiate between past sessions and current session stats in UI
### Option 1 - Persist in chat db, agent to track only current session
- Chat stores stats in such table:
```sql
CREATE TABLE agent_stats(
agent_stats_id INTEGER PRIMARY KEY, -- dummy id, there will only be one record
past_stats TEXT, -- accumulated from previous sessions
session_stats TEXT, -- current session
past_started_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of tracking stats, reset on stats reset
session_started_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of current session
session_updated_at TEXT NOT NULL DEFAULT(datetime('now')) -- last update of current session stats (periodic, frequent updates)
);
```
- Chat periodically calls getAgentServersStats api and updates `session_stats`.
- interval? should be short to not lose too much data, 5-30 seconds?
- On start `session_stats` are accumulated into `past_stats` and set to null.
- On user deletion, agent updates current session stats in memory (removes keys), chat has to do same for both stats fields in db.
- other cases where stats have to be manipulated in similar way?
### Option 2 - Persist in chat db, agent to accumulate stats from past sessions
- Table is only used for persistence of overall stats:
```sql
CREATE TABLE agent_stats(
agent_stats_id INTEGER PRIMARY KEY, -- dummy id, there will only be one record
agent_stats TEXT, -- overall stats - past and session
started_tracking_at TEXT NOT NULL DEFAULT(datetime('now')), -- starting point of tracking stats, reset on stats reset
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
```
- Chat to parameterize creation of agent client with initial stats.
### Option 3 - Persist in agent db, agent to differentiate past stats and session stats
- Table in agent db similar to option 1.
- Agent is responsible for periodic updates in session, as well as accumulating into "past" and resetting session stats on start.
- Agent only communicates stats to chat on request.
- On user deletion agent is fully responsible for maintaining both in-memory session stats, and updating db records.
### Option 4 - Persist in agent db, agent to accumulate stats from past sessions
- Table in agent db similar to option 2.
- On start agent restores initial stats into memory by itself.
- Since all stats are in memory, on user deletion it's enough to update in memory without updating db.
- there is a race possible where agent crashes after updating stats (removing user keys) in memory before database stats have been overwritten by a periodic update, so it may be better to immediately overwrite and not wait for periodic update.
- still at least there's at least no additional logic to update past stats.
### Other considerations
Why is it important to timely remove user keys from past stats?
- stats not being saved for past users:
- important both privacy-wise and to not cause confusion when showing "All" stats (e.g. user summing up across users stats would have smaller total than total stats).
- to avoid accidentally mixing up with newer users.
- though we do have an AUTOINCREMENT user_id in agent so probably it wouldn't be a problem.
- on the other hand maybe we don't want to "forget" stats on user deletion so that stats would reflect networking more accurately?
@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Terminal=false
Name=SimpleX Chat
Comment=A private & encrypted open-source messenger without any user IDs (not even random ones)!
Keywords=chat;message;private;secure;simplex;
Categories=Utility;Chat;InstantMessaging;
Exec=simplex %U
Icon=chat.simplex.simplex
StartupWMClass=simplex-chat
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>chat.simplex.simplex</id>
<name>SimpleX Chat</name>
<summary>A private &amp; encrypted open-source messenger without any user IDs (not even random)!</summary>
<developer id="chat.simplex">
<name>SimpleX Chat</name>
</developer>
<metadata_license>MIT</metadata_license>
<project_license>AGPL-3.0-or-later</project_license>
<description>
<p>SimpleX - the first messaging platform that has no user identifiers, not even random numbers!</p>
<p>Security assessment was done by Trail of Bits in November 2022.</p>
<p>SimpleX Chat features:</p>
<ol>
<li>end-to-end encrypted messages, with editing, replies and deletion of messages.</li>
<li>sending end-to-end encrypted images and files.</li>
<li>single-use and long-term user addresses.</li>
<li>secret chat groups - only group members know it exists and who is the member.</li>
<li>end-to-end encrypted audio and video calls.</li>
<li>private instant notifications.</li>
<li>portable chat profile - you can transfer your chat contacts and history to another device (terminal or mobile).</li>
</ol>
<p>SimpleX Chat advantages:</p>
<ol>
<li><em>Full privacy of your identity, profile, contacts and metadata:</em> unlike any other existing messaging platform, SimpleX uses no phone numbers or any other identifiers assigned to the users - not even random numbers. This protects the privacy of who you are communicating with, hiding it from SimpleX platform servers and from any observers.</li>
<li><em>Complete protection against spam and abuse:</em> as you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address.</li>
<li><em>Full ownership, control and security of your data:</em> SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received.</li>
<li><em>Decentralized network:</em> you can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers.</li>
</ol>
<p>You can connect to anybody you know via link or scan QR code (in the video call or in person) and start sending messages instantly - no emails, phone numbers or passwords needed.</p>
<p>Your profile and contacts are only stored in the app on your device - our servers do not have access to this information.</p>
<p>All messages are end-to-end encrypted using open-source double-ratchet protocol; the messages are routed via our servers using open-source SimpleX Messaging Protocol.</p>
</description>
<releases>
<release version="5.8.1" date="2024-06-19">
<description>
<p>General:</p>
<ol>
<li>fixes in sending/receiving files.</li>
<li>better error reporting when connecting to desktop app.</li>
<li>prevent forwarding to conversations where conversation preferences do not allow message.</li>
</ol>
<p>Android and desktop apps:</p>
<ol>
<li>support transparent theme colors for chat message bubbles.</li>
<li>do not reset changed network settings when switching SOCKS proxy on/off</li>
<li>fix swipe to reply when animation is disabled.</li>
<li>fix bug when duplicate group shown in the UI.</li>
</ol>
</description>
</release>
</releases>
<content_rating type="oars-1.1" />
<url type="homepage">https://simplex.chat/</url>
<url type="bugtracker">https://github.com/simplex-chat/simplex-chat/issues</url>
<url type="donation">https://opencollective.com/simplex-chat</url>
<url type="translate">https://simplex.chat/docs/translations</url>
<url type="faq">https://simplex.chat/faq</url>
<url type="vcs-browser">https://github.com/simplex-chat/simplex-chat</url>
<launchable type="desktop-id">chat.simplex.simplex.desktop</launchable>
<branding>
<color type="primary" scheme_preference="light">#a5f0ff</color>
<color type="primary" scheme_preference="dark">#110e26</color>
</branding>
<screenshots>
<screenshot type="default">
<image>https://simplex.chat/blog/images/simplex-desktop-light.png</image>
</screenshot>
</screenshots>
</component>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."8a3b72458f917e9867f4e3640dda0fa1827ff6cf" = "1mmxdaj563kjmlkacxdnq62n6mzw9khampzaqghnk6iiwzdig0qy";
"https://github.com/simplex-chat/simplexmq.git"."6a54a58a0d47aeca9d5482b0d27e451731b692f1" = "1hfbzcirncmmzag2c3gbxnpxl1dwy9gy42d78a5zzf1979rp3yjq";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+1 -1
View File
@@ -146,7 +146,6 @@ library
Simplex.Chat.Migrations.M20240510_chat_items_via_proxy
Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
Simplex.Chat.Migrations.M20240528_quota_err_counter
Simplex.Chat.Migrations.M20240530_user_contact_links_user_id
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
@@ -161,6 +160,7 @@ library
Simplex.Chat.Remote.RevHTTP
Simplex.Chat.Remote.Transport
Simplex.Chat.Remote.Types
Simplex.Chat.Stats
Simplex.Chat.Store
Simplex.Chat.Store.AppSettings
Simplex.Chat.Store.Connections
+141 -110
View File
@@ -71,6 +71,7 @@ import Simplex.Chat.ProfileGenerator (generateRandomProfile)
import Simplex.Chat.Protocol
import Simplex.Chat.Remote
import Simplex.Chat.Remote.Types
import Simplex.Chat.Stats
import Simplex.Chat.Store
import Simplex.Chat.Store.AppSettings
import Simplex.Chat.Store.Connections
@@ -84,7 +85,6 @@ import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.Util
import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle)
import qualified Simplex.Chat.Util as U
import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard)
@@ -95,7 +95,7 @@ import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent as Agent
import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getNetworkConfig', ipAddressProtected, withLockMap)
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getNetworkConfig', ipAddressProtected, withLockMap)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock (withLock)
import Simplex.Messaging.Agent.Protocol
@@ -113,7 +113,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI, SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import qualified Simplex.Messaging.TMap as TM
@@ -369,7 +369,7 @@ activeAgentServers ChatConfig {defaultServers} p =
fromMaybe (cfgServers p defaultServers)
. nonEmpty
. map (\ServerCfg {server} -> server)
. filter (\ServerCfg {enabled} -> enabled)
. filter (\ServerCfg {enabled} -> enabled == SEEnabled)
cfgServers :: UserProtocol p => SProtocolType p -> (DefaultAgentServers -> NonEmpty (ProtoServerWithAuth p))
cfgServers p DefaultAgentServers {smp, xftp} = case p of
@@ -1315,7 +1315,7 @@ processChatCommand' vr = \case
servers' = fromMaybe (L.map toServerCfg defServers) $ nonEmpty servers
pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p servers' defServers
where
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True}
toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = SEEnabled}
GetUserProtoServers aProtocol -> withUser $ \User {userId} ->
processChatCommand $ APIGetUserProtoServers userId aProtocol
APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ do
@@ -2253,17 +2253,23 @@ processChatCommand' vr = \case
CLUserContact ucId -> "UserContact " <> show ucId
CLFile fId -> "File " <> show fId
DebugEvent event -> toView event >> ok_
GetAgentServersSummary userId -> withUserId userId $ \user -> do
agentServersSummary <- lift $ withAgent' getAgentServersSummary
users <- withStore' getUsers
smpServers <- getUserServers user SPSMP
xftpServers <- getUserServers user SPXFTP
let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers
pure $ CRAgentServersSummary user presentedServersSummary
where
getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => User -> SProtocolType p -> CM [ProtocolServer p]
getUserServers users protocol = do
ChatConfig {defaultServers} <- asks config
let defServers = cfgServers protocol defaultServers
servers <- map (\ServerCfg {server} -> server) <$> withStore' (`getProtocolServers` users)
let srvs = if null servers then L.toList defServers else servers
pure $ map protoServer srvs
GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary
GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails
GetAgentStats -> lift $ CRAgentStats . map stat <$> withAgent' getAgentStats
where
stat (AgentStatsKey {host, clientTs, cmd, res}, count) =
map B.unpack [host, clientTs, cmd, res, bshow count]
ResetAgentStats -> lift (withAgent' resetAgentStats) >> ok_
GetAgentMsgCounts -> lift $ do
counts <- map (first decodeLatin1) <$> withAgent' getMsgCounts
let allMsgs = foldl' (\(ts, ds) (_, (t, d)) -> (ts + t, ds + d)) (0, 0) counts
pure CRAgentMsgCounts {msgCounts = ("all", allMsgs) : sortOn (Down . snd) (filter (\(_, (_, d)) -> d /= 0) counts)}
GetAgentSubs -> lift $ summary <$> withAgent' getAgentSubscriptions
where
summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} =
@@ -3400,55 +3406,71 @@ subscribeUserConnections :: VersionRangeChat -> Bool -> AgentBatchSubscribe -> U
subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
-- get user connections
ce <- asks $ subscriptionEvents . config
(conns, ctConns, ucs, gs, mConns, sfts, rfts, pcConns) <-
(conns, cts, ucs, gs, ms, sfts, rfts, pcs) <-
if onlyNeeded
then do
(conns, entities) <- withStore' $ \db -> getConnectionsToSubscribe db vr user
let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity ([], [], [], M.empty, M.empty, []) entities
(conns, entities) <- withStore' (`getConnectionsToSubscribe` vr)
let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities
pure (conns, cts, ucs, [], ms, sfts, rfts, pcs)
else do
withStore' (`unsetConnectionToSubscribe` user)
ctConns <- getContactConns
withStore' unsetConnectionToSubscribe
(ctConns, cts) <- getContactConns
(ucConns, ucs) <- getUserContactLinkConns
(gs, mConns) <- getGroupMemberConns
(gs, mConns, ms) <- getGroupMemberConns
(sftConns, sfts) <- getSndFileTransferConns
(rftConns, rfts) <- getRcvFileTransferConns
pcConns <- getPendingContactConns
(pcConns, pcs) <- getPendingContactConns
let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns]
pure (conns, ctConns, ucs, gs, mConns, sfts, rfts, pcConns)
-- detach subscription and result processing
void . lift . forkIO . runSubscriber $ do
-- subscribe using batched commands
rs <- withAgent $ \a -> agentBatchSubscribe a conns
let (errs, _oks) = M.mapEither id rs
refs <- if ce then withStore' $ \db -> getConnectionsContacts db (M.keys errs) else pure []
let connRefs = M.fromList $ map (\ContactRef {agentConnId = AgentConnId acId, localDisplayName} -> (acId, localDisplayName)) refs
contactSubsToView errs ctConns connRefs ce
contactLinkSubsToView errs ucs
groupSubsToView errs gs mConns connRefs ce
sndFileSubsToView errs sfts
rcvFileSubsToView errs rfts
pendingConnSubsToView errs pcConns
pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs)
-- subscribe using batched commands
rs <- withAgent $ \a -> agentBatchSubscribe a conns
-- send connection events to view
contactSubsToView rs cts ce
-- TODO possibly, we could either disable these events or replace with less noisy for API
contactLinkSubsToView rs ucs
groupSubsToView rs gs ms ce
sndFileSubsToView rs sfts
rcvFileSubsToView rs rfts
pendingConnSubsToView rs pcs
where
runSubscriber :: CM () -> CM' ()
runSubscriber action = tryAllErrors' mkChatError action >>= either (logError . tshow) pure
addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case
RcvDirectMsgConnection c (Just _ct) -> let cts' = aConnId c : cts in (cts', ucs, ms, sfts, rfts, pcs)
RcvDirectMsgConnection c Nothing -> let pcs' = aConnId c : pcs in (cts, ucs, ms, sfts, rfts, pcs')
RcvGroupMsgConnection c _g _m -> let ms' = aConnId c : ms in (cts, ucs, ms', sfts, rfts, pcs)
SndFileConnection c sft -> let sfts' = M.insert (aConnId c) sft sfts in (cts, ucs, ms, sfts', rfts, pcs)
RcvFileConnection c rft -> let rfts' = M.insert (aConnId c) rft rfts in (cts, ucs, ms, sfts, rfts', pcs)
UserContactConnection c uc -> let ucs' = (aConnId c, isNothing $ userContactGroupId uc) : ucs in (cts, ucs', ms, sfts, rfts, pcs)
getContactConns :: CM [ConnId]
getContactConns = withStore_ getUserContactConnIds
getUserContactLinkConns :: CM ([ConnId], [(ConnId, Bool)])
RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs)
RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs')
RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs)
SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs)
RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs)
UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs)
addConn :: Connection -> a -> Map ConnId a -> Map ConnId a
addConn = M.insert . aConnId
toPCC Connection {connId, agentConnId, connStatus, viaUserContactLink, groupLinkId, customUserProfileId, localAlias, createdAt} =
PendingContactConnection
{ pccConnId = connId,
pccAgentConnId = agentConnId,
pccConnStatus = connStatus,
viaContactUri = False,
viaUserContactLink,
groupLinkId,
customUserProfileId,
connReqInv = Nothing,
localAlias,
createdAt,
updatedAt = createdAt
}
getContactConns :: CM ([ConnId], Map ConnId Contact)
getContactConns = do
cts <- withStore_ (`getUserContacts` vr)
let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts
pure (map fst cts', M.fromList cts')
getUserContactLinkConns :: CM ([ConnId], Map ConnId UserContact)
getUserContactLinkConns = do
ucs <- withStore_ getUserContactLinks
pure (map fst ucs, ucs)
getGroupMemberConns :: CM ([(GroupInfo, [ConnId])], [ConnId])
(cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr)
let connIds = map aConnId cs
pure (connIds, M.fromList $ zip connIds ucs)
getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember)
getGroupMemberConns = do
gs <- withStore_ (`getUserGroupMemberConnIds` vr)
pure (gs, concatMap snd gs)
gs <- withStore_ (`getUserGroups` vr)
let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs
pure (gs, map fst mPairs, M.fromList mPairs)
getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer)
getSndFileTransferConns = do
sfts <- withStore_ getLiveSndFileTransfers
@@ -3459,81 +3481,92 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do
rfts <- withStore_ getLiveRcvFileTransfers
let rftPairs = mapMaybe (\ft -> (,ft) <$> liveRcvFileTransferConnId ft) rfts
pure (map fst rftPairs, M.fromList rftPairs)
getPendingContactConns :: CM [ConnId]
getPendingContactConns = withStore_ getPendingContactConnections
contactSubsToView :: Map ConnId AgentErrorType -> [ConnId] -> Map ConnId ContactName -> Bool -> CM ()
contactSubsToView errs cts names ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI
getPendingContactConns :: CM ([ConnId], Map ConnId PendingContactConnection)
getPendingContactConns = do
pcs <- withStore_ getPendingContactConnections
let connIds = map aConnId' pcs
pure (connIds, M.fromList $ zip connIds pcs)
contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> CM ()
contactSubsToView rs cts ce = do
chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses)
ifM (asks $ coreApi . config) (notifyAPI statuses) notifyCLI
where
conns = S.fromList cts
errConns = M.restrictKeys errs conns
notifyCLI = do
toView CRContactSubSummary {user, okSubs = S.size conns - M.size errConns, errSubs = M.size errConns}
when ce $ forM_ (M.assocs errConns) $ \(acId, err) ->
forM_ (M.lookup acId names) $ \contactName ->
toView CRContactSubError {user, contactName, chatError = ChatErrorAgent err Nothing}
notifyAPI = unless (M.null errConns) $ toView $ CRNetworkStatuses (Just user) $ map status (M.assocs errConns)
let cRs = resultsFor rs cts
cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs
toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs
when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors
notifyAPI = toView . CRNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus)
statuses = M.foldrWithKey' addStatus [] cts
where
status (connId, err) = ConnNetworkStatus (AgentConnId connId) $ NSError (errorNetworkStatus err)
errorNetworkStatus :: AgentErrorType -> String
addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)]
addStatus _ Contact {activeConn = Nothing} nss = nss
addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss =
let ns = (agentConnId, netStatus $ resultErr connId rs)
in ns : nss
netStatus :: Maybe ChatError -> NetworkStatus
netStatus = maybe NSConnected $ NSError . errorNetworkStatus
errorNetworkStatus :: ChatError -> String
errorNetworkStatus = \case
BROKER _ NETWORK -> "network"
SMP _ SMP.AUTH -> "contact deleted"
ChatErrorAgent (BROKER _ NETWORK) _ -> "network"
ChatErrorAgent (SMP _ SMP.AUTH) _ -> "contact deleted"
e -> show e
contactLinkSubsToView :: Map ConnId AgentErrorType -> [(ConnId, Bool)] -> CM ()
contactLinkSubsToView errs ucs = do
let (addresses, groupLinks) = partition snd ucs
forM_ addresses $ \(acId, _uc) -> toView $ CRUserAddrSubStatus {user, userContactError = (`ChatErrorAgent` Nothing) <$> M.lookup acId errs}
let groups = S.fromList $ map fst groupLinks
errGroups = M.restrictKeys errs groups
unless (S.null groups) $ toView CRUserGroupLinksSubSummary
{ user,
okSubs = S.size groups - M.size errGroups,
errSubs = M.size errGroups
}
groupSubsToView :: Map ConnId AgentErrorType -> [(GroupInfo, [ConnId])] -> [ConnId] -> Map ConnId ContactName -> Bool -> CM ()
groupSubsToView errs gs allMembers names ce = do
mapM_ (uncurry groupSub) gs
toView CRMemberSubSummary {user, okSubs = S.size conns - M.size errConns, errSubs = M.size errConns}
-- TODO possibly below could be replaced with less noisy events for API
contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM ()
contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs
groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM ()
groupSubsToView rs gs ms ce = do
mapM_ groupSub $
sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs
toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs
where
conns = S.fromList allMembers
errConns = M.restrictKeys errs conns
groupSub :: GroupInfo -> [ConnId] -> CM ()
groupSub g@GroupInfo {membership} groupMembers = do
when ce $ mapM_ (toView . uncurry (CRMemberSubError user g) ) mErrors
mRs = resultsFor rs ms
groupSub :: Group -> CM ()
groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do
when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors
toView groupEvent
where
mErrors :: [(ContactName, ChatError)]
mErrors = sortOn fst $ mapMaybe mError groupMembers
mError :: ConnId -> Maybe (ContactName, ChatError)
mError mConnId = do
mErr <- M.lookup mConnId errConns
name <- M.lookup mConnId names
Just (name, ChatErrorAgent mErr Nothing)
mErrors :: [(GroupMember, ChatError)]
mErrors =
sortOn (\(GroupMember {localDisplayName = n}, _) -> n)
. filterErrors
$ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs
groupEvent :: ChatResponse
groupEvent
| memberStatus membership == GSMemInvited = CRGroupInvitation user g
| null groupMembers =
| all (\GroupMember {activeConn} -> isNothing activeConn) members =
if memberActive membership
then CRGroupEmpty user g
else CRGroupRemoved user g
| otherwise = CRGroupSubscribed user g
sndFileSubsToView :: Map ConnId AgentErrorType -> Map ConnId SndFileTransfer -> CM ()
sndFileSubsToView errs sfts =
forM_ (M.assocs sfts) $ \(acId, ft@SndFileTransfer {fileId, fileStatus}) -> do
forM_ (M.lookup acId errs) $ toView . CRSndFileSubError user ft . (`ChatErrorAgent` Nothing)
sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM ()
sndFileSubsToView rs sfts = do
let sftRs = resultsFor rs sfts
forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do
forM_ err_ $ toView . CRSndFileSubError user ft
void . forkIO $ do
threadDelay 1000000
when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $
sendFileChunk user ft
rcvFileSubsToView :: Map ConnId AgentErrorType -> Map ConnId RcvFileTransfer -> CM ()
rcvFileSubsToView errs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . M.mapMaybeWithKey (\acId rft -> (\e -> (rft, ChatErrorAgent e Nothing)) <$> M.lookup acId errs)
pendingConnSubsToView :: Map ConnId AgentErrorType -> [ConnId] -> CM () -- XXX: ignored by View
pendingConnSubsToView errs pcs = toView CRPendingSubSummary {user, okSubs = S.size conns - M.size errConns, errSubs = M.size errConns}
where
conns = S.fromList pcs
errConns = M.restrictKeys errs conns
rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> CM ()
rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs
pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> CM ()
pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs
withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a]
withStore_ a = withStore' (`a` user) `catchChatError` \e -> toView (CRChatError (Just user) e) $> []
filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)]
filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_)
resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)]
resultsFor rs = M.foldrWithKey' addResult []
where
addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)]
addResult connId = (:) . (,resultErr connId rs)
resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError
resultErr connId rs = case M.lookup connId rs of
Just (Left e) -> Just $ ChatErrorAgent e Nothing
Just _ -> Nothing
_ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId
cleanupManager :: CM ()
cleanupManager = do
interval <- asks (cleanupManagerInterval . config)
@@ -3719,7 +3752,7 @@ processAgentMessageNoConn = \case
where
connIds = map AgentConnId conns
notifyAPI = toView . CRNetworkStatus nsStatus
notifyCLI = whenM (asks $ subscriptionEvents . config) $ do
notifyCLI = do
cs <- withStore' (`getConnectionsContacts` conns)
toView $ event srv cs
@@ -7584,13 +7617,11 @@ chatCommandP =
("/version" <|> "/v") $> ShowVersion,
"/debug locks" $> DebugLocks,
"/debug event " *> (DebugEvent <$> jsonP),
"/get stats" $> GetAgentStats,
"/reset stats" $> ResetAgentStats,
"/get servers summary " *> (GetAgentServersSummary <$> A.decimal),
"/get subs" $> GetAgentSubs,
"/get subs details" $> GetAgentSubsDetails,
"/get workers" $> GetAgentWorkers,
"/get workers details" $> GetAgentWorkersDetails,
"/get msgs" $> GetAgentMsgCounts,
"/get queues" $> GetAgentQueuesInfo,
"//" *> (CustomChatCommand <$> A.takeByteString)
]
@@ -7728,7 +7759,7 @@ chatCommandP =
(Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP)))
(pure Nothing)
srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP)
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = True}
toServerCfg server = ServerCfg {server, preset = False, tested = Nothing, enabled = SEEnabled}
rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P))
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char
+1 -1
View File
@@ -85,7 +85,7 @@ defaultAppSettings =
uiDarkColorScheme = Just DCSSimplex,
uiCurrentThemeIds = Nothing,
uiThemes = Nothing,
oneHandUI = Just True
oneHandUI = Just False
}
defaultParseAppSettings :: AppSettings
+1 -1
View File
@@ -21,10 +21,10 @@ import Data.Time.Clock (UTCTime)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import Simplex.Chat.Types (Contact, ContactId, User)
import Simplex.Chat.Types.Util (decodeJSON, encodeJSON)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON)
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
data Call = Call
{ contactId :: ContactId,
+12 -13
View File
@@ -60,6 +60,7 @@ import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol
import Simplex.Chat.Remote.AppVersion
import Simplex.Chat.Remote.Types
import Simplex.Chat.Stats (PresentedServersSummary)
import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, UserMsgReceiptSettings)
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
@@ -75,7 +76,7 @@ import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Client (SMPProxyMode (..), SMPProxyFallback (..))
import Simplex.Messaging.Client (SMPProxyFallback (..), SMPProxyMode (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -505,13 +506,11 @@ data ChatCommand
| ShowVersion
| DebugLocks
| DebugEvent ChatResponse
| GetAgentStats
| ResetAgentStats
| GetAgentServersSummary UserId
| GetAgentSubs
| GetAgentSubsDetails
| GetAgentWorkers
| GetAgentWorkersDetails
| GetAgentMsgCounts
| GetAgentQueuesInfo
| -- The parser will return this command for strings that start from "//".
-- This command should be processed in preCmdHook
@@ -680,10 +679,9 @@ data ChatResponse
| CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity}
| CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]}
| CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]}
| CRContactSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRContactSubError {user :: User, contactName :: ContactName, chatError :: ChatError}
| CRUserAddrSubStatus {user :: User, userContactError :: Maybe ChatError}
| CRUserGroupLinksSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError}
| CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]}
| CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]}
| CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]}
| CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]}
| CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost}
@@ -720,10 +718,10 @@ data ChatResponse
| CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember}
| CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember}
| CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact}
| CRMemberSubError {user :: User, groupInfo :: GroupInfo, contactName :: ContactName, chatError :: ChatError}
| CRMemberSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError}
| CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]}
| CRGroupSubscribed {user :: User, groupInfo :: GroupInfo}
| CRPendingSubSummary {user :: User, okSubs :: Int, errSubs :: Int}
| CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]}
| CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError}
| CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError}
| CRCallInvitation {callInvitation :: RcvCallInvitation}
@@ -732,6 +730,8 @@ data ChatResponse
| CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo}
| CRCallEnded {user :: User, contact :: Contact}
| CRCallInvitations {callInvitations :: [RcvCallInvitation]}
| CRUserContactLinkSubscribed -- TODO delete
| CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete
| CRNtfTokenStatus {status :: NtfTknStatus}
| CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode, ntfServer :: NtfServer}
| CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]}
@@ -755,12 +755,11 @@ data ChatResponse
| CRSQLResult {rows :: [Text]}
| CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]}
| CRDebugLocks {chatLockName :: Maybe String, chatEntityLocks :: Map String String, agentLocks :: AgentLocks}
| CRAgentStats {agentStats :: [[String]]}
| CRAgentServersSummary {user :: User, serversSummary :: PresentedServersSummary}
| CRAgentWorkersDetails {agentWorkersDetails :: AgentWorkersDetails}
| CRAgentWorkersSummary {agentWorkersSummary :: AgentWorkersSummary}
| CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]}
| CRAgentSubsDetails {agentSubs :: SubscriptionsInfo}
| CRAgentMsgCounts {msgCounts :: [(Text, (Int, Int))]}
| CRAgentQueuesInfo {agentQueuesInfo :: AgentQueuesInfo}
| CRContactDisabled {user :: User, contact :: Contact}
| CRConnectionDisabled {connectionEntity :: ConnectionEntity}
+8 -5
View File
@@ -29,13 +29,12 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Types
import Simplex.Chat.Types.Util
import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
import Simplex.Messaging.Protocol (ProtocolServer (..))
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import Simplex.Messaging.Util (safeDecodeUtf8)
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8)
import System.Console.ANSI.Types
import qualified Text.Email.Validate as Email
@@ -146,7 +145,7 @@ parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInpu
isSimplexLink :: Format -> Bool
isSimplexLink = \case
SimplexLink {} -> True;
SimplexLink {} -> True
_ -> False
markdownP :: Parser Markdown
@@ -223,11 +222,15 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD s
| T.null s = unmarked s
| isUri s =
let t = T.takeWhileEnd isPunctuation s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
let t = T.takeWhileEnd isPunctuation' s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation' s
in if T.null t then uri else uri :|: unmarked t
| isEmail s = markdown Email s
| otherwise = unmarked s
isPunctuation' = \case
'/' -> False
')' -> False
c -> isPunctuation c
uriMarkdown s = case strDecode $ encodeUtf8 s of
Right cReq -> markdown (simplexUriFormat cReq) s
_ -> markdown Uri s
+2 -3
View File
@@ -29,12 +29,11 @@ import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.Util
import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..))
import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff)
import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern PQEncOn)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON)
import Simplex.Messaging.Util (safeDecodeUtf8, tshow, (<$?>))
import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>))
data MsgDirection = MDRcv | MDSnd
deriving (Eq, Show)
@@ -1,18 +0,0 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20240530_user_contact_links_user_id where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20240530_user_contact_links_user_id :: Query
m20240530_user_contact_links_user_id =
[sql|
CREATE INDEX idx_user_contact_links_user_id ON user_contact_links(user_id);
|]
down_m20240530_user_contact_links_user_id :: Query
down_m20240530_user_contact_links_user_id =
[sql|
DROP INDEX idx_user_contact_links_user_id;
|]
@@ -882,4 +882,3 @@ CREATE INDEX idx_chat_items_fwd_from_group_id ON chat_items(fwd_from_group_id);
CREATE INDEX idx_chat_items_fwd_from_chat_item_id ON chat_items(
fwd_from_chat_item_id
);
CREATE INDEX idx_user_contact_links_user_id ON user_contact_links(user_id);
+1 -2
View File
@@ -46,14 +46,13 @@ import Database.SQLite.Simple.ToField (ToField (..))
import Simplex.Chat.Call
import Simplex.Chat.Types
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.Util
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
import Simplex.Messaging.Compression (Compressed, compress1, decompress1)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (MsgBody)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Version hiding (version)
-- Chat version history:
+268
View File
@@ -0,0 +1,268 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Stats where
import Control.Applicative ((<|>))
import qualified Data.Aeson.TH as J
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Stats
import Simplex.Messaging.Parsers (defaultJSON)
import Simplex.Messaging.Protocol
data PresentedServersSummary = PresentedServersSummary
{ statsStartedAt :: UTCTime,
currentUserServers :: ServersSummary,
allUsersServers :: ServersSummary
}
deriving (Show)
-- Presentation of servers will be split into separate categories,
-- so users can differentiate currently used (connected) servers,
-- previously connected servers that were in use in previous sessions,
-- and servers that are only proxied (not connected directly).
data ServersSummary = ServersSummary
{ -- currently used SMP servers are those with Just in sessions and/or subs in SMPServerSummary;
-- all other servers would fall either into previously used or only proxied servers category
currentlyUsedSMPServers :: [SMPServerSummary],
-- previously used SMP servers are those with Nothing in sessions and subs,
-- and have any of sentDirect, sentProxied, recvMsgs, etc. > 0 in server stats (see toPresentedServersSummary);
-- remaining servers would fall into only proxied servers category
previouslyUsedSMPServers :: [SMPServerSummary],
-- only proxied SMP servers are those that aren't (according to current state - sessions and subs)
-- and weren't (according to stats) connected directly; they would have Nothing in sessions and subs,
-- and have all of sentDirect, sentProxied, recvMsgs, etc. = 0 in server stats
onlyProxiedSMPServers :: [SMPServerSummary],
-- currently used XFTP servers are those with Just in sessions in XFTPServerSummary,
-- and/or have upload/download/deletion in progress;
-- all other servers would fall into previously used servers category
currentlyUsedXFTPServers :: [XFTPServerSummary],
-- previously used XFTP servers are those with Nothing in sessions and don't have any process in progress
previouslyUsedXFTPServers :: [XFTPServerSummary]
}
deriving (Show)
data SMPServerSummary = SMPServerSummary
{ smpServer :: SMPServer,
-- known:
-- for simplicity always Nothing in totalServersSummary - allows us to load configured servers only for current user,
-- and also unnecessary unless we want to add navigation to other users servers settings;
-- always Just in currentUserServers - True if server is in list of user servers, otherwise False;
-- True - allows to navigate to server settings, False - allows to add server to configured as known (SEKnown)
known :: Maybe Bool,
sessions :: Maybe ServerSessions,
subs :: Maybe SMPServerSubs,
-- stats:
-- even if sessions and subs are Nothing, stats can be Just - server could be used earlier in session,
-- or in previous sessions and stats for it were restored; server would fall into a category of
-- previously used or only proxied servers - see ServersSummary above
stats :: Maybe AgentSMPServerStatsData
}
deriving (Show)
data XFTPServerSummary = XFTPServerSummary
{ xftpServer :: XFTPServer,
known :: Maybe Bool, -- same as for SMPServerSummary
sessions :: Maybe ServerSessions,
stats :: Maybe AgentXFTPServerStatsData,
rcvInProgress :: Bool,
sndInProgress :: Bool,
delInProgress :: Bool
}
deriving (Show)
-- Maps AgentServersSummary to PresentedServersSummary:
-- - currentUserServers is for currentUser;
-- - users are passed to exclude hidden users from totalServersSummary;
-- - if currentUser is hidden, it should be accounted in totalServersSummary;
-- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs
toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> PresentedServersSummary
toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs = do
let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries
(userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms
(allSMPCurr, allSMPPrev, allSMPProx) = smpSummsIntoCategories allSMPSrvsSumms
(userXFTPSrvsSumms, allXFTPSrvsSumms) = accXFTPSrvsSummaries
(userXFTPCurr, userXFTPPrev) = xftpSummsIntoCategories userXFTPSrvsSumms
(allXFTPCurr, allXFTPPrev) = xftpSummsIntoCategories allXFTPSrvsSumms
PresentedServersSummary
{ statsStartedAt,
currentUserServers =
ServersSummary
{ currentlyUsedSMPServers = userSMPCurr,
previouslyUsedSMPServers = userSMPPrev,
onlyProxiedSMPServers = userSMPProx,
currentlyUsedXFTPServers = userXFTPCurr,
previouslyUsedXFTPServers = userXFTPPrev
},
allUsersServers =
ServersSummary
{ currentlyUsedSMPServers = allSMPCurr,
previouslyUsedSMPServers = allSMPPrev,
onlyProxiedSMPServers = allSMPProx,
currentlyUsedXFTPServers = allXFTPCurr,
previouslyUsedXFTPServers = allXFTPPrev
}
}
where
AgentServersSummary {statsStartedAt, smpServersSessions, smpServersSubs, smpServersStats, xftpServersSessions, xftpServersStats, xftpRcvInProgress, xftpSndInProgress, xftpDelInProgress} = agentSummary
countUserInAll auId = auId == aUserId currentUser || auId `notElem` hiddenUserIds
hiddenUserIds = map aUserId $ filter (isJust . viewPwdHash) users
smpSummsIntoCategories :: Map SMPServer SMPServerSummary -> ([SMPServerSummary], [SMPServerSummary], [SMPServerSummary])
smpSummsIntoCategories = foldr partitionSummary ([], [], [])
where
partitionSummary srvSumm (curr, prev, prox)
| isCurrentlyUsed srvSumm = (srvSumm : curr, prev, prox)
| isPreviouslyUsed srvSumm = (curr, srvSumm : prev, prox)
| otherwise = (curr, prev, srvSumm : prox)
isCurrentlyUsed SMPServerSummary {sessions, subs} = isJust sessions || isJust subs
isPreviouslyUsed SMPServerSummary {stats} = case stats of
Nothing -> False
-- add connCompleted, connDeleted?
-- check: should connCompleted be counted for proxy? is it?
Just AgentSMPServerStatsData {_sentDirect, _sentProxied, _sentDirectAttempts, _sentProxiedAttempts, _recvMsgs, _connCreated, _connSecured, _connSubscribed, _connSubAttempts} ->
_sentDirect > 0 || _sentProxied > 0 || _sentDirectAttempts > 0 || _sentProxiedAttempts > 0 || _recvMsgs > 0 || _connCreated > 0 || _connSecured > 0 || _connSubscribed > 0 || _connSubAttempts > 0
xftpSummsIntoCategories :: Map XFTPServer XFTPServerSummary -> ([XFTPServerSummary], [XFTPServerSummary])
xftpSummsIntoCategories = foldr partitionSummary ([], [])
where
partitionSummary srvSumm (curr, prev)
| isCurrentlyUsed srvSumm = (srvSumm : curr, prev)
| otherwise = (curr, srvSumm : prev)
isCurrentlyUsed XFTPServerSummary {sessions, rcvInProgress, sndInProgress, delInProgress} =
isJust sessions || rcvInProgress || sndInProgress || delInProgress
accSMPSrvsSummaries :: (Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary)
accSMPSrvsSummaries = M.foldrWithKey' (addServerData addStats) summs2 smpServersStats
where
summs1 = M.foldrWithKey' (addServerData addSessions) (M.empty, M.empty) smpServersSessions
summs2 = M.foldrWithKey' (addServerData addSubs) summs1 smpServersSubs
addServerData ::
(a -> SMPServerSummary -> SMPServerSummary) ->
(UserId, SMPServer) ->
a ->
(Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary) ->
(Map SMPServer SMPServerSummary, Map SMPServer SMPServerSummary)
addServerData addData (userId, srv) d (userSumms, allUsersSumms) = (userSumms', allUsersSumms')
where
userSumms'
| userId == aUserId currentUser = alterSumms newUserSummary userSumms
| otherwise = userSumms
allUsersSumms'
| countUserInAll userId = alterSumms newSummary allUsersSumms
| otherwise = allUsersSumms
alterSumms n = M.alter (Just . addData d . fromMaybe n) srv
newUserSummary = (newSummary :: SMPServerSummary) {known = Just $ srv `elem` userSMPSrvs}
newSummary =
SMPServerSummary
{ smpServer = srv,
known = Nothing,
sessions = Nothing,
subs = Nothing,
stats = Nothing
}
addSessions :: ServerSessions -> SMPServerSummary -> SMPServerSummary
addSessions s summ@SMPServerSummary {sessions} = summ {sessions = Just $ maybe s (s `addServerSessions`) sessions}
addSubs :: SMPServerSubs -> SMPServerSummary -> SMPServerSummary
addSubs s summ@SMPServerSummary {subs} = summ {subs = Just $ maybe s (s `addSMPSubs`) subs}
addStats :: AgentSMPServerStatsData -> SMPServerSummary -> SMPServerSummary
addStats s summ@SMPServerSummary {stats} = summ {stats = Just $ maybe s (s `addSMPStats`) stats}
accXFTPSrvsSummaries :: (Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary)
accXFTPSrvsSummaries = M.foldrWithKey' (addServerData addStats) summs1 xftpServersStats
where
summs1 = M.foldrWithKey' (addServerData addSessions) (M.empty, M.empty) xftpServersSessions
addServerData ::
(a -> XFTPServerSummary -> XFTPServerSummary) ->
(UserId, XFTPServer) ->
a ->
(Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary) ->
(Map XFTPServer XFTPServerSummary, Map XFTPServer XFTPServerSummary)
addServerData addData (userId, srv) d (userSumms, allUsersSumms) = (userSumms', allUsersSumms')
where
userSumms'
| userId == aUserId currentUser = alterSumms newUserSummary userSumms
| otherwise = userSumms
allUsersSumms'
| countUserInAll userId = alterSumms newSummary allUsersSumms
| otherwise = allUsersSumms
alterSumms n = M.alter (Just . addData d . fromMaybe n) srv
newUserSummary = (newSummary :: XFTPServerSummary) {known = Just $ srv `elem` userXFTPSrvs}
newSummary =
XFTPServerSummary
{ xftpServer = srv,
known = Nothing,
sessions = Nothing,
stats = Nothing,
rcvInProgress = srv `elem` xftpRcvInProgress,
sndInProgress = srv `elem` xftpSndInProgress,
delInProgress = srv `elem` xftpDelInProgress
}
addSessions :: ServerSessions -> XFTPServerSummary -> XFTPServerSummary
addSessions s summ@XFTPServerSummary {sessions} = summ {sessions = Just $ maybe s (s `addServerSessions`) sessions}
addStats :: AgentXFTPServerStatsData -> XFTPServerSummary -> XFTPServerSummary
addStats s summ@XFTPServerSummary {stats} = summ {stats = Just $ maybe s (s `addXFTPStats`) stats}
addServerSessions :: ServerSessions -> ServerSessions -> ServerSessions
addServerSessions ss1 ss2 =
ServerSessions
{ ssConnected = ssConnected ss1 + ssConnected ss2,
ssErrors = ssErrors ss1 + ssErrors ss2,
ssConnecting = ssConnecting ss1 + ssConnecting ss2
}
addSMPSubs :: SMPServerSubs -> SMPServerSubs -> SMPServerSubs
addSMPSubs ss1 ss2 =
SMPServerSubs
{ ssActive = ssActive ss1 + ssActive ss2,
ssPending = ssPending ss1 + ssPending ss2
}
addSMPStats :: AgentSMPServerStatsData -> AgentSMPServerStatsData -> AgentSMPServerStatsData
addSMPStats sd1 sd2 =
AgentSMPServerStatsData
{ _sentDirect = _sentDirect sd1 + _sentDirect sd2,
_sentViaProxy = _sentViaProxy sd1 + _sentViaProxy sd2,
_sentProxied = _sentProxied sd1 + _sentProxied sd2,
_sentDirectAttempts = _sentDirectAttempts sd1 + _sentDirectAttempts sd2,
_sentViaProxyAttempts = _sentViaProxyAttempts sd1 + _sentViaProxyAttempts sd2,
_sentProxiedAttempts = _sentProxiedAttempts sd1 + _sentProxiedAttempts sd2,
_sentAuthErrs = _sentAuthErrs sd1 + _sentAuthErrs sd2,
_sentQuotaErrs = _sentQuotaErrs sd1 + _sentQuotaErrs sd2,
_sentExpiredErrs = _sentExpiredErrs sd1 + _sentExpiredErrs sd2,
_sentOtherErrs = _sentOtherErrs sd1 + _sentOtherErrs sd2,
_recvMsgs = _recvMsgs sd1 + _recvMsgs sd2,
_recvDuplicates = _recvDuplicates sd1 + _recvDuplicates sd2,
_recvCryptoErrs = _recvCryptoErrs sd1 + _recvCryptoErrs sd2,
_recvErrs = _recvErrs sd1 + _recvErrs sd2,
_connCreated = _connCreated sd1 + _connCreated sd2,
_connSecured = _connSecured sd1 + _connSecured sd2,
_connCompleted = _connCompleted sd1 + _connCompleted sd2,
_connDeleted = _connDeleted sd1 + _connDeleted sd2,
_connSubscribed = _connSubscribed sd1 + _connSubscribed sd2,
_connSubAttempts = _connSubAttempts sd1 + _connSubAttempts sd2,
_connSubErrs = _connSubErrs sd1 + _connSubErrs sd2
}
addXFTPStats :: AgentXFTPServerStatsData -> AgentXFTPServerStatsData -> AgentXFTPServerStatsData
addXFTPStats sd1 sd2 =
AgentXFTPServerStatsData
{ _uploads = _uploads sd1 + _uploads sd2,
_uploadAttempts = _uploadAttempts sd1 + _uploadAttempts sd2,
_uploadErrs = _uploadErrs sd1 + _uploadErrs sd2,
_downloads = _downloads sd1 + _downloads sd2,
_downloadAttempts = _downloadAttempts sd1 + _downloadAttempts sd2,
_downloadAuthErrs = _downloadAuthErrs sd1 + _downloadAuthErrs sd2,
_downloadErrs = _downloadErrs sd1 + _downloadErrs sd2,
_deletions = _deletions sd1 + _deletions sd2,
_deleteAttempts = _deleteAttempts sd1 + _deleteAttempts sd2,
_deleteErrs = _deleteErrs sd1 + _deleteErrs sd2
}
$(J.deriveJSON defaultJSON ''SMPServerSummary)
$(J.deriveJSON defaultJSON ''XFTPServerSummary)
$(J.deriveJSON defaultJSON ''ServersSummary)
$(J.deriveJSON defaultJSON ''PresentedServersSummary)
+12 -9
View File
@@ -19,7 +19,7 @@ module Simplex.Chat.Store.Connections
where
import Control.Applicative ((<|>))
import Control.Monad (forM)
import Control.Monad
import Control.Monad.Except
import Data.Int (Int64)
import Data.Maybe (catMaybes, fromMaybe)
@@ -28,6 +28,7 @@ import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (ConnId)
@@ -212,17 +213,19 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2
(userId, cReqHash1, cReqHash2, ConnDeleted)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_
getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> User -> IO ([ConnId], [ConnectionEntity])
getConnectionsToSubscribe db vr user@User {userId} = do
aConnIds <- map fromOnly <$> DB.query db "SELECT agent_conn_id FROM connections WHERE to_subscribe = 1 AND user_id = ?" (Only userId)
entities <- forM aConnIds $ \acId ->
eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId)
unsetConnectionToSubscribe db user
getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> IO ([ConnId], [ConnectionEntity])
getConnectionsToSubscribe db vr = do
aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1"
entities <- forM aConnIds $ \acId -> do
getUserByAConnId db acId >>= \case
Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId)
Nothing -> pure Nothing
unsetConnectionToSubscribe db
let connIds = map (\(AgentConnId connId) -> connId) aConnIds
pure (connIds, catMaybes entities)
unsetConnectionToSubscribe :: DB.Connection -> User -> IO ()
unsetConnectionToSubscribe db User {userId} = DB.execute db "UPDATE connections SET to_subscribe = 0 WHERE user_id = ? AND to_subscribe = 1" (Only userId)
unsetConnectionToSubscribe :: DB.Connection -> IO ()
unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1"
deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO ()
deleteConnectionRecord db User {userId} cId = do
+13 -31
View File
@@ -56,7 +56,6 @@ module Simplex.Chat.Store.Direct
incQuotaErrCounter,
setQuotaErrCounter,
getUserContacts,
getUserContactConnIds,
createOrUpdateContactRequest,
getContactRequest',
getContactRequest,
@@ -840,9 +839,19 @@ getUserByContactRequestId db contactRequestId =
ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $
DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId)
getPendingContactConnections :: DB.Connection -> User -> IO [ConnId]
getPendingContactConnections db User {userId} =
map fromOnly <$> DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL" (userId, ConnContact)
getPendingContactConnections :: DB.Connection -> User -> IO [PendingContactConnection]
getPendingContactConnections db User {userId} = do
map toPendingContactConnection
<$> DB.queryNamed
db
[sql|
SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at
FROM connections
WHERE user_id = :user_id
AND conn_type = :conn_type
AND contact_id IS NULL
|]
[":user_id" := userId, ":conn_type" := ConnContact]
getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection]
getContactConnections db vr userId Contact {contactId} =
@@ -864,33 +873,6 @@ getContactConnections db vr userId Contact {contactId} =
connections [] = pure []
connections rows = pure $ map (toConnection vr) rows
getUserContactConnIds :: DB.Connection -> User -> IO [ConnId]
getUserContactConnIds db User {userId} =
map fromOnly
<$> DB.query
db
[sql|
SELECT c.agent_conn_id
FROM contacts ct
LEFT JOIN connections c ON c.contact_id = ct.contact_id
WHERE ct.user_id = ?
AND ct.contact_status = ?
AND ct.deleted = 0
AND
c.connection_id = (
SELECT cc_connection_id FROM (
SELECT
cc.connection_id AS cc_connection_id,
cc.created_at AS cc_created_at
FROM connections cc
WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id
ORDER BY cc_created_at DESC
LIMIT 1
)
)
|]
(userId, CSActive)
getConnectionById :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Connection
getConnectionById db vr User {userId} connId = ExceptT $ do
firstRow (toConnection vr) (SEConnectionNotFoundById connId) $
-29
View File
@@ -52,7 +52,6 @@ module Simplex.Chat.Store.Groups
deleteGroupItemsAndMembers,
deleteGroup,
getUserGroups,
getUserGroupMemberConnIds,
getUserGroupDetails,
getUserGroupsWithSummary,
getGroupSummary,
@@ -629,14 +628,6 @@ getUserGroups db vr user@User {userId} = do
groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId)
rights <$> mapM (runExceptT . getGroup db vr user) groupIds
getUserGroupMemberConnIds :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, [ConnId])]
getUserGroupMemberConnIds db vr user@User {userId} = do
groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ? ORDER BY local_display_name" (Only userId)
fmap rights . forM groupIds $ \groupId -> runExceptT $ do
gInfo <- getGroupInfo db vr user groupId
members <- liftIO $ getGroupMemberConnIds db user gInfo
pure (gInfo, members)
getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo]
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ =
map (toGroupInfo vr userContactId)
@@ -757,26 +748,6 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do
(groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)")
(userId, groupId, userId, userContactId)
getGroupMemberConnIds :: DB.Connection -> User -> GroupInfo -> IO [ConnId]
getGroupMemberConnIds db User {userId, userContactId} GroupInfo {groupId} = do
map fromOnly
<$> DB.query
db
[sql|
SELECT c.agent_conn_id
FROM group_members m
JOIN connections c ON c.connection_id = (
SELECT max(cc.connection_id)
FROM connections cc
WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
WHERE m.group_id = ?
AND m.user_id = ?
AND (m.contact_id IS NULL OR m.contact_id != ?)
AND m.member_status NOT IN (?, ?, ?, ?)
|]
(userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown)
getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember]
getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do
map (toContactMember vr user)
+1 -3
View File
@@ -110,7 +110,6 @@ import Simplex.Chat.Migrations.M20240501_chat_deleted
import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy
import Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
import Simplex.Chat.Migrations.M20240528_quota_err_counter
import Simplex.Chat.Migrations.M20240530_user_contact_links_user_id
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -220,8 +219,7 @@ schemaMigrations =
("20240501_chat_deleted", m20240501_chat_deleted, Just down_m20240501_chat_deleted),
("20240510_chat_items_via_proxy", m20240510_chat_items_via_proxy, Just down_m20240510_chat_items_via_proxy),
("20240515_rcv_files_user_approved_relays", m20240515_rcv_files_user_approved_relays, Just down_m20240515_rcv_files_user_approved_relays),
("20240528_quota_err_counter", m20240528_quota_err_counter, Just down_m20240528_quota_err_counter),
("20240530_user_contact_links_user_id", m20240530_user_contact_links_user_id, Just down_m20240530_user_contact_links_user_id)
("20240528_quota_err_counter", m20240528_quota_err_counter, Just down_m20240528_quota_err_counter)
]
-- | The list of migrations in ascending order by date
+23 -14
View File
@@ -350,17 +350,25 @@ getUserAddressConnections db vr User {userId} = do
|]
(userId, userId)
getUserContactLinks :: DB.Connection -> User -> IO [(ConnId, Bool)]
getUserContactLinks db User {userId} =
DB.query
db
[sql|
SELECT c.agent_conn_id, uc.group_id IS NULL
FROM connections c
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
WHERE c.user_id = ? AND uc.user_id = ?
|]
(userId, userId)
getUserContactLinks :: DB.Connection -> VersionRangeChat -> User -> IO [(Connection, UserContact)]
getUserContactLinks db vr User {userId} =
map toUserContactConnection
<$> DB.query
db
[sql|
SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id,
c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version,
uc.user_contact_link_id, uc.conn_req_contact, uc.group_id
FROM connections c
JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id
WHERE c.user_id = ? AND uc.user_id = ?
|]
(userId, userId)
where
toUserContactConnection :: (ConnectionRow :. (Int64, ConnReqContact, Maybe GroupId)) -> (Connection, UserContact)
toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection vr connRow, UserContact {userContactLinkId, connReqContact, groupId})
deleteUserAddress :: DB.Connection -> User -> IO ()
deleteUserAddress db user@User {userId} = do
@@ -515,9 +523,10 @@ getProtocolServers db User {userId} =
(userId, decodeLatin1 $ strEncode protocol)
where
protocol = protocolTypeI @p
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p
toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) =
toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Int) -> ServerCfg p
toServerCfg (host, port, keyHash, auth_, preset, tested, enabledInt) =
let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_)
enabled = toServerEnabled enabledInt
in ServerCfg {server, preset, tested, enabled}
overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO ()
@@ -534,7 +543,7 @@ overwriteProtocolServers db User {userId} servers =
(protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|]
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs))
((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, fromServerEnabled enabled, userId, currentTs, currentTs))
pure $ Right ()
where
protocol = decodeLatin1 $ strEncode $ protocolTypeI @p
+35 -1
View File
@@ -1632,10 +1632,42 @@ data ServerCfg p = ServerCfg
{ server :: ProtoServerWithAuth p,
preset :: Bool,
tested :: Maybe Bool,
enabled :: Bool
enabled :: ServerEnabled
}
deriving (Show)
data ServerEnabled
= SEDisabled
| SEEnabled
| -- server is marked as known, but it's not in the list of configured servers;
-- e.g., it may be added via an unknown server dialogue and user didn't manually configure it,
-- meaning server wasn't tested (or at least such option wasn't presented in UI)
-- and it may be inoperable for user due to server password
SEKnown
deriving (Eq, Show)
pattern DBSEDisabled :: Int
pattern DBSEDisabled = 0
pattern DBSEEnabled :: Int
pattern DBSEEnabled = 1
pattern DBSEKnown :: Int
pattern DBSEKnown = 2
toServerEnabled :: Int -> ServerEnabled
toServerEnabled = \case
DBSEDisabled -> SEDisabled
DBSEEnabled -> SEEnabled
DBSEKnown -> SEKnown
_ -> SEDisabled
fromServerEnabled :: ServerEnabled -> Int
fromServerEnabled = \case
SEDisabled -> DBSEDisabled
SEEnabled -> DBSEEnabled
SEKnown -> DBSEKnown
data ChatVersion
instance VersionScope ChatVersion
@@ -1764,6 +1796,8 @@ $(JQ.deriveJSON defaultJSON ''ContactRef)
$(JQ.deriveJSON defaultJSON ''NoteFolder)
$(JQ.deriveJSON (enumJSON $ dropPrefix "SE") ''ServerEnabled)
instance ProtocolTypeI p => ToJSON (ServerCfg p) where
toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerCfg)
toJSON = $(JQ.mkToJSON defaultJSON ''ServerCfg)
+1 -1
View File
@@ -36,7 +36,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.Util
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>))
data ChatFeature
= CFTimedMessages
+1
View File
@@ -18,6 +18,7 @@ import Database.SQLite.Simple.ToField (ToField (..))
import Simplex.Chat.Types.Util
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_)
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
data UITheme = UITheme
{ themeId :: Text,
-11
View File
@@ -2,26 +2,15 @@
module Simplex.Chat.Types.Util where
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.Types as JT
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Typeable
import Database.SQLite.Simple (ResultError (..), SQLData (..))
import Database.SQLite.Simple.FromField (FieldParser, returnError)
import Database.SQLite.Simple.Internal (Field (..))
import Database.SQLite.Simple.Ok (Ok (Ok))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util (safeDecodeUtf8)
encodeJSON :: ToJSON a => a -> Text
encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode
decodeJSON :: FromJSON a => Text -> Maybe a
decodeJSON = J.decode . LB.fromStrict . encodeUtf8
textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a
textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode
+22 -16
View File
@@ -18,7 +18,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isSpace, toUpper)
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, sortOn)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
@@ -54,7 +54,6 @@ import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..))
import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..))
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP))
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..))
import qualified Simplex.Messaging.Crypto as C
@@ -238,13 +237,19 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
in ttyUser u [sShow connId <> ": END"]
CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"]
CRContactSubError u c e -> ttyUser u [ttyContact c <> ": contact error " <> sShow e]
CRContactSubSummary {user, okSubs, errSubs} -> ttyUser user $ [sShow okSubs <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | okSubs > 0] <> viewErrorsSummary errSubs " contact errors"
CRUserAddrSubStatus {user, userContactError} -> ttyUser user [maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError]
CRUserGroupLinksSubSummary {user, okSubs, errSubs} ->
ttyUser user $
[sShow okSubs <> " group links active" | okSubs > 0]
<> viewErrorsSummary errSubs " group link errors"
CRContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e]
CRContactSubSummary u summary ->
ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors"
where
(errors, subscribed) = partition (isJust . contactError) summary
CRUserContactSubSummary u summary ->
ttyUser u $
map addressSS addresses
<> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors")
where
(addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary
addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError
(groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks
CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else []
CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else []
CRGroupInvitation u g -> ttyUser u [groupInvitation' g]
@@ -278,10 +283,10 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m]
CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"]
CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct'
CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyContact m <> " error: " <> sShow e]
CRMemberSubSummary {user, errSubs} -> ttyUser user $ viewErrorsSummary errSubs " group member errors"
CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e]
CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors"
CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g
CRPendingSubSummary {user} -> ttyUser user [] -- XXX: ???
CRPendingSubSummary u _ -> ttyUser u []
CRSndFileSubError u SndFileTransfer {fileId, fileName} e ->
ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e ->
@@ -292,6 +297,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact]
CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"]
CRCallInvitations _ -> []
CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"]
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
@@ -358,7 +365,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
"chat entity locks: " <> viewJSON chatEntityLocks,
"agent locks: " <> viewJSON agentLocks
]
CRAgentStats stats -> map (plain . intercalate ",") stats
CRAgentServersSummary u serversSummary -> ttyUser u ["agent servers summary: " <> viewJSON serversSummary]
CRAgentSubs {activeSubs, pendingSubs, removedSubs} ->
[plain $ "Subscriptions: active = " <> show (sum activeSubs) <> ", pending = " <> show (sum pendingSubs) <> ", removed = " <> show (sum $ M.map length removedSubs)]
<> ("active subscriptions:" : listSubs activeSubs)
@@ -376,7 +383,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
[ "agent workers details:",
viewJSON agentWorkersDetails -- this would be huge, but copypastable when has its own line
]
CRAgentMsgCounts {msgCounts} -> ["received messages (total, duplicates):", viewJSON msgCounts]
CRAgentQueuesInfo {agentQueuesInfo} ->
[ "agent queues info:",
plain . LB.unpack $ J.encode agentQueuesInfo
@@ -450,8 +456,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
testViewItem (CChatItem _ ci@ChatItem {meta = CIMeta {itemText}}) membership_ =
let deleted_ = maybe "" (\t -> " [" <> t <> "]") (chatItemDeletedText ci membership_)
in itemText <> deleted_
viewErrorsSummary :: Int -> StyledString -> [StyledString]
viewErrorsSummary numErrors s = [ttyError (tshow numErrors) <> s <> " (run with -c option to show each error)" | numErrors > 0]
viewErrorsSummary :: [a] -> StyledString -> [StyledString]
viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)]
contactList :: [ContactRef] -> String
contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs
unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString]
+10 -9
View File
@@ -1448,7 +1448,6 @@ testUsersSubscribeAfterRestart :: HasCallStack => FilePath -> IO ()
testUsersSubscribeAfterRestart tmp = do
withNewTestChat tmp "bob" bobProfile $ \bob -> do
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
threadDelay 100000
connectUsers alice bob
alice <##> bob
@@ -1459,7 +1458,8 @@ testUsersSubscribeAfterRestart tmp = do
withTestChat tmp "alice" $ \alice -> do
-- second user is active
alice <### ["1 contacts connected (use /cs for the list)", "[user: alice] 1 contacts connected (use /cs for the list)"]
alice <## "1 contacts connected (use /cs for the list)"
alice <## "[user: alice] 1 contacts connected (use /cs for the list)"
-- second user receives message
alice <##> bob
@@ -1793,7 +1793,8 @@ testUsersRestartCIExpiration tmp = do
showActiveUser alice "alice (Alice)"
withTestChatCfg tmp cfg "alice" $ \alice -> do
alice <### ["1 contacts connected (use /cs for the list)", "[user: alisa] 1 contacts connected (use /cs for the list)"]
alice <## "1 contacts connected (use /cs for the list)"
alice <## "[user: alisa] 1 contacts connected (use /cs for the list)"
-- first user messages
alice ##> "/user alice"
@@ -1891,7 +1892,8 @@ testEnableCIExpirationOnlyForOneUser tmp = do
alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")])
withTestChatCfg tmp cfg "alice" $ \alice -> do
alice <### ["1 contacts connected (use /cs for the list)", "[user: alice] 1 contacts connected (use /cs for the list)"]
alice <## "1 contacts connected (use /cs for the list)"
alice <## "[user: alice] 1 contacts connected (use /cs for the list)"
-- messages are not deleted for second user after restart
alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")])
@@ -1946,7 +1948,8 @@ testDisableCIExpirationOnlyForOneUser tmp = do
alice #$> ("/_get chat @4 count=100", chat, [])
withTestChatCfg tmp cfg "alice" $ \alice -> do
alice <### ["1 contacts connected (use /cs for the list)", "[user: alice] 1 contacts connected (use /cs for the list)"]
alice <## "1 contacts connected (use /cs for the list)"
alice <## "[user: alice] 1 contacts connected (use /cs for the list)"
-- second user still has ttl configured after restart
alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)")
@@ -2052,10 +2055,8 @@ testUsersTimedMessages tmp = do
alice <# "bob> alisa 4"
withTestChat tmp "alice" $ \alice -> do
alice
<### [ "1 contacts connected (use /cs for the list)",
"[user: alice] 1 contacts connected (use /cs for the list)"
]
alice <## "1 contacts connected (use /cs for the list)"
alice <## "[user: alice] 1 contacts connected (use /cs for the list)"
alice ##> "/user alice"
showActiveUser alice "alice (Alice)"
-2
View File
@@ -2590,7 +2590,6 @@ testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO ()
testPlanGroupLinkConnecting tmp = do
-- gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do
gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do
threadDelay 100000
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
@@ -3201,7 +3200,6 @@ testPlanGroupLinkNoContactKnown =
testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO ()
testPlanGroupLinkNoContactConnecting tmp = do
gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do
threadDelay 100000
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
+1 -1
View File
@@ -17,7 +17,7 @@ import Simplex.Chat.Store.Shared (createContact)
import Simplex.Chat.Types (ConnStatus (..), Profile (..))
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Chat.Types.UITheme
import Simplex.Chat.Types.Util (encodeJSON)
import Simplex.Messaging.Util (encodeJSON)
import Simplex.Messaging.Encoding.String (StrEncoding (..))
import System.Directory (copyFile, createDirectoryIfMissing)
import Test.Hspec hiding (it)
+5
View File
@@ -149,6 +149,11 @@ textWithUri = describe "text with Uri" do
parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat"
parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat"
parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site"
parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat/" `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat/"
parseMarkdown "SimpleX on GitHub: https://github.com/simplex-chat." `shouldBe` "SimpleX on GitHub: " <> uri "https://github.com/simplex-chat" <> "."
parseMarkdown "https://github.com/simplex-chat/ - SimpleX on GitHub" `shouldBe` uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub"
-- parseMarkdown "SimpleX on GitHub (https://github.com/simplex-chat/)" `shouldBe` "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")"
parseMarkdown "https://en.m.wikipedia.org/wiki/Servo_(software)" `shouldBe` uri "https://en.m.wikipedia.org/wiki/Servo_(software)"
it "ignored as markdown" do
parseMarkdown "_https://simplex.chat" `shouldBe` "_https://simplex.chat"
parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat"
+4 -3
View File
@@ -136,10 +136,10 @@ networkStatuses =
#endif
networkStatusesSwift :: LB.ByteString
networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"networkStatuses\":[]}}}"
networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}"
networkStatusesTagged :: LB.ByteString
networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"networkStatuses\":[]}}"
networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}"
memberSubSummary :: LB.ByteString
memberSubSummary =
@@ -222,7 +222,8 @@ testChatApi tmp = do
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted
chatSendCmd cc "/_network_statuses" `shouldReturn` networkStatuses
chatRecvMsg cc `shouldReturn` networkStatuses
chatRecvMsg cc `shouldReturn` userContactSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` ""
chatParseMarkdown "hello" `shouldBe` "{}"
chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown
@@ -0,0 +1 @@
ae8b5b2e-76c9-4a31-a044-bcbda1cdf264