Compare commits

...

77 Commits

Author SHA1 Message Date
Evgeny Poberezkin 0078c4d0d3 simplify? 2024-12-17 14:22:53 +00:00
Diogo e6b62b8900 delete destructive 2024-12-17 10:53:35 +00:00
Diogo 041acc839d fix reset glitches 2024-12-17 09:43:56 +00:00
Diogo e87be333f7 reuse string 2024-12-17 09:38:01 +00:00
Diogo e6861f5eb6 stop flickering on chat tags load 2024-12-17 09:36:39 +00:00
Diogo 6cc3cbc9a2 secondary plus 2024-12-16 23:39:36 +00:00
Diogo dbcc244086 remove diff 2024-12-16 23:34:09 +00:00
Diogo e01cc93026 emoji picker improvements 2024-12-16 23:27:21 +00:00
Diogo 79ea2e1c69 in memory list 2024-12-16 23:21:28 +00:00
Diogo 9676c0bfe7 nav fixes 2024-12-16 23:01:41 +00:00
Diogo b0e319b396 fraction on long press 2024-12-16 22:47:56 +00:00
Diogo 6f4560ed99 add unread to same unit 2024-12-16 22:43:55 +00:00
Diogo 7823eff4f1 remove code 2024-12-16 22:39:44 +00:00
Diogo 498c60740e refactor 2024-12-16 22:39:09 +00:00
Evgeny Poberezkin 11aa5b450a layout 2024-12-16 18:26:13 +00:00
Evgeny Poberezkin 6467d6d706 frame 2024-12-16 12:46:08 +00:00
Evgeny Poberezkin 76820755e3 color, size 2024-12-16 11:57:32 +00:00
Diogo 3acc59bd5d in memory tag edit 2024-12-16 10:39:44 +00:00
Diogo 7057b5ffc7 show tag creation sheet when + button pressed 2024-12-16 10:19:41 +00:00
Diogo 7fb72d6a90 remove delete tag if empty 2024-12-16 10:16:20 +00:00
Diogo f6d32ef6e8 tap unread filter deselects other filters 2024-12-16 10:16:09 +00:00
Diogo e017d3a425 delete and edit on swipe actions 2024-12-14 22:12:32 +00:00
Diogo b66dfc8617 remove context menu 2024-12-14 21:14:53 +00:00
Diogo 77a2815c3a swipe changes 2024-12-14 00:35:26 +00:00
Diogo 289aaa0545 create list to end of list 2024-12-14 00:20:47 +00:00
Diogo cd16fd7a00 edit tag (broken menu inside swiftui list) 2024-12-14 00:19:27 +00:00
Diogo b9b43c8260 reset selection of tag filter on profile changes 2024-12-13 23:19:37 +00:00
Diogo 617dee46c4 + button when no custom lists 2024-12-13 23:18:01 +00:00
Diogo 1983653c5e only apply tag filters on empty search 2024-12-13 22:36:23 +00:00
Diogo 704bf857d2 default selected tag (all) 2024-12-13 22:29:27 +00:00
Diogo 23ad3a9171 all preset tag and change collapsed icon on selection 2024-12-13 22:23:22 +00:00
Diogo b3d67fd0e7 semibold selection 2024-12-13 22:08:38 +00:00
Diogo b20408c844 dismiss sheets on tag/untag 2024-12-13 22:06:34 +00:00
Diogo 3fd2938a5f nice emoji picker 2024-12-13 21:58:15 +00:00
Diogo a5afd4e98a simple emoji text field 2024-12-13 10:57:28 +00:00
Diogo ec5fe341c6 open on create tag if no tags 2024-12-12 22:52:56 +00:00
Diogo 4122b4cb1d simplify tag casts and collapse 2024-12-12 22:39:23 +00:00
Diogo 2a24ef3e6a Merge branch 'dc/core-chat-tags' into dc/ios-chat-tags 2024-12-12 21:17:16 +00:00
Diogo c70677452b Merge branch 'master' into dc/core-chat-tags 2024-12-12 21:16:43 +00:00
Evgeny Poberezkin 4d82afe602 6.2.1: ios 255, android 261, desktop 83 2024-12-12 21:00:16 +00:00
Diogo 591f74a8e3 android, desktop: show contact reactions (#5376)
* android, desktop: show contact reactions

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-12-12 20:33:50 +00:00
Evgeny aede65db14 ios: show who reacted for contacts (#5375) 2024-12-12 18:57:40 +00:00
Stanislav Dmitrenko 0bf82c08a1 android, desktop: fix importing the same database after exporting it (#5372)
* android, desktop: fix importing the same database after exporting it

* delete archive after export or cancel

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-12-12 17:07:08 +00:00
Stanislav Dmitrenko ddef5a122c android, desktop: disable scroll to top of the message when chat bubble was clicked (#5365) 2024-12-12 16:25:51 +00:00
Stanislav Dmitrenko 0fdd2e04cc android, desktop: hide debug logs by default (#5362)
* android, desktop: hide debug logs by default

* string

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-12-12 16:14:55 +00:00
Diogo 3c274bba14 worst emoji picker ever 2024-12-12 16:13:59 +00:00
Stanislav Dmitrenko d1e66386f5 android: fix bottom bar positon in chat item info page (#5373)
* android: fix bottom bar positon in chat item info page

* change
2024-12-12 15:56:59 +00:00
Diogo a9c3aa9c19 changes 2024-12-12 15:56:30 +00:00
Diogo 0d3d2212c0 only show preset when it has matches 2024-12-12 14:26:47 +00:00
Diogo 58ea99055e sheet height 2024-12-12 11:44:50 +00:00
Diogo 257e91cedc temporary display for other option on swipe 2024-12-12 11:32:21 +00:00
Diogo 11cb2d866f updates to sheet 2024-12-12 11:15:18 +00:00
spaced4ndy 708368c9c5 android, desktop: fix operators text (#5371) 2024-12-12 07:26:17 +00:00
Diogo 0c007f8db0 ios: chat tags poc 2024-12-11 22:02:30 +00:00
Diogo 12c2a09c85 get tags fix 2024-12-11 15:17:24 +00:00
Diogo 56dde80689 get chat tags with supplied user id 2024-12-11 12:00:44 +00:00
Diogo f74f6dfb5a attach chat tags to group info 2024-12-11 11:54:06 +00:00
Diogo 0c3a742d0b simplify 2024-12-11 11:33:43 +00:00
Diogo 38da1e2ff1 attach tags to contact 2024-12-11 11:31:40 +00:00
Diogo 5b670c9ad4 refactor 2024-12-11 11:10:51 +00:00
Evgeny Poberezkin 4a6442b30b update 2024-12-11 09:26:53 +00:00
Diogo a5e6d847fe Merge branch 'master' into dc/core-chat-tags 2024-12-10 23:27:39 +00:00
Diogo a0584dedc9 working api 2024-12-10 22:48:31 +00:00
Evgeny Poberezkin 5d8e11c3a2 blog: typo in screenshot 2024-12-10 18:39:14 +00:00
Evgeny Poberezkin df8ce2b06d blog: fix image 2024-12-10 18:13:11 +00:00
Evgeny 6ff5f31bee blog: v6.2 announcement (#5359)
* blog: v6.2 announcement

* update, images

* update

* readme

* correction

* update
2024-12-10 17:16:34 +00:00
Diogo 3104d4487c building 2024-12-10 12:46:07 +00:00
Diogo bba08cd954 update return type 2024-12-10 12:30:12 +00:00
Diogo d63cfe59a8 update type 2024-12-10 12:27:42 +00:00
Diogo c3dc943eb0 profiles build 2024-12-10 12:26:17 +00:00
Diogo f56ea45ce0 store method proposal 2024-12-10 11:50:08 +00:00
Diogo e57b0c98af chat tag 2024-12-10 11:49:32 +00:00
Diogo 25e8f1094c migration module 2024-12-10 11:27:06 +00:00
Diogo 06d7e00cae types and db 2024-12-10 11:12:24 +00:00
Stanislav Dmitrenko 5e29cda27b desktop: start using continuous runtime for AppImage again (#5366) 2024-12-10 11:09:01 +00:00
Stanislav Dmitrenko 2e573662d5 android, desktop: hiding counters while scrolling (#5363)
* android, desktop: hiding counters while scrolling

* change
2024-12-10 10:07:49 +00:00
spaced4ndy 4075c26dd2 ui: offer to fix connection on call and message buttons (#5358) 2024-12-09 21:03:56 +04:00
53 changed files with 1751 additions and 295 deletions
+4 -8
View File
@@ -233,7 +233,7 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Nov 25, 2025. Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md)
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
@@ -243,20 +243,14 @@ Recent and important updates:
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[All updates](./blog)
## :zap: Quick installation of a terminal app
@@ -384,9 +378,11 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Support multiple network operators in the app.
- 🏗 Large groups, communities and public channels.
- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users.
- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
+7
View File
@@ -100,6 +100,13 @@ class ItemsModel: ObservableObject {
}
}
class ChatTagsModel: ObservableObject {
static let shared = ChatTagsModel()
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
+31
View File
@@ -313,6 +313,13 @@ func apiGetChatsAsync() async throws -> [ChatData] {
return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId)))
}
func apiGetChatTags() async throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r = await chatSendCmd(.apiGetChatTags(userId: userId))
if case let .chatTags(_, chatTags) = r { return chatTags }
throw r
}
private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
if case let .apiChats(_, chats) = r { return chats }
throw r
@@ -368,6 +375,30 @@ func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: Ch
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiCreateChatTag(type: ChatType, id: Int64, tag: ChatTagData) async throws -> ([ChatTag], [Int64]) {
let r = await chatSendCmd(.apiCreateChatTag(type: type, id: id, tag: tag))
if case let .tagsUpdated(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r
}
func apiTagChat(type: ChatType, id: Int64, tagId: Int64) async throws -> ([ChatTag], [Int64]) {
let r = await chatSendCmd(.apiTagChat(type: type, id: id, tagId: tagId))
if case let .tagsUpdated(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r
}
func apiUntagChat(type: ChatType, id: Int64, tagId: Int64) async throws -> ([ChatTag], [Int64]) {
let r = await chatSendCmd(.apiUntagChat(type: type, id: id, tagId: tagId))
if case let .chatUntagged(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r
}
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
+79 -30
View File
@@ -156,8 +156,8 @@ struct ChatInfoView: View {
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
@@ -314,7 +314,15 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case .syncConnectionForceAlert:
return syncConnectionForceAlert({
Task {
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
})
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
@@ -324,7 +332,7 @@ struct ChatInfoView: View {
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
@@ -493,7 +501,12 @@ struct ChatInfoView: View {
private func synchronizeConnectionButton() -> some View {
Button {
syncContactConnection(force: false)
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
} label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange)
@@ -612,25 +625,6 @@ struct ChatInfoView: View {
}
}
private func syncContactConnection(force: Bool) {
Task {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
connectionStats = stats
await MainActor.run {
chatModel.updateContactConnectionStats(contact, stats)
dismiss()
}
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
}
private func savePreferences() {
Task {
do {
@@ -649,9 +643,32 @@ struct ChatInfoView: View {
}
}
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats)
}
return stats
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
showAlert(
SomeAlert(
alert: mkAlert(title: a.title, message: a.message),
id: "syncContactConnection error"
)
)
}
return nil
}
}
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -659,6 +676,7 @@ struct AudioCallButton: View {
CallButton(
chat: chat,
contact: contact,
connectionStats: $connectionStats,
image: "phone.fill",
title: "call",
mediaType: .audio,
@@ -671,6 +689,7 @@ struct AudioCallButton: View {
struct VideoButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -678,6 +697,7 @@ struct VideoButton: View {
CallButton(
chat: chat,
contact: contact,
connectionStats: $connectionStats,
image: "video.fill",
title: "video",
mediaType: .video,
@@ -690,6 +710,7 @@ struct VideoButton: View {
private struct CallButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
@@ -701,12 +722,40 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
if let connStats = connectionStats {
if connStats.ratchetSyncState == .ok {
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if connStats.ratchetSyncAllowed {
showAlert(SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
connectionStats = stats
}
}
},
secondaryButton: .cancel()
),
id: "can't call contact, fix connection"
))
} else {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Encryption renegotiation in progress."
),
id: "can't call contact, encryption renegotiation in progress"
))
}
}
} else if contact.nextSendGrpInv {
+39 -21
View File
@@ -440,6 +440,7 @@ struct ChatView: View {
maxWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
showChatInfoSheet: $showChatInfoSheet,
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems
@@ -893,12 +894,14 @@ struct ChatView: View {
private struct ChatItemWithMenu: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
@Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem
let maxWidth: CGFloat
@Binding var composeState: ComposeState
@Binding var selectedMember: GMember?
@Binding var showChatInfoSheet: Bool
@Binding var revealedChatItem: ChatItem?
@State private var deletingItem: ChatItem? = nil
@@ -1255,16 +1258,22 @@ struct ChatView: View {
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
}
}
if case let .group(groupInfo) = chat.chatInfo {
switch chat.chatInfo {
case let .group(groupInfo):
v.contextMenu {
ReactionContextMenu(
groupInfo: groupInfo,
itemId: ci.id,
reactionCount: r,
selectedMember: $selectedMember
selectedMember: $selectedMember,
profileRadius: profileRadius
)
}
} else {
case let .direct(contact):
v.contextMenu {
contactReactionMenu(contact, r)
}
default:
v
}
}
@@ -1767,6 +1776,20 @@ struct ChatView: View {
}
}
}
@ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View {
if !r.userReacted || r.totalReacted > 1 {
Button { showChatInfoSheet = true } label: {
profileMenuItem(Text(contact.displayName), contact.image, radius: profileRadius)
}
}
if r.userReacted {
Button {} label: {
profileMenuItem(Text("you"), m.currentUser?.profile.image, radius: profileRadius)
}
.disabled(true)
}
}
private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme
@@ -1859,13 +1882,12 @@ struct ReactionContextMenu: View {
var itemId: Int64
var reactionCount: CIReactionCount
@Binding var selectedMember: GMember?
var profileRadius: CGFloat
@State private var memberReactions: [MemberReaction] = []
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View {
groupMemberReactionList()
.task {
logger.debug("ReactionContextMenu task \(radius)")
await loadChatItemReaction()
}
}
@@ -1889,27 +1911,12 @@ struct ReactionContextMenu: View {
selectedMember = member
}
} label: {
HStack {
Text(mem.displayName)
if let img = cropImage(mem.image) {
Image(uiImage: img)
} else {
Image(systemName: "person.crop.circle")
}
}
profileMenuItem(Text(mem.displayName), mem.image, radius: profileRadius)
}
.disabled(userMember)
}
}
}
private func cropImage(_ img: String?) -> UIImage? {
return if let originalImage = imageFromBase64(img) {
maskToCustomShape(originalImage, size: 30, radius: radius)
} else {
nil
}
}
private func loadChatItemReaction() async {
do {
@@ -1927,6 +1934,17 @@ struct ReactionContextMenu: View {
}
}
func profileMenuItem(_ nameText: Text, _ image: String?, radius: CGFloat) -> some View {
HStack {
nameText
if let image, let img = imageFromBase64(image) {
Image(uiImage: maskToCustomShape(img, size: 30, radius: radius))
} else {
Image(systemName: "person.crop.circle")
}
}
}
func maskToCustomShape(_ image: UIImage, size: CGFloat, radius: CGFloat) -> UIImage {
let path = Path { path in
if radius >= 50 {
@@ -20,6 +20,9 @@ struct GroupMemberInfoView: View {
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@State private var knownContactChat: Chat? = nil
@State private var knownContact: Contact? = nil
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@@ -119,8 +122,8 @@ struct GroupMemberInfoView: View {
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if member.memberContactId != nil {
if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
@@ -229,6 +232,18 @@ struct GroupMemberInfoView: View {
}
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
knownContactChat = contactChat
knownContact = contact
do {
let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
await MainActor.run {
knownContactConnectionStats = stats
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
}
}
}
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
@@ -274,10 +289,10 @@ struct GroupMemberInfoView: View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
if let chat = knownContactChat, let contact = knownContact {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
@@ -366,25 +381,49 @@ struct GroupMemberInfoView: View {
func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
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)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
if let connStats = connectionStats {
if connStats.ratchetSyncState == .ok {
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)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.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)
}
}
NetworkModel.shared.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)
}
} else if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
syncMemberConnection(force: false)
},
secondaryButton: .cancel()
),
id: "can't message member, fix connection"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Encryption renegotiation in progress."
),
id: "can't message contact, encryption renegotiation in progress"
))
}
}
}
@@ -8,6 +8,7 @@
import SwiftUI
import SimpleXChat
import ElegantEmojiPicker
typealias DynamicSizes = (
rowHeight: CGFloat,
@@ -43,9 +44,11 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@@ -85,6 +88,7 @@ struct ChatListNavLink: View {
progressByTimeout = false
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
@@ -124,6 +128,7 @@ struct ChatListNavLink: View {
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
tagChatButton(chat)
if !chat.chatItems.isEmpty {
clearChatButton()
}
@@ -145,11 +150,10 @@ struct ChatListNavLink: View {
}
}
.alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
@@ -185,6 +189,7 @@ struct ChatListNavLink: View {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo)
}
@@ -206,14 +211,25 @@ struct ChatListNavLink: View {
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
tagChatButton(chat)
let showClearButton = !chat.chatItems.isEmpty
let showDeleteGroup = groupInfo.canDelete
let showLeaveGroup = groupInfo.membership.memberCurrent
let totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton, totalNumberOfButtons <= 3 {
clearChatButton()
}
if (groupInfo.membership.memberCurrent) {
if (showLeaveGroup) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
deleteGroupChatButton(groupInfo)
if showDeleteGroup {
if totalNumberOfButtons <= 3 {
deleteGroupChatButton(groupInfo)
} else {
moreOptionsButton(chat, groupInfo)
}
}
}
}
@@ -306,7 +322,73 @@ struct ChatListNavLink: View {
}
.tint(Color.orange)
}
private func tagChatButton(_ chat: Chat) -> some View {
Button {
setTagChatSheet(chat)
} label: {
SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI)
}
.tint(.mint)
}
private func setTagChatSheet(_ chat: Chat) {
let fraction: Double
switch chatTagsModel.userTags.count {
case 0..<4:
fraction = 0.35
case 4..<9:
fraction = 0.7
default:
fraction = 1
}
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
if chatTagsModel.userTags.isEmpty {
ChatListTagEditor(chat: chat)
} else {
ChatListTag(chat: chat)
}
}
)
},
id: "lists sheet",
fraction: fraction
)
}
private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
Button {
var buttons: [Alert.Button] = [
.default(Text("Clear")) {
AlertManager.shared.showAlert(clearChatAlert())
}
]
if let gi = groupInfo, gi.canDelete {
buttons.append(.destructive(Text("Delete")) {
AlertManager.shared.showAlert(deleteGroupAlert(gi))
})
}
buttons.append(.cancel())
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Clear or delete group?"),
buttons: buttons
),
id: "other options"
)
} label: {
SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI)
}
}
private func clearNoteFolderButton() -> some View {
Button {
AlertManager.shared.showAlert(clearNoteFolderAlert())
@@ -484,6 +566,389 @@ struct ChatListNavLink: View {
}
}
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct ChatListTag: View {
var chat: Chat? = nil
var editMode: Bool = false
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
let v = List {
Section {
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let text = tag.chatTagText
let emoji = tag.chatTagEmoji
let tagId = tag.chatTagId
let selected = chatTagsIds.contains(tagId)
HStack {
if let emoji {
Text(emoji)
} else {
Image(systemName: "tag")
}
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.onTapGesture {
if let c = chat {
if selected {
untagChat(tagId: tagId, chat: c)
} else {
// TODO: Temporary until API is final.
chatTagsIds.forEach { t in
untagChat(tagId: t, chat: c)
}
tagChat(tagId: tagId, chat: c)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("\(text) will be deleted", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
),
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
handler: { _ in
deleteTag(tagId)
}
)
]}
)
} label: {
Label("Delete", systemImage: "trash.fill")
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.background(
// isActive required to navigate to edit view from any possible tag edited in swipe action
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
if let params = tagEditorNavParams {
ChatListTagEditor(
chat: params.chat,
tagId: params.tagId,
emoji: params.chatListTag?.emoji,
name: params.chatListTag?.text ?? ""
)
}
} label: {
EmptyView()
}
.opacity(0)
)
}
.onMove(perform: moveItem)
NavigationLink {
ChatListTagEditor(chat: chat)
} label: {
Label("Create list", systemImage: "plus")
}
}
}
.listStyle(.insetGrouped)
if editMode {
v.toolbar {
ToolbarItem(placement: .topBarTrailing) {
EditButton()
}
}
} else {
v
}
}
@ViewBuilder private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
}
private func moveItem(from source: IndexSet, to destination: Int) {
// TODO: API for reordering tags
Task {
do {
await MainActor.run {
chatTagsModel.userTags.move(fromOffsets: source, toOffset: destination)
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func tagChat(tagId: Int64, chat: Chat) {
Task {
do {
let (userTags, chatTags) = try await apiTagChat(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagId: tagId
)
await MainActor.run {
chatTagsModel.userTags = userTags
updateChatTags(chat: chat, chatTags: chatTags)
dismiss()
}
} catch let error {
showAlert(
NSLocalizedString("Error adding chat to list", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func untagChat(tagId: Int64, chat: Chat) {
Task {
do {
let (userTags, chatTags) = try await apiUntagChat(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagId: tagId
)
await MainActor.run {
chatTagsModel.userTags = userTags
if case let .userTag(tag) = chatTagsModel.activeFilter,
!userTags.contains(where: { $0.chatTagId == tag.chatTagId }) {
chatTagsModel.activeFilter = nil
}
updateChatTags(chat: chat, chatTags: chatTags)
dismiss()
}
} catch let error {
showAlert(
NSLocalizedString("Error removing chat from list", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
// TODO: Delete tag API
Task {
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
chatTagsModel.activeFilter = nil
}
m.chats.forEach { c in
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
m.updateContact(contact)
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
group.chatTags = group.chatTags.filter({ $0 != tagId })
m.updateGroup(group)
}
}
}
}
}
private func updateChatTags(chat: Chat, chatTags: [Int64]) {
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
}
}
struct EmojiPickerView: UIViewControllerRepresentable {
@Binding var selectedEmoji: String?
@Binding var showingPicker: Bool
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
var parent: EmojiPickerView
init(parent: EmojiPickerView) {
self.parent = parent
}
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
parent.selectedEmoji = emoji?.emoji
parent.showingPicker = false
picker.dismiss(animated: true)
}
// Called when the picker is dismissed manually (without selection)
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
parent.showingPicker = false
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
picker.presentationController?.delegate = context.coordinator
let viewController = UIViewController()
DispatchQueue.main.async {
if let topVC = getTopViewController() {
topVC.present(picker, animated: true)
}
}
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No need to update the controller after creation
}
}
struct ChatListTagEditor: View {
var chat: Chat? = nil
var tagId: Int64? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State var emoji: String? = nil
@State var name: String = ""
@State private var isPickerPresented = false
var body: some View {
VStack {
List {
HStack {
Button {
isPickerPresented = true
} label: {
if let emoji {
Text(emoji)
} else {
Image(systemName: "face.smiling")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $name)
}
Button {
if let tId = tagId {
if let em = emoji, !name.isEmpty {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: em, text: name))
}
} else {
createChatTag()
}
} label: {
Text(NSLocalizedString(tagId == nil ? "Create list" : "Change list", comment: "list editor button"))
}
.disabled(name.isEmpty)
}
.listStyle(.insetGrouped)
if isPickerPresented {
EmojiPickerView(selectedEmoji: $emoji, showingPicker: $isPickerPresented)
}
}
}
private func createChatTag() {
// TODO: Allow creating tag without chat
if let chat = chat {
Task {
do {
let (userTags, chatTags) = try await apiCreateChatTag(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tag: ChatTagData(emoji: emoji ?? "😂", text: name)
)
await MainActor.run {
chatTagsModel.userTags = userTags
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
dismiss()
}
} catch let error {
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
// TODO: Create endpoint for edit
Task {
do {
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.map { tag in
if tag.chatTagId == tagId {
ChatTag(chatTagId: tag.chatTagId, chatTagText: chatTagData.text, chatTagEmoji: chatTagData.emoji)
} else {
tag
}
}
dismiss()
}
} catch let error {
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
@@ -585,15 +1050,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
))
), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
))
), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
))
), parentSheet: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 82))
}
+334 -34
View File
@@ -31,6 +31,29 @@ enum UserPickerSheet: Identifiable {
}
}
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 0
case contacts = 1
case groups = 2
case business = 3
var id: Int { rawValue }
}
enum ActiveFilter: Identifiable, Equatable {
case presetTag(PresetTag)
case userTag(ChatTag)
case unread
var id: String {
switch self {
case let .presetTag(tag): return "preset \(tag.id)"
case let .userTag(tag): return "user \(tag.chatTagId)"
case .unread: return "unread"
}
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
@@ -117,8 +140,10 @@ struct ChatListView: View {
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var presetTags: [PresetTag] = []
@StateObject private var chatTagsModel = ChatTagsModel.shared
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@@ -161,6 +186,16 @@ struct ChatListView: View {
}
}
}
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
}
.environmentObject(chatTagsModel)
}
private var chatListView: some View {
@@ -306,7 +341,7 @@ struct ChatListView: View {
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
ChatListNavLink(chat: chat, parentSheet: $sheet)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
@@ -318,7 +353,7 @@ struct ChatListView: View {
VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat)
ChatListNavLink(chat: chat, parentSheet: $sheet)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
@@ -392,45 +427,40 @@ struct ChatListView: View {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
return s == ""
? chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat)
}
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
return switch cInfo {
case let .direct(contact):
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
!contact.chatDeleted && !chat.chatInfo.contactCard && (
( 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)
: viewNameContains(cInfo, s)
case .local:
return s == "" || viewNameContains(cInfo, s)
case .contactRequest:
return s == "" || viewNameContains(cInfo, s)
case let .contactConnection(conn):
return s != "" && conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON:
return false
case .group: viewNameContains(cInfo, s)
case .local: viewNameContains(cInfo, s)
case .contactRequest: viewNameContains(cInfo, s)
case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON: false
}
}
}
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func filtered(_ chat: Chat) -> Bool {
(chat.chatInfo.chatSettings?.favorite ?? false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
case .none: true
}
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
@@ -500,6 +530,7 @@ struct SubsStatusIndicator: View {
struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@@ -508,10 +539,10 @@ struct ChatListSearchBar: View {
@State private var ignoreSearchTextChange = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
ScrollView([.horizontal], showsIndicators: false) { ChatTagsView() }
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -578,16 +609,21 @@ struct ChatListSearchBar: View {
}
private func toggleFilterButton() -> some View {
ZStack {
let showUnread = chatTagsModel.activeFilter == .unread
return ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
if chatTagsModel.activeFilter == .unread {
chatTagsModel.activeFilter = nil
} else {
chatTagsModel.activeFilter = .unread
}
}
}
}
@@ -605,6 +641,233 @@ struct ChatListSearchBar: View {
}
}
struct ChatTagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var chatTagsLoaded: Bool = false
var presetTags: [PresetTag] = []
var body: some View {
HStack {
if chatTagsLoaded {
tagsView()
} else {
tagsViewPlaceholder()
}
}.task {
getChatTags()
}
.onChange(of: chatModel.currentUser?.userId) { _ in
chatTagsModel.activeFilter = nil
getChatTags()
}
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
}
}
@ViewBuilder private func tagsView() -> some View {
if presetTags.count + chatTagsModel.userTags.count <= 5 {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
}
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let current = if case let .userTag(t) = chatTagsModel.activeFilter {
t == tag
} else {
false
}
let color: Color = current ? .accentColor : .secondary
ZStack {
HStack(spacing: 4) {
if let emoji = tag.chatTagEmoji {
Text(emoji)
} else {
Image(systemName: current ? "tag.fill" : "tag")
.foregroundColor(color)
}
ZStack {
Text(tag.chatTagText).fontWeight(.semibold).foregroundColor(.clear)
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .userTag(tag))
}
.onLongPressGesture {
let fraction: Double
switch chatTagsModel.userTags.count {
case 0..<4:
fraction = 0.35
case 4..<9:
fraction = 0.7
default:
fraction = 1
}
sheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTag(chat: nil, editMode: true)
}
)
},
id: "tag list",
fraction: fraction
)
}
}
}
if chatTagsModel.userTags.isEmpty {
Button {
sheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTagEditor()
}
)
},
id: "tag create"
)
} label: {
Image(systemName: "plus")
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
}
}
@ViewBuilder private func tagsViewPlaceholder() -> some View {
HStack {
Text("🙂").foregroundColor(.clear)
ZStack {
Text("Create list").fontWeight(.semibold).foregroundColor(.clear)
}
}
}
@ViewBuilder private func createChatListTagView() -> some View {
NavigationView {
ChatListTagEditor()
.modifier(ThemedBackground(grouped: true))
}
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(presetTags, id: \.id) { tag in
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack {
Image(systemName: icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag))
}
}
}
@ViewBuilder private func collapsedTagsFilterView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
Menu {
ForEach(presetTags, id: \.id) { tag in
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
Image(systemName: systemName)
Text(text)
}
}
}
Button {
chatTagsModel.activeFilter = nil
} label: {
let (systemName, text) = presetTagLabel(tag: nil, active: false)
HStack {
Image(systemName: systemName)
Text(text)
}
}
} label: {
let active = selectedPresetTag != nil
let (systemName, _) = presetTagLabel(tag: selectedPresetTag, active: active)
Image(systemName: systemName)
.foregroundColor(active ? .accentColor : .secondary)
}
.frame(minWidth: 28)
}
private func presetTagLabel(tag: PresetTag?, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .favorites: (active ? "star.fill" : "star", "Favorites")
case .contacts: (active ? "person.fill" : "person", "Contacts")
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
case .none: ("list.bullet", "All")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
} else {
chatTagsModel.activeFilter = nil
}
}
private func getChatTags() {
Task {
do {
chatTagsLoaded = false
let chatTags = try await apiGetChatTags()
await MainActor.run {
self.chatTagsModel.userTags = chatTags
if case let .userTag(tag) = self.chatTagsModel.activeFilter {
if !chatTags.contains(tag) {
self.chatTagsModel.activeFilter = nil
}
}
withAnimation {
self.chatTagsLoaded = true
}
}
} catch let error {
AlertManager.shared.showAlertMsg(title: "Error", message: "\(responseError(error))")
chatTagsLoaded = false
}
}
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
@@ -616,6 +879,43 @@ func chatStoppedIcon() -> some View {
}
}
private func getPresetTags(_ chats: [Chat]) -> [PresetTag] {
var matches: Set<PresetTag> = []
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat) {
matches.insert(tag)
}
}
if matches.count == PresetTag.allCases.count {
break
}
}
return Array(matches).sorted(by: { $0.rawValue < $1.rawValue })
}
private func presetTagMatchesChat(_ tag: PresetTag, _ chat: Chat) -> Bool {
switch tag {
case .favorites:
chat.chatInfo.chatSettings?.favorite == true
case .contacts:
switch chat.chatInfo {
case .direct: true
case .contactRequest: true
case .contactConnection: true
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chat.chatInfo {
case let .group(groupInfo): groupInfo.businessChat == nil
default: false
}
case .business:
chat.chatInfo.groupInfo?.businessChat?.chatType == .business
}
}
struct ChatListView_Previews: PreviewProvider {
@State static var userPickerSheet: UserPickerSheet? = .none
@@ -20,7 +20,7 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false
var body: some View {
let contactType = chatContactType(chat: chat)
let contactType = chatContactType(chat)
Group {
switch (chat.chatInfo) {
@@ -186,7 +186,7 @@ struct NewChatSheet: View {
}
}
func chatContactType(chat: Chat) -> ContactType {
func chatContactType(_ chat: Chat) -> ContactType {
switch chat.chatInfo {
case .contactRequest:
return .request
@@ -207,7 +207,7 @@ func chatContactType(chat: Chat) -> ContactType {
private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
return chats.filter { chat in
contactTypes.contains(chatContactType(chat: chat))
contactTypes.contains(chatContactType(chat))
}
}
@@ -279,8 +279,8 @@ struct ContactsList: View {
}
private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
let chat1Type = chatContactType(chat: chat1)
let chat2Type = chatContactType(chat: chat2)
let chat1Type = chatContactType(chat1)
let chat2Type = chatContactType(chat2)
if chat1Type.rawValue < chat2Type.rawValue {
return true
@@ -25,6 +25,7 @@ struct SomeActionSheet: Identifiable {
struct SomeSheet<Content: View>: Identifiable {
@ViewBuilder var content: Content
var id: String
var fraction = 0.4
}
private enum NewChatViewAlert: Identifiable {
@@ -21,7 +21,7 @@ struct AddressCreationCard: View {
var body: some View {
let addressExists = chatModel.userAddress != nil
let chats = chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard
}
ZStack(alignment: .topTrailing) {
HStack(alignment: .top, spacing: 16) {
+37 -20
View File
@@ -203,6 +203,7 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
@@ -636,6 +637,7 @@
buildActionMask = 2147483647;
files = (
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */,
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */,
8C8118722C220B5B00E6FC94 /* Yams in Frameworks */,
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */,
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */,
@@ -1186,6 +1188,7 @@
D7197A1729AE89660055C05A /* WebRTC */,
8C8118712C220B5B00E6FC94 /* Yams */,
8CB3476B2CF5CFFA006787A5 /* Ink */,
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */,
);
productName = "SimpleX (iOS)";
productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */;
@@ -1330,6 +1333,7 @@
D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */,
8C73C1162C21E17B00892670 /* XCRemoteSwiftPackageReference "Yams" */,
8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */,
B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */,
);
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
projectDirPath = "";
@@ -1931,7 +1935,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1956,7 +1960,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1980,7 +1984,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2005,7 +2009,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -2021,11 +2025,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2041,11 +2045,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2066,7 +2070,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2081,7 +2085,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2103,7 +2107,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2118,7 +2122,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2140,7 +2144,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2166,7 +2170,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2191,7 +2195,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2217,7 +2221,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2242,7 +2246,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2257,7 +2261,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2276,7 +2280,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 254;
CURRENT_PROJECT_VERSION = 255;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2291,7 +2295,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.2;
MARKETING_VERSION = 6.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2387,6 +2391,14 @@
version = 0.6.0;
};
};
B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Finalet/Elegant-Emoji-Picker";
requirement = {
branch = main;
kind = branch;
};
};
D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/simplex-chat/WebRTC.git";
@@ -2429,6 +2441,11 @@
package = 8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */;
productName = Ink;
};
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */ = {
isa = XCSwiftPackageProductDependency;
package = B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */;
productName = ElegantEmojiPicker;
};
CE38A29B2C3FCD72005ED185 /* SwiftyGif */ = {
isa = XCSwiftPackageProductDependency;
package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */;
@@ -1,5 +1,5 @@
{
"originHash" : "33afc44be5f4225325b3cb940ed71b6cbf3ef97290d348d7b6803697bcd0637d",
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"pins" : [
{
"identity" : "codescanner",
@@ -10,6 +10,15 @@
"version" : "2.5.0"
}
},
{
"identity" : "elegant-emoji-picker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Finalet/Elegant-Emoji-Picker",
"state" : {
"branch" : "main",
"revision" : "71d2d46092b4d550cc593614efc06438f845f6e6"
}
},
{
"identity" : "ink",
"kind" : "remoteSourceControl",
+31
View File
@@ -40,10 +40,14 @@ public enum ChatCommand {
case testStorageEncryption(key: String)
case apiSaveSettings(settings: AppSettings)
case apiGetSettings(settings: AppSettings)
case apiGetChatTags(userId: Int64)
case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(type: ChatType, id: Int64, tag: ChatTagData)
case apiTagChat(type: ChatType, id: Int64, tagId: Int64)
case apiUntagChat(type: ChatType, id: Int64, tagId: Int64)
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
@@ -198,6 +202,7 @@ public enum ChatCommand {
case let .testStorageEncryption(key): return "/db test key \(key)"
case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))"
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
@@ -206,6 +211,9 @@ public enum ChatCommand {
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatTag(type, id, tag): return "/_create tag \(ref(type, id)) \(encodeJSON(tag))"
case let .apiTagChat(type, id, tagId): return "/_tag \(ref(type, id)) \(tagId)"
case let .apiUntagChat(type, id, tagId): return "/_untag \(ref(type, id)) \(tagId)"
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
@@ -367,10 +375,14 @@ public enum ChatCommand {
case .testStorageEncryption: return "testStorageEncryption"
case .apiSaveSettings: return "apiSaveSettings"
case .apiGetSettings: return "apiGetSettings"
case .apiGetChatTags: return "apiGetChatTags"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
case .apiSendMessages: return "apiSendMessages"
case .apiCreateChatTag: return "apiCreateChatTag"
case .apiTagChat: return "apiTagChat"
case .apiUntagChat: return "apiUntagChat"
case .apiCreateChatItems: return "apiCreateChatItems"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
@@ -564,6 +576,7 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case chatTags(user: UserRef, userTags: [ChatTag])
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
case serverOperatorConditions(conditions: ServerOperatorConditions)
@@ -590,6 +603,8 @@ public enum ChatResponse: Decodable, Error {
case contactCode(user: UserRef, contact: Contact, connectionCode: String)
case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String)
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64])
case chatUntagged(user: UserRef, userTags: [ChatTag], chatTags: [Int64])
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
@@ -741,6 +756,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return "chatSuspended"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .chatTags: return "chatTags"
case .chatItemInfo: return "chatItemInfo"
case .serverTestResult: return "serverTestResult"
case .serverOperatorConditions: return "serverOperators"
@@ -767,6 +783,8 @@ public enum ChatResponse: Decodable, Error {
case .contactCode: return "contactCode"
case .groupMemberCode: return "groupMemberCode"
case .connectionVerified: return "connectionVerified"
case .tagsUpdated: return "tagsUpdated"
case .chatUntagged: return "chatUntagged"
case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .connectionUserChanged: return "connectionUserChanged"
@@ -914,6 +932,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
@@ -942,6 +961,8 @@ public enum ChatResponse: Decodable, Error {
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))")
case let .chatUntagged(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))")
case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))")
@@ -1172,6 +1193,16 @@ public enum ChatPagination {
}
}
public struct ChatTagData: Encodable {
public var emoji: String?
public var text: String
public init(emoji: String?, text: String) {
self.emoji = emoji
self.text = text
}
}
public struct ComposedMessage: Encodable {
public var fileSource: CryptoFile?
var quotedItemId: Int64?
+34 -1
View File
@@ -1334,6 +1334,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var contactCard: Bool {
switch self {
case let .direct(contact): contact.activeConn == nil && contact.profile.contactLink != nil && contact.active
default: false
}
}
public var groupInfo: GroupInfo? {
switch self {
case let .group(groupInfo): return groupInfo
@@ -1444,6 +1451,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
default: return nil
}
}
public var chatTags: [Int64]? {
switch self {
case let .direct(contact): return contact.chatTags
case let .group(groupInfo): return groupInfo.chatTags
default: return nil
}
}
var createdAt: Date {
switch self {
@@ -1545,6 +1560,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatTags: [Int64]
public var uiThemes: ThemeModeOverrides?
public var chatDeleted: Bool
@@ -1615,6 +1631,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false,
chatTags: [],
chatDeleted: false
)
}
@@ -1910,6 +1927,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var fullName: String { get { groupProfile.fullName } }
public var image: String? { get { groupProfile.image } }
public var localAlias: String { "" }
public var chatTags: [Int64]
public var isOwner: Bool {
return membership.memberRole == .owner && membership.memberCurrent
@@ -1932,7 +1950,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now
updatedAt: .now,
chatTags: []
)
}
@@ -4210,6 +4229,20 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
}
}
public struct ChatTag: Decodable, Hashable {
public var chatTagId: Int64
public var chatTagText: String
public var chatTagEmoji: String?
public var id: Int64 { chatTagId }
public init(chatTagId: Int64, chatTagText: String, chatTagEmoji: String?) {
self.chatTagId = chatTagId
self.chatTagText = chatTagText
self.chatTagEmoji = chatTagEmoji
}
}
public struct ChatItemInfo: Decodable, Hashable {
public var itemVersions: [ChatItemVersion]
public var memberDeliveryStatuses: [MemberDeliveryStatus]?
@@ -1,10 +1,11 @@
package chat.simplex.common.platform
import android.util.Log
import chat.simplex.common.model.ChatController.appPrefs
actual object Log {
actual fun d(tag: String, text: String) = Log.d(tag, text).run{}
actual fun e(tag: String, text: String) = Log.e(tag, text).run{}
actual fun i(tag: String, text: String) = Log.i(tag, text).run{}
actual fun w(tag: String, text: String) = Log.w(tag, text).run{}
actual fun d(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.DEBUG && appPrefs.developerTools.get()) Log.d(tag, text) }
actual fun e(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.ERROR || !appPrefs.developerTools.get()) Log.e(tag, text) }
actual fun i(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.INFO && appPrefs.developerTools.get()) Log.i(tag, text) }
actual fun w(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.WARNING || !appPrefs.developerTools.get()) Log.w(tag, text) }
}
@@ -132,6 +132,7 @@ class AppPreferences {
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val chatStopped = mkBoolPreference(SHARED_PREFS_CHAT_STOPPED, false)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val logLevel = mkEnumPreference(SHARED_PREFS_LOG_LEVEL, LogLevel.WARNING) { LogLevel.entries.firstOrNull { it.name == this } }
val showInternalErrors = mkBoolPreference(SHARED_PREFS_SHOW_INTERNAL_ERRORS, false)
val showSlowApiCalls = mkBoolPreference(SHARED_PREFS_SHOW_SLOW_API_CALLS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
@@ -393,6 +394,7 @@ class AppPreferences {
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_LOG_LEVEL = "LogLevel"
private const val SHARED_PREFS_SHOW_INTERNAL_ERRORS = "ShowInternalErrors"
private const val SHARED_PREFS_SHOW_SLOW_API_CALLS = "ShowSlowApiCalls"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
@@ -2,6 +2,10 @@ package chat.simplex.common.platform
const val TAG = "SIMPLEX"
enum class LogLevel {
DEBUG, INFO, WARNING, ERROR
}
expect object Log {
fun d(tag: String, text: String)
fun e(tag: String, text: String)
@@ -131,26 +131,14 @@ fun ChatInfoView(
},
syncContactConnection = {
withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false)
connStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
syncContactConnection(chatRh, contact, connStats, force = false)
close.invoke()
}
},
syncContactConnectionForce = {
showSyncConnectionForceAlert(syncConnectionForce = {
withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true)
connStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
syncContactConnection(chatRh, contact, connStats, force = true)
close.invoke()
}
})
@@ -189,6 +177,16 @@ fun ChatInfoView(
}
}
suspend fun syncContactConnection(rhId: Long?, contact: Contact, connectionStats: MutableState<ConnectionStats?>, force: Boolean) {
val cStats = chatModel.controller.apiSyncContactRatchet(rhId, contact.contactId, force = force)
connectionStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(rhId, contact, cStats)
}
}
}
sealed class SendReceipts {
object Yes: SendReceipts()
object No: SendReceipts()
@@ -505,7 +503,7 @@ fun ChatInfoLayout(
currentUser: User,
sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit,
connStats: State<ConnectionStats?>,
connStats: MutableState<ConnectionStats?>,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
@@ -553,8 +551,8 @@ fun ChatInfoLayout(
verticalAlignment = Alignment.CenterVertically
) {
SearchButton(modifier = Modifier.fillMaxWidth(0.25f), chat, contact, close, onSearchClicked)
AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact)
VideoButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact)
AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact, connStats)
VideoButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, connStats)
MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, contact)
}
}
@@ -825,12 +823,14 @@ fun MuteButton(
fun AudioCallButton(
modifier: Modifier,
chat: Chat,
contact: Contact
contact: Contact,
connectionStats: MutableState<ConnectionStats?>
) {
CallButton(
modifier = modifier,
chat,
contact,
connectionStats,
icon = painterResource(MR.images.ic_call),
title = generalGetString(MR.strings.info_view_call_button),
mediaType = CallMediaType.Audio
@@ -841,12 +841,14 @@ fun AudioCallButton(
fun VideoButton(
modifier: Modifier,
chat: Chat,
contact: Contact
contact: Contact,
connectionStats: MutableState<ConnectionStats?>
) {
CallButton(
modifier = modifier,
chat,
contact,
connectionStats,
icon = painterResource(MR.images.ic_videocam),
title = generalGetString(MR.strings.info_view_video_button),
mediaType = CallMediaType.Video
@@ -858,6 +860,7 @@ fun CallButton(
modifier: Modifier,
chat: Chat,
contact: Contact,
connectionStats: MutableState<ConnectionStats?>,
icon: Painter,
title: String,
mediaType: CallMediaType
@@ -879,7 +882,23 @@ fun CallButton(
disabledLook = !canCall,
onClick =
when {
canCall -> { { startChatCall(chat.remoteHostId, chat.chatInfo, mediaType) } }
canCall -> { {
val connStats = connectionStats.value
if (connStats != null) {
if (connStats.ratchetSyncState == RatchetSyncState.Ok) {
startChatCall(chat.remoteHostId, chat.chatInfo, mediaType)
} else if (connStats.ratchetSyncAllowed) {
showFixConnectionAlert(syncConnection = {
withBGApi { syncContactConnection(chat.remoteHostId, contact, connectionStats, force = false) }
})
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_call_contact_alert_title),
generalGetString(MR.strings.encryption_renegotiation_in_progress)
)
}
}
} }
contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } }
!contact.active -> { { showCantCallContactDeletedAlert() } }
!contact.ready -> { { showCantCallContactConnectingAlert() } }
@@ -1265,6 +1284,15 @@ fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) {
)
}
fun showFixConnectionAlert(syncConnection: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.sync_connection_question),
text = generalGetString(MR.strings.sync_connection_desc),
confirmText = generalGetString(MR.strings.sync_connection_confirm),
onConfirm = syncConnection,
)
}
fun queueInfoText(info: Pair<RcvMsgInfo?, ServerQueueInfo>): String {
val (rcvMsgInfo, qInfo) = info
val msgInfo: String = if (rcvMsgInfo != null) json.encodeToString(rcvMsgInfo) else generalGetString(MR.strings.message_queue_info_none)
@@ -296,6 +296,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
SectionBottomSpacer()
SectionBottomSpacer()
}
}
@@ -309,6 +310,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
QuotedMsgView(qi)
}
SectionBottomSpacer()
SectionBottomSpacer()
}
}
@@ -324,6 +326,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
ForwardedFromView(forwardedFromItem)
}
SectionBottomSpacer()
SectionBottomSpacer()
}
}
@@ -395,6 +398,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
SectionBottomSpacer()
SectionBottomSpacer()
}
}
@@ -433,12 +437,11 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
Column {
if (numTabs() > 1) {
Column(
Box(
Modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
.fillMaxHeight()
) {
Column(Modifier.weight(1f)) {
Column {
when (val sel = selection.value) {
is CIInfoTab.Delivery -> {
DeliveryTab(sel.memberDeliveryStatuses)
@@ -479,7 +482,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.offset(x = 0.dp, y = if (oneHandUI.value) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)) {
Box(Modifier.align(Alignment.BottomCenter).navigationBarsPadding().offset(x = 0.dp, y = if (oneHandUI.value) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)) {
TabRow(
selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class },
Modifier.height(AppBarHeight * fontSizeSqrtMultiplier),
@@ -665,13 +665,18 @@ fun ChatLayout(
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chatInfo != null) {
Box(Modifier.fillMaxSize()) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
)
// disables scrolling to top of chat item on click inside the bubble
CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
}) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
)
}
}
}
Box(
@@ -939,6 +944,7 @@ fun BoxScope.ChatItemsList(
linkMode: SimplexLinkMode,
selectedChatItems: MutableState<Set<Long>?>,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
showChatInfo: () -> Unit,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
@@ -984,6 +990,7 @@ fun BoxScope.ChatItemsList(
})
val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val loadingMoreItems = remember { mutableStateOf(false) }
val animatedScrollingInProgress = remember { mutableStateOf(false) }
val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf<Long>() }
if (!loadingMoreItems.value) {
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
@@ -1004,7 +1011,7 @@ fun BoxScope.ChatItemsList(
val chatInfoUpdated = rememberUpdatedState(chatInfo)
val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
val scope = rememberCoroutineScope()
val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) }
val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) }
val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) }
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo)
@@ -1065,7 +1072,7 @@ fun BoxScope.ChatItemsList(
highlightedItems.value = setOf()
}
}
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@@ -1314,7 +1321,7 @@ fun BoxScope.ChatItemsList(
}
}
}
FloatingButtons(loadingMoreItems, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState)
FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState)
LaunchedEffect(Unit) {
@@ -1323,6 +1330,15 @@ fun BoxScope.ChatItemsList(
chatViewScrollState.value = it
}
}
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.filter { !it }
.collect {
if (animatedScrollingInProgress.value) {
animatedScrollingInProgress.value = false
}
}
}
}
@Composable
@@ -1400,6 +1416,7 @@ private fun NotifyChatListOnFinishingComposition(
@Composable
fun BoxScope.FloatingButtons(
loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
mergedItems: State<MergedItems>,
unreadCount: State<Int>,
maxHeight: State<Int>,
@@ -1439,8 +1456,14 @@ fun BoxScope.FloatingButtons(
bottomUnreadCount,
showBottomButtonWithCounter,
showBottomButtonWithArrow,
animatedScrollingInProgress,
composeViewHeight,
onClick = { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } } }
onClick = {
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) }
}
}
)
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
@@ -1451,11 +1474,15 @@ fun BoxScope.FloatingButtons(
TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd),
topUnreadCount,
animatedScrollingInProgress,
onClick = {
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) {
// scroll to the top unread item
scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } }
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) }
}
}
},
onLongClick = { showDropDown.value = true }
@@ -1595,10 +1622,11 @@ fun MemberImage(member: GroupMember) {
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: State<Int>,
animatedScrollingInProgress: State<Boolean>,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
if (unreadCount.value > 0) {
if (remember { derivedStateOf { unreadCount.value > 0 && !animatedScrollingInProgress.value } }.value) {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
@@ -1839,6 +1867,7 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State<Int>, de
private fun scrollToItem(
searchValue: State<String>,
loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
highlightedItems: MutableState<Set<Long>>,
chatInfo: State<ChatInfo>,
maxHeight: State<Int>,
@@ -1876,6 +1905,7 @@ private fun scrollToItem(
highlightedItems.value = setOf(itemId)
} else {
withContext(scope.coroutineContext) {
animatedScrollingInProgress.value = true
listState.value.animateScrollToItem(min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value)
highlightedItems.value = setOf(itemId)
}
@@ -1937,10 +1967,11 @@ private fun BoxScope.BottomEndFloatingButton(
unreadCount: State<Int>,
showButtonWithCounter: State<Boolean>,
showButtonWithArrow: State<Boolean>,
animatedScrollingInProgress: State<Boolean>,
composeViewHeight: State<Dp>,
onClick: () -> Unit
) = when {
showButtonWithCounter.value -> {
showButtonWithCounter.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
@@ -1954,7 +1985,7 @@ private fun BoxScope.BottomEndFloatingButton(
)
}
}
showButtonWithArrow.value -> {
showButtonWithArrow.value && !animatedScrollingInProgress.value -> {
FloatingActionButton(
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
@@ -8,8 +8,6 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import java.net.URI
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -58,6 +56,19 @@ fun GroupMemberInfoView(
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
fun syncMemberConnection() {
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
connStats.value = r.second
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
}
if (chat != null) {
val newRole = remember { mutableStateOf(member.memberRole) }
GroupMemberInfoLayout(
@@ -78,19 +89,30 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
withBGApi {
progressIndicator = true
val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId)
if (memberContact != null) {
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
withChats {
addChat(memberChat)
openLoadedChat(memberChat)
if (connectionStats != null) {
if (connectionStats.ratchetSyncState == RatchetSyncState.Ok) {
withBGApi {
progressIndicator = true
val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId)
if (memberContact != null) {
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
withChats {
addChat(memberChat)
openLoadedChat(memberChat)
}
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
}
progressIndicator = false
}
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
} else if (connectionStats.ratchetSyncAllowed) {
showFixConnectionAlert(syncConnection = { syncMemberConnection() })
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_send_message_to_member_alert_title),
generalGetString(MR.strings.encryption_renegotiation_in_progress)
)
}
progressIndicator = false
}
},
connectViaAddress = { connReqUri ->
@@ -149,16 +171,7 @@ fun GroupMemberInfoView(
})
},
syncMemberConnection = {
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
connStats.value = r.second
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
syncMemberConnection()
},
syncMemberConnectionForce = {
showSyncConnectionForceAlert(syncConnectionForce = {
@@ -335,9 +348,20 @@ fun GroupMemberInfoLayout(
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
val knownContactConnectionStats: MutableState<ConnectionStats?> = remember { mutableStateOf(null) }
LaunchedEffect(contact.contactId) {
withBGApi {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId)
if (contactInfo != null) {
knownContactConnectionStats.value = contactInfo.first
}
}
}
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) })
AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact)
VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact)
AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats)
VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group
@@ -24,12 +24,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.currentUser
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlin.math.*
@@ -51,6 +51,12 @@ fun chatEventText(eventText: String, ts: String): AnnotatedString =
withStyle(chatEventStyle) { append("$eventText $ts") }
}
data class ChatItemReactionMenuItem (
val name: String,
val image: String?,
val onClick: (() -> Unit)?
)
@Composable
fun ChatItemView(
rhId: Long?,
@@ -87,6 +93,7 @@ fun ChatItemView(
showItemDetails: (ChatInfo, ChatItem) -> Unit,
reveal: (Boolean) -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
showChatInfo: () -> Unit,
developerTools: Boolean,
showViaProxy: Boolean,
showTimestamp: Boolean,
@@ -120,7 +127,7 @@ fun ChatItemView(
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true)) {
cItem.reactions.forEach { r ->
val showReactionMenu = remember { mutableStateOf(false) }
val reactionMembers = remember { mutableStateOf(emptyList<MemberReaction>()) }
val reactionMenuItems = remember { mutableStateOf(emptyList<ChatItemReactionMenuItem>()) }
val interactionSource = remember { MutableInteractionSource() }
val enterInteraction = remember { HoverInteraction.Enter() }
KeyChangeEffect(highlighted.value) {
@@ -134,18 +141,39 @@ fun ChatItemView(
var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp))
if (cInfo.featureEnabled(ChatFeature.Reactions)) {
fun showReactionsMenu() {
if (cInfo is ChatInfo.Group) {
withBGApi {
try {
val members = controller.apiGetReactionMembers(rhId, cInfo.groupInfo.groupId, cItem.id, r.reaction)
if (members != null) {
showReactionMenu.value = true
reactionMembers.value = members
when (cInfo) {
is ChatInfo.Group -> {
withBGApi {
try {
val members = controller.apiGetReactionMembers(rhId, cInfo.groupInfo.groupId, cItem.id, r.reaction)
if (members != null) {
showReactionMenu.value = true
reactionMenuItems.value = members.map {
val enabled = cInfo.groupInfo.membership.groupMemberId != it.groupMember.groupMemberId
val click = if (enabled) ({ showMemberInfo(cInfo.groupInfo, it.groupMember) }) else null
ChatItemReactionMenuItem(it.groupMember.displayName, it.groupMember.image, click)
}
}
} catch (e: Exception) {
Log.d(TAG, "chatItemView ChatItemReactions onLongClick: unexpected exception: ${e.stackTraceToString()}")
}
} catch (e: Exception) {
Log.d(TAG, "hatItemView ChatItemReactions onLongClick: unexpected exception: ${e.stackTraceToString()}")
}
}
is ChatInfo.Direct -> {
showReactionMenu.value = true
val reactions = mutableListOf<ChatItemReactionMenuItem>()
if (!r.userReacted || r.totalReacted > 1) {
val contact = cInfo.contact
reactions.add(ChatItemReactionMenuItem(contact.displayName, contact.image, showChatInfo))
}
if (r.userReacted) {
reactions.add(ChatItemReactionMenuItem(generalGetString(MR.strings.sender_you_pronoun), currentUser.value?.image, null))
}
reactionMenuItems.value = reactions
}
else -> {}
}
}
modifier = modifier
@@ -166,19 +194,19 @@ fun ChatItemView(
Row(modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) {
ReactionIcon(r.reaction.text, fontSize = 12.sp)
DefaultDropdownMenu(showMenu = showReactionMenu) {
reactionMembers.value.forEach { m ->
reactionMenuItems.value.forEach { m ->
ItemAction(
text = m.groupMember.displayName,
composable = { ProfileImage(44.dp, m.groupMember.image) },
text = m.name,
composable = { ProfileImage(44.dp, m.image) },
onClick = {
if (cInfo is ChatInfo.Group && cInfo.groupInfo.membership.groupMemberId != m.groupMember.groupMemberId) {
showMemberInfo(cInfo.groupInfo, m.groupMember)
showReactionMenu.value = false
} else {
val click = m.onClick
if (click != null) {
click()
showReactionMenu.value = false
}
},
lineLimit = 1
lineLimit = 1,
color = if (m.onClick == null) MaterialTheme.colors.secondary else MenuTextColor
)
}
}
@@ -1188,6 +1216,7 @@ fun PreviewChatItemView(
showItemDetails = { _, _ -> },
reveal = {},
showMemberInfo = { _, _ ->},
showChatInfo = {},
developerTools = false,
showViaProxy = false,
showTimestamp = true,
@@ -1233,6 +1262,7 @@ fun PreviewChatItemViewDeletedContent() {
showItemDetails = { _, _ -> },
reveal = {},
showMemberInfo = { _, _ ->},
showChatInfo = {},
developerTools = false,
showViaProxy = false,
preview = true,
@@ -30,6 +30,7 @@ import kotlinx.datetime.*
import java.io.*
import java.net.URI
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
@@ -44,11 +45,14 @@ fun DatabaseView() {
val chatArchiveFile = remember { mutableStateOf<String?>(null) }
val stopped = remember { m.chatRunning }.value == false
val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? ->
val file = chatArchiveFile.value
if (file != null && to != null) {
copyFileToFile(File(file), to) {
chatArchiveFile.value = null
}
val archive = chatArchiveFile.value
if (archive != null && to != null) {
copyFileToFile(File(archive), to) {}
}
// delete no matter the database was exported or canceled the export process
if (archive != null) {
File(archive).delete()
chatArchiveFile.value = null
}
}
val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) }
@@ -680,6 +684,8 @@ suspend fun importArchive(
} finally {
File(archivePath).delete()
}
} else {
progressIndicator.value = false
}
return false
}
@@ -691,14 +697,15 @@ private fun saveArchiveFromURI(importedArchiveURI: URI): String? {
if (inputStream != null && archiveName != null) {
val archivePath = "$databaseExportDir${File.separator}$archiveName"
val destFile = File(archivePath)
Files.copy(inputStream, destFile.toPath())
Files.copy(inputStream, destFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
archivePath
} else {
Log.e(TAG, "saveArchiveFromURI null inputStream")
null
}
} catch (e: Exception) {
Log.e(TAG, "saveArchiveFromURI error: ${e.message}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_database), e.stackTraceToString())
Log.e(TAG, "saveArchiveFromURI error: ${e.stackTraceToString()}")
null
}
}
@@ -14,6 +14,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -44,6 +45,12 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) ->
if (devTools.value) {
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.developer_options_section).uppercase()) {
SettingsActionItemWithContent(painterResource(MR.images.ic_breaking_news), stringResource(MR.strings.debug_logs)) {
DefaultSwitch(
checked = remember { appPrefs.logLevel.state }.value <= LogLevel.DEBUG,
onCheckedChange = { appPrefs.logLevel.set(if (it) LogLevel.DEBUG else LogLevel.WARNING) }
)
}
SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
if (appPlatform.isDesktop) {
TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked ->
@@ -735,7 +735,10 @@ private fun ConditionsAppliedToOtherOperatorsText(userServers: List<UserOperator
}
if (otherOperatorsToApply.value.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_will_be_applied)
ReadableText(
MR.strings.operator_conditions_will_be_applied,
args = otherOperatorsToApply.value.joinToString(", ") { it.legalName_ }
)
}
}
@@ -524,6 +524,10 @@
<string name="sync_connection_force_question">Renegotiate encryption?</string>
<string name="sync_connection_force_desc">The encryption is working and the new encryption agreement is not required. It may result in connection errors!</string>
<string name="sync_connection_force_confirm">Renegotiate</string>
<string name="sync_connection_question">Fix connection?</string>
<string name="sync_connection_desc">Connection requires encryption renegotiation.</string>
<string name="sync_connection_confirm">Fix</string>
<string name="encryption_renegotiation_in_progress">Encryption renegotiation in progress.</string>
<string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string>
@@ -903,6 +907,7 @@
<string name="show_dev_options">Show:</string>
<string name="hide_dev_options">Hide:</string>
<string name="show_developer_options">Show developer options</string>
<string name="debug_logs">Enable logs</string>
<string name="developer_options">Database IDs and Transport isolation option.</string>
<string name="developer_options_section">Developer options</string>
<string name="show_internal_errors">Show internal errors</string>
@@ -1334,6 +1339,7 @@
<string name="chat_database_exported_migrate">You may migrate the exported database.</string>
<string name="chat_database_exported_not_all_files">Some file(s) were not exported</string>
<string name="chat_database_exported_continue">Continue</string>
<string name="error_saving_database">Error saving database</string>
<!-- DatabaseEncryptionView.kt -->
<string name="save_passphrase_in_keychain">Save passphrase in Keystore</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22px" viewBox="0 -960 960 960" width="22px" fill="#5f6368"><path d="M262.5-285q11.5 0 20.25-8.5t8.75-20q0-11.5-8.75-20.25t-20.25-8.75q-11.5 0-20 8.75T234-313.5q0 11.5 8.5 20t20 8.5Zm-29-168.5H291v-227h-57.5v227Zm217.5 174h275.5V-337H451v57.5Zm0-174h275.5V-511H451v57.5Zm0-169.5h275.5v-57.5H451v57.5ZM134.5-124.5q-22.97 0-40.23-17.27Q77-159.03 77-182v-596q0-22.97 17.27-40.23 17.26-17.27 40.23-17.27h691q22.97 0 40.23 17.27Q883-800.97 883-778v596q0 22.97-17.27 40.23-17.26 17.27-40.23 17.27h-691Zm0-57.5h691v-596h-691v596Zm0 0v-596 596Z"/></svg>

After

Width:  |  Height:  |  Size: 592 B

@@ -67,7 +67,7 @@ object NtfManager {
ntf.second.close()
} catch (e: Exception) {
// Can be java.lang.UnsupportedOperationException, for example. May do nothing
println("Failed to close notification: ${e.stackTraceToString()}")
Log.e(TAG, "Failed to close notification: ${e.stackTraceToString()}")
}*/
}
}
@@ -85,7 +85,8 @@ object NtfManager {
}
fun cancelAllNotifications() {
// prevNtfs.forEach { try { it.second.close() } catch (e: Exception) { println("Failed to close notification: ${e.stackTraceToString()}") } }
// prevNtfs.forEach { try { it.second.close() } catch (e: Exception) { Log.e(TAG, "Failed to close notification: ${e
// .stackTraceToString()}") } }
withBGApi {
prevNtfsMutex.withLock {
prevNtfs.clear()
@@ -153,7 +154,7 @@ object NtfManager {
ImageIO.write(icon.toAwtImage(), "PNG", newFile.outputStream())
newFile.absolutePath
} catch (e: Exception) {
println("Failed to write an icon to tmpDir: ${e.stackTraceToString()}")
Log.e(TAG, "Failed to write an icon to tmpDir: ${e.stackTraceToString()}")
null
}
} else null
@@ -1,8 +1,10 @@
package chat.simplex.common.platform
import chat.simplex.common.model.ChatController.appPrefs
actual object Log {
actual fun d(tag: String, text: String) = println("D: $text")
actual fun e(tag: String, text: String) = println("E: $text")
actual fun i(tag: String, text: String) = println("I: $text")
actual fun w(tag: String, text: String) = println("W: $text")
actual fun d(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.DEBUG && appPrefs.developerTools.get()) println("D: $text") }
actual fun e(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.ERROR || !appPrefs.developerTools.get()) println("E: $text") }
actual fun i(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.INFO && appPrefs.developerTools.get()) println("I: $text") }
actual fun w(tag: String, text: String) { if (appPrefs.logLevel.get() <= LogLevel.WARNING || !appPrefs.developerTools.get()) println("W: $text") }
}
+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.2
android.version_code=259
android.version_name=6.2.1
android.version_code=261
desktop.version_name=6.2
desktop.version_code=82
desktop.version_name=6.2.1
desktop.version_code=83
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
@@ -68,7 +68,7 @@ So, for Android we can now deliver instant message notifications without comprom
Please let us know what needs to be improved - it's only the first version of instant notifications for Android!
## Our iOS approach has one trade-off
## iOS notifications require a server
iOS is much more protective of what apps are allowed to run on the devices, and the solution that worked on Android is not viable on iOS.
@@ -1,23 +1,88 @@
---
layout: layouts/article.html
title: "Servers operated by Flux - true privacy and decentralization for all users"
title: "SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps"
date: 2024-12-10
# previewBody: blog_previews/20241210.html
# image: images/simplexonflux.png
# imageWide: true
draft: true
previewBody: blog_previews/20241210.html
image: images/20241210-operators-1.png
permalink: "/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"
---
# SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps
**Will be published:** Dec 10, 2024
**Published:** Dec 10, 2024
This is a placeholder page for the upcoming v6.2 release announcement!
What's new in v6.2:
- Preset servers are now operated by two companies - SimpleX Chat and Flux. Read [this post](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md).
- Business chats to provide support from your business to users of SimpleX network. Read [this page](../docs/BUSINESS.md).
- and more!
- [SimpleX Chat and Flux](#simplex-chat-and-flux-improve-metadata-privacy-in-simplex-network) improve metadata privacy in SimpleX network.
- [Business chats](#business-chats) to provide support from your business to users of SimpleX network.
- [Better user experience](#better-user-experience): open on the first unread, jump to quoted messages, see who reacted.
- [Improving notifications in iOS app](#improving-notifications-in-ios-app).
## What's new in v6.2
### SimpleX Chat and Flux improve metadata privacy in SimpleX network
<img src="./images/20241210-operators-1.png" width=288 class="float-to-right"> <img src="./images/20241210-operators-2.png" width=288 class="float-to-right">
SimpleX Chat and [Flux](https://runonflux.com) (Influx Technology Limited) made an agreement to include messaging and file servers operated by Flux into the app.
SimpleX network is decentralized by design, but in the users of the previous app versions had to find other servers online or host servers themselves to use any other servers than operated by us.
Now all users can choose between servers of two companies, use both of them, and continue using any other servers they host or available online.
To use Flux servers enable them when the app offers it, or at any point later via Network & servers settings in the app.
When both SimpleX Chat and Flux servers are enabled, the app will use servers of both operators in each connection to receive messages and for [private message routing](./20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md), increasing metadata privacy for all users.
Read more about why SimpleX network benefits from multiple operators in [our previous post](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md).
You can also read about our plan [how network operators will make money](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-04-26-commercial-model.md), while continuing to protect users privacy, based on network design rather than on trust to operators, and without any cryptocurrency emission.
### Business chats
<img src="./images/20241210-business.png" width=288 class="float-to-right">
We use SimpleX Chat to provide support to SimpleX Chat users, and we also see some other companies offering SimpleX Chat as a support channel.
One of the problem of providing support via general purpose messengers is that the customers don't see who they talk to, as they can in all dedicated support systems.
It is not possible in most messengers, including SimpleX Chat prior to v6.2 - every new customer joins a one-to-one conversation, where the customers see that they talk to a company, not knowing who they talk to, and if it's a bot or a human.
The new business chats in SimpleX Chat solve this problem: to use them enable the toggle under the contact address in your chat profile. It is safe to do, and you can always toggle it off, if needed - the address itself does not change.
Once you do it, the app will be creating a new business chat with each connecting customer where multiple people can participate. Business chat is a hybrid of one-to-one and group conversation. In the list of chats you will see customer names and avatars, and the customer will see your business name and avatar, like with one-to-one conversations. But inside it works as a group, allowing customer to see who sent the message, and allowing you to add other participants from the business side, for delegation and escalation of customer questions.
This can be done manually, or you can automate these conversations using bots that can answer some customer questions and then add a human to the conversation when appropriate or requested by the customer. We will be offering more bot-related features to the app and a simpler way to program bots very soon - watch our announcements.
### Better user experience
<img src="./images/20241210-reactions.png" width=288 class="float-to-right">
**Chat navigation**
This has been a long-standing complaint from the users: *why does the app opens conversations on the last message, and not on the first unread message*?
Android and desktop apps now open the chat on the first unread message. It will soon be done in the iOS app too.
Also, the app can scroll to the replied message anywhere in the conversation (when you tap it), even if it was sent a very long time ago.
**See who reacted!**
This is a small but important change - you can now see who reacted to your messages!
### Improving notifications in iOS app
iOS notifications in a decentralized network is a complex problems. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough.
We solved several problems of notification delivery in this release:
- messaging servers no longer lose notifications while notification servers are restarted.
- Apple can drop notifications while your device is offline - about 15-20% of notifications are dropped because of it. The servers and the new version of the app work around this problem by delivering several last notifications, to show notifications correctly even when Apple drops them.
With these changes the iOS notifications remained as private and secure as before. The notifications only contain metadata, without the actual messages, and even the metadata is end-to-end encrypted between SimpleX notification servers and the client device, inaccessible to Apple push notification servers.
There are two remaining problems we will solve soon:
- iOS only allows to use 25mb of device memory when processing notifications in the background. This limit didn't change for many years, and it is challenging for decentralized design. If the app uses more memory, iOS kills it and the notification is not shown – approximately 10% of notifications can be lost because of that.
- for notifications to work, the app communicates with the notification server. If the user puts the app in background too quickly, the app may fail to enable notification for the new contacts. We plan to change clients and servers to delegate this task to messaging servers, to remove the need for this additional communication entirely, without any impact on privacy and security. This will happen early next year.
## SimpleX network
+10 -1
View File
@@ -1,6 +1,15 @@
# Blog
Nov 25, 2025 [Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md)
Dec 10, 2024 [SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
- SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network.
- Business chats for better privacy and support of your customers.
- Better user experience: open on the first unread, jump to quoted messages, see who reacted.
- Improving notifications in iOS app.
--
Nov 25, 2024 [Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md)
- Welcome, Flux - the new servers in v6.2-beta.1!
- What's the problem?
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

+4 -4
View File
@@ -40,10 +40,10 @@ if [ ! -f ../appimagetool-x86_64.AppImage ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ../appimagetool-x86_64.AppImage
chmod +x ../appimagetool-x86_64.AppImage
fi
if [ ! -f ../runtime-fuse3-x86_64 ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/old/runtime-fuse3-x86_64 -O ../runtime-fuse3-x86_64
chmod +x ../runtime-fuse3-x86_64
if [ ! -f ../runtime-x86_64 ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64 -O ../runtime-x86_64
chmod +x ../runtime-x86_64
fi
../appimagetool-x86_64.AppImage --runtime-file ../runtime-fuse3-x86_64 .
../appimagetool-x86_64.AppImage --runtime-file ../runtime-x86_64 .
mv *imple*.AppImage ../../
+1
View File
@@ -155,6 +155,7 @@ library
Simplex.Chat.Migrations.M20241125_indexes
Simplex.Chat.Migrations.M20241128_business_chats
Simplex.Chat.Migrations.M20241205_business_chat_members
Simplex.Chat.Migrations.M20241206_chat_tags
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
+33
View File
@@ -848,6 +848,9 @@ processChatCommand' vr = \case
. sortOn (timeAvg . snd)
. M.assocs
<$> withConnection st (readTVarIO . DB.slow)
APIGetChatTags userId -> withUserId' userId $ \user -> do
tags <- withFastStore' (`getUserChatTags` user)
pure $ CRChatTags user tags
APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do
(errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query)
unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs)
@@ -895,6 +898,32 @@ processChatCommand' vr = \case
CTLocal -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APICreateChatTag (ChatRef cType chatId) (ChatTagData emoji text) -> withUser $ \user -> withFastStore $ \db -> case cType of
CTDirect -> do
tId <- liftIO $ createChatTag db user emoji text
liftIO $ tagDirectChat db chatId tId
CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db chatId)
CTGroup -> do
tId <- liftIO $ createChatTag db user emoji text
liftIO $ tagGroupChat db chatId tId
CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db chatId)
_ -> pure $ chatCmdError (Just user) "not supported"
APITagChat (ChatRef cType chatId) tId -> withUser $ \user -> withFastStore $ \db -> case cType of
CTDirect -> do
liftIO $ tagDirectChat db chatId tId
CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db chatId)
CTGroup -> do
liftIO $ tagGroupChat db chatId tId
CRTagsUpdated user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db chatId)
_ -> pure $ chatCmdError (Just user) "not supported"
APIUntagChat (ChatRef cType chatId) tId -> withUser $ \user -> withFastStore $ \db -> case cType of
CTDirect -> do
liftIO $ untagDirectChat db user chatId tId
CRChatUntagged user <$> liftIO (getUserChatTags db user) <*> liftIO (getDirectChatTags db chatId)
CTGroup -> do
liftIO $ untagGroupChat db user chatId tId
CRChatUntagged user <$> liftIO (getUserChatTags db user) <*> liftIO (getGroupChatTags db chatId)
_ -> pure $ chatCmdError (Just user) "not supported"
APICreateChatItems folderId cms -> withUser $ \user ->
createNoteFolderContentItems user folderId (L.map (,Nothing) cms)
APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of
@@ -8392,6 +8421,7 @@ chatCommandP =
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/sql slow" $> SlowSQLQueries,
"/_get tags " *> (APIGetChatTags <$> A.decimal),
"/_get chats "
*> ( APIGetChats
<$> A.decimal
@@ -8403,6 +8433,9 @@ chatCommandP =
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal),
"/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
"/_create tag " *> (APICreateChatTag <$> chatRefP <* A.space <*> jsonP),
"/_tag " *> (APITagChat <$> chatRefP <* A.space <*> A.decimal),
"/_untag " *> (APIUntagChat <$> chatRefP <* A.space <*> A.decimal),
"/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode),
+19
View File
@@ -294,11 +294,15 @@ data ChatCommand
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
| APIGetChatTags UserId
| APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
| APIGetChatItemInfo ChatRef ChatItemId
| APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage}
| APICreateChatTag ChatRef ChatTagData
| APITagChat ChatRef ChatTagId
| APIUntagChat ChatRef ChatTagId
| APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
@@ -587,6 +591,7 @@ data ChatResponse
| CRApiChats {user :: User, chats :: [AChat]}
| CRChats {chats :: [AChat]}
| CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo}
| CRChatTags {user :: User, userTags :: [ChatTag]}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
@@ -617,6 +622,8 @@ data ChatResponse
| CRContactCode {user :: User, contact :: Contact, connectionCode :: Text}
| CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text}
| CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text}
| CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]}
| CRChatUntagged {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]}
| CRNewChatItems {user :: User, chatItems :: [AChatItem]}
| CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]}
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
@@ -1068,6 +1075,16 @@ instance FromJSON ComposedMessage where
parseJSON invalid =
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
data ChatTagData = ChatTagData
{ emoji :: Text,
text :: Text
}
deriving (Show)
instance FromJSON ChatTagData where
parseJSON (J.Object v) = ChatTagData <$> v .: "emoji" <*> v .: "text"
parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid)
data NtfConn = NtfConn
{ user_ :: Maybe User,
connEntity_ :: Maybe ConnectionEntity,
@@ -1603,3 +1620,5 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig)
$(JQ.deriveToJSON defaultJSON ''ComposedMessage)
$(JQ.deriveToJSON defaultJSON ''ChatTagData)
@@ -0,0 +1,42 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241206_chat_tags where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241206_chat_tags :: Query
m20241206_chat_tags =
[sql|
CREATE TABLE chat_tags (
chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users,
chat_tag_text TEXT UNIQUE NOT NULL,
chat_tag_emoji TEXT UNIQUE NOT NULL
);
CREATE TABLE chat_tags_chats (
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, -- NULL for groups
group_id INTEGER REFERENCES groups ON DELETE CASCADE, -- NULL for contacts
chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE,
UNIQUE(chat_tag_id, group_id),
UNIQUE(chat_tag_id, contact_id)
);
CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id);
CREATE INDEX idx_chat_tags_chat_tag_id ON chat_tags(chat_tag_id);
CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id);
CREATE INDEX idx_chat_tags_user_id_chat_tag_id ON chat_tags(user_id, chat_tag_id);
|]
down_m20241206_chat_tags :: Query
down_m20241206_chat_tags =
[sql|
DROP INDEX idx_chat_tags_user_id;
DROP INDEX idx_chat_tags_chat_tag_id;
DROP INDEX idx_chat_tags_chats_chat_tag_id;
DROP INDEX idx_chat_tags_user_id_chat_tag_id;
DROP TABLE chat_tags_chats
DROP TABLE chat_tags;
|]
+1 -1
View File
@@ -111,7 +111,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
activeConn = Just conn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData}
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags = [], uiThemes, chatDeleted, customData}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ groupMemberId c = ExceptT $ do
+36 -1
View File
@@ -79,6 +79,9 @@ module Simplex.Chat.Store.Direct
setContactCustomData,
setContactUIThemes,
setContactChatDeleted,
tagDirectChat,
untagDirectChat,
getDirectChatTags,
)
where
@@ -251,6 +254,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p
chatTs = Just currentTs,
contactGroupMemberId = Nothing,
contactGrpInvSent = False,
chatTags = [],
uiThemes = Nothing,
chatDeleted = False,
customData = Nothing
@@ -819,6 +823,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
chatTs = Just createdAt,
contactGroupMemberId = Nothing,
contactGrpInvSent = False,
chatTags = [],
uiThemes = Nothing,
chatDeleted = False,
customData = Nothing
@@ -842,7 +847,10 @@ getContactIdByName db User {userId} cName =
DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ? AND deleted = 0" (userId, cName)
getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO Contact
getContact db vr user contactId = getContact_ db vr user contactId False
getContact db vr user contactId = do
ct <- getContact_ db vr user contactId False
chatTags <- liftIO $ getDirectChatTags db contactId
pure (ct :: Contact) {chatTags}
getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact
getContact_ db vr user@User {userId} contactId deleted =
@@ -1018,3 +1026,30 @@ setContactChatDeleted :: DB.Connection -> User -> Contact -> Bool -> IO ()
setContactChatDeleted db User {userId} Contact {contactId} chatDeleted = do
updatedAt <- getCurrentTime
DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (chatDeleted, updatedAt, userId, contactId)
tagDirectChat :: DB.Connection -> ContactId -> ChatTagId -> IO ()
tagDirectChat db contactId tId =
DB.execute
db
[sql|
INSERT INTO chat_tags_chats (contact_id, chat_tag_id)
VALUES (?,?)
|]
(contactId, tId)
untagDirectChat :: DB.Connection -> User -> ContactId -> ChatTagId -> IO ()
untagDirectChat db user contactId tId = do
untagDirectChat' db contactId tId
untagDirectChat' :: DB.Connection -> ContactId -> ChatTagId -> IO ()
untagDirectChat' db contactId tId =
DB.execute
db
[sql|
DELETE FROM chat_tags_chats
WHERE contact_id = ? AND chat_tag_id = ?
|]
(contactId, tId)
getDirectChatTags :: DB.Connection -> ContactId -> IO [ChatTagId]
getDirectChatTags db contactId = map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ?" (Only contactId)
+50 -13
View File
@@ -122,6 +122,9 @@ module Simplex.Chat.Store.Groups
updateUserMemberProfileSentAt,
setGroupCustomData,
setGroupUIThemes,
tagGroupChat,
untagGroupChat,
getGroupChatTags,
)
where
@@ -333,6 +336,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc
updatedAt = currentTs,
chatTs = Just currentTs,
userMemberProfileSentAt = Just currentTs,
chatTags = [],
uiThemes = Nothing,
customData = Nothing
}
@@ -401,6 +405,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
updatedAt = currentTs,
chatTs = Just currentTs,
userMemberProfileSentAt = Just currentTs,
chatTags = [],
uiThemes = Nothing,
customData = Nothing
},
@@ -1482,26 +1487,30 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName
updateGroupProfile db user g' p'
where
getGroupProfile =
ExceptT $ firstRow toGroupProfile (SEGroupNotFound groupId) $
DB.query
db
[sql|
ExceptT $
firstRow toGroupProfile (SEGroupNotFound groupId) $
DB.query
db
[sql|
SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences
FROM group_profiles gp
JOIN groups g ON gp.group_profile_id = g.group_profile_id
WHERE g.group_id = ?
|]
(Only groupId)
(Only groupId)
toGroupProfile (displayName, fullName, description, image, groupPreferences) =
GroupProfile {displayName, fullName, description, image, groupPreferences}
getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo
getGroupInfo db vr User {userId, userContactId} groupId =
ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $
DB.query
db
(groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?")
(groupId, userId, userContactId)
getGroupInfo db vr User {userId, userContactId} groupId = do
info <-
ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $
DB.query
db
(groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?")
(groupId, userId, userContactId)
chatTags <- liftIO $ getGroupChatTags db groupId
pure (info :: GroupInfo) {chatTags}
getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo)
getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do
@@ -2053,7 +2062,7 @@ createMemberContact
quotaErrCounter = 0
}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing}
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing}
getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation)
getMemberContact db vr user contactId = do
@@ -2090,7 +2099,7 @@ createMemberContactInvited
contactId <- createContactUpdateMember currentTs userPreferences
ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing}
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing}
m' = m {memberContactId = Just contactId}
pure (mCt', m')
where
@@ -2301,3 +2310,31 @@ setGroupUIThemes :: DB.Connection -> User -> GroupInfo -> Maybe UIThemeEntityOve
setGroupUIThemes db User {userId} GroupInfo {groupId} uiThemes = do
updatedAt <- getCurrentTime
DB.execute db "UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (uiThemes, updatedAt, userId, groupId)
tagGroupChat :: DB.Connection -> GroupId -> ChatTagId -> IO ()
tagGroupChat db groupId tId =
DB.execute
db
[sql|
INSERT INTO chat_tags_chats (group_id, chat_tag_id)
VALUES (?,?)
|]
(groupId, tId)
untagGroupChat :: DB.Connection -> User -> GroupId -> ChatTagId -> IO ()
untagGroupChat db user gId tId = do
untagGroupChat' db gId tId
untagGroupChat' :: DB.Connection -> GroupId -> ChatTagId -> IO ()
untagGroupChat' db groupId tId =
DB.execute
db
[sql|
DELETE FROM chat_tags_chats
WHERE group_id = ? AND chat_tag_id = ?
|]
(groupId, tId)
getGroupChatTags :: DB.Connection -> GroupId -> IO [ChatTagId]
getGroupChatTags db groupId =
map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ?" (Only groupId)
+3 -1
View File
@@ -119,6 +119,7 @@ import Simplex.Chat.Migrations.M20241027_server_operators
import Simplex.Chat.Migrations.M20241125_indexes
import Simplex.Chat.Migrations.M20241128_business_chats
import Simplex.Chat.Migrations.M20241205_business_chat_members
import Simplex.Chat.Migrations.M20241206_chat_tags
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -237,7 +238,8 @@ schemaMigrations =
("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators),
("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes),
("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats),
("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members)
("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members),
("20241206_chat_tags", m20241206_chat_tags, Just down_m20241206_chat_tags)
]
-- | The list of migrations in ascending order by date
+47 -2
View File
@@ -398,7 +398,7 @@ toContact vr user ((Only contactId :. (profileId, localDisplayName, viaGroup, di
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
incognito = maybe False connIncognito activeConn
mergedPreferences = contactUserPreferences user userPreferences preferences incognito
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData}
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags = [], uiThemes, chatDeleted, customData}
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
getProfileById db userId profileId =
@@ -559,7 +559,7 @@ toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName,
fullGroupPreferences = mergeGroupPreferences groupPreferences
groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences}
businessChat = toBusinessChatInfo businessRow
in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData}
in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags = [], uiThemes, customData}
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) =
@@ -592,3 +592,48 @@ groupInfoQuery =
JOIN group_members mu ON mu.group_id = g.group_id
JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
|]
createChatTag :: DB.Connection -> User -> Text -> Text -> IO ChatTagId
createChatTag db User {userId} emoji text = do
DB.execute
db
[sql|
INSERT INTO chat_tags (user_id, chat_tag_emoji, chat_tag_text)
VALUES (?,?,?)
|]
(userId, emoji, text)
insertedRowId db
deleteChatTag :: DB.Connection -> User -> ChatTagId -> IO ()
deleteChatTag db User {userId} tId =
DB.execute
db
[sql|
DELETE FROM chat_tags
WHERE user_id = ? AND chat_tag_id = ?
|]
(userId, tId)
getTagChatsCount :: DB.Connection -> ChatTagId -> IO Int
getTagChatsCount db tId =
fromOnly . head <$> DB.query db "SELECT COUNT(*) FROM chat_tags_chats WHERE chat_tag_id = ?" (Only tId)
deleteChatTagIfEmpty :: DB.Connection -> User -> ChatTagId -> IO ()
deleteChatTagIfEmpty db user ctId = do
tagChatsCount <- getTagChatsCount db ctId
when (tagChatsCount == 0) $ deleteChatTag db user ctId
getUserChatTags :: DB.Connection -> User -> IO [ChatTag]
getUserChatTags db User {userId} =
map toChatTag
<$> DB.query
db
[sql|
SELECT chat_tag_id, chat_tag_emoji, chat_tag_text
FROM chat_tags
WHERE user_id = ?
|]
(Only userId)
where
toChatTag :: (ChatTagId, Text, Text) -> ChatTag
toChatTag (chatTagId, chatTagEmoji, chatTagText) = ChatTag {chatTagId, chatTagEmoji, chatTagText}
+13
View File
@@ -160,6 +160,8 @@ type ContactId = Int64
type ProfileId = Int64
type ChatTagId = Int64
data Contact = Contact
{ contactId :: ContactId,
localDisplayName :: ContactName,
@@ -176,6 +178,7 @@ data Contact = Contact
chatTs :: Maybe UTCTime,
contactGroupMemberId :: Maybe GroupMemberId,
contactGrpInvSent :: Bool,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
chatDeleted :: Bool,
customData :: Maybe CustomData
@@ -380,6 +383,7 @@ data GroupInfo = GroupInfo
updatedAt :: UTCTime,
chatTs :: Maybe UTCTime,
userMemberProfileSentAt :: Maybe UTCTime,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
customData :: Maybe CustomData
}
@@ -1637,6 +1641,13 @@ data CommandData = CommandData
}
deriving (Show)
data ChatTag = ChatTag
{ chatTagId :: Int64,
chatTagText :: Text,
chatTagEmoji :: Text
}
deriving (Show)
-- ad-hoc type for data required for XGrpMemIntro continuation
data XGrpMemIntroCont = XGrpMemIntroCont
{ groupId :: GroupId,
@@ -1791,3 +1802,5 @@ $(JQ.deriveJSON defaultJSON ''Contact)
$(JQ.deriveJSON defaultJSON ''ContactRef)
$(JQ.deriveJSON defaultJSON ''NoteFolder)
$(JQ.deriveJSON defaultJSON ''ChatTag)
+3
View File
@@ -96,6 +96,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
CRChats chats -> viewChats ts tz chats
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRChatTags u tags -> ttyUser u $ [viewJSON tags]
CRApiParsedMarkdown ft -> [viewJSON ft]
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca
@@ -149,6 +150,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
| otherwise -> []
CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz
CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci
CRTagsUpdated u _ _ -> ttyUser u ["chat tagged"]
CRChatUntagged u _ _ -> ttyUser u ["chat untagged"]
CRChatItemsDeleted u deletions byUser timed -> case deletions of
[ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] ->
ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView
@@ -1,5 +1,3 @@
<p></p>
<ul>
<li><strong>Welcome, Flux</strong> &mdash; the new servers in <strong>v6.2-beta.1!</strong></li>
<li>What's the problem?</li>
@@ -0,0 +1,8 @@
<p><strong>v6.2 is released:</strong></p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network.</li>
<li>Business chats for better privacy and support of your customers.</li>
<li>Better user experience: open on the first unread, jump to quoted messages, see who reacted.</li>
<li>Improving notifications in iOS app.</li>
</ul>