mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0078c4d0d3 | |||
| e6b62b8900 | |||
| 041acc839d | |||
| e87be333f7 | |||
| e6861f5eb6 | |||
| 6cc3cbc9a2 | |||
| dbcc244086 | |||
| e01cc93026 | |||
| 79ea2e1c69 | |||
| 9676c0bfe7 | |||
| b0e319b396 | |||
| 6f4560ed99 | |||
| 7823eff4f1 | |||
| 498c60740e | |||
| 11aa5b450a | |||
| 6467d6d706 | |||
| 76820755e3 | |||
| 3acc59bd5d | |||
| 7057b5ffc7 | |||
| 7fb72d6a90 | |||
| f6d32ef6e8 | |||
| e017d3a425 | |||
| b66dfc8617 | |||
| 77a2815c3a | |||
| 289aaa0545 | |||
| cd16fd7a00 | |||
| b9b43c8260 | |||
| 617dee46c4 | |||
| 1983653c5e | |||
| 704bf857d2 | |||
| 23ad3a9171 | |||
| b3d67fd0e7 | |||
| b20408c844 | |||
| 3fd2938a5f | |||
| a5afd4e98a | |||
| ec5fe341c6 | |||
| 4122b4cb1d | |||
| 2a24ef3e6a | |||
| c70677452b | |||
| 4d82afe602 | |||
| 591f74a8e3 | |||
| aede65db14 | |||
| 0bf82c08a1 | |||
| ddef5a122c | |||
| 0fdd2e04cc | |||
| 3c274bba14 | |||
| d1e66386f5 | |||
| a9c3aa9c19 | |||
| 0d3d2212c0 | |||
| 58ea99055e | |||
| 257e91cedc | |||
| 11cb2d866f | |||
| 708368c9c5 | |||
| 0c007f8db0 | |||
| 12c2a09c85 | |||
| 56dde80689 | |||
| f74f6dfb5a | |||
| 0c3a742d0b | |||
| 38da1e2ff1 | |||
| 5b670c9ad4 | |||
| 4a6442b30b | |||
| a5e6d847fe | |||
| a0584dedc9 | |||
| 5d8e11c3a2 | |||
| df8ce2b06d | |||
| 6ff5f31bee | |||
| 3104d4487c | |||
| bba08cd954 | |||
| d63cfe59a8 | |||
| c3dc943eb0 | |||
| f56ea45ce0 | |||
| e57b0c98af | |||
| 25e8f1094c | |||
| 06d7e00cae | |||
| 5e29cda27b | |||
| 2e573662d5 | |||
| 4075c26dd2 |
@@ -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.
|
||||
|
||||
@@ -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> = [:]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" */;
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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]?
|
||||
|
||||
+5
-4
@@ -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) }
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+48
-20
@@ -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)
|
||||
|
||||
+8
-5
@@ -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),
|
||||
|
||||
+46
-15
@@ -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),
|
||||
|
||||
+49
-25
@@ -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
|
||||
|
||||
+49
-19
@@ -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,
|
||||
|
||||
+14
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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 ->
|
||||
|
||||
+4
-1
@@ -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 |
+4
-3
@@ -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
|
||||
|
||||
+6
-4
@@ -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") }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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 ../../
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> — 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>
|
||||
Reference in New Issue
Block a user