Merge branch 'master' into av/ios-infinity-scroll

This commit is contained in:
Avently
2024-12-25 03:00:55 -08:00
76 changed files with 10761 additions and 8838 deletions
+7 -6
View File
@@ -163,13 +163,14 @@ Your donations help us raise more funds - any amount, even the price of the cup
It is possible to donate via:
- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission).
- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad
- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u
- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
- BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT:
- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins.
Thank you,
+105 -1
View File
@@ -104,6 +104,94 @@ class ItemsModel: ObservableObject {
}
}
class ChatTagsModel: ObservableObject {
static let shared = ChatTagsModel()
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
@Published var presetTags: [PresetTag:Int] = [:]
@Published var unreadTags: [Int64:Int] = [:]
func updateChatTags(_ chats: [Chat]) {
let tm = ChatTagsModel.shared
var newPresetTags: [PresetTag:Int] = [:]
var newUnreadTags: [Int64:Int] = [:]
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat.chatInfo) {
newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
}
}
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
for tag in tags {
newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1
}
}
}
if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 {
activeFilter = nil
}
presetTags = newPresetTags
unreadTags = newUnreadTags
}
func updateChatFavorite(favorite: Bool, wasFavorite: Bool) {
let count = presetTags[.favorites]
if favorite && !wasFavorite {
presetTags[.favorites] = (count ?? 0) + 1
} else if !favorite && wasFavorite, let count {
presetTags[.favorites] = max(0, count - 1)
if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 {
activeFilter = nil
}
}
}
func addPresetChatTags(_ chatInfo: ChatInfo) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
presetTags[tag] = (presetTags[tag] ?? 0) + 1
}
}
}
func removePresetChatTags(_ chatInfo: ChatInfo) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
if let count = presetTags[tag] {
presetTags[tag] = max(0, count - 1)
}
}
}
}
func markChatTagRead(_ chat: Chat) -> Void {
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
markChatTagRead_(chat, tags)
}
}
func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void {
guard let tags = chat.chatInfo.chatTags else { return }
let nowUnread = chat.unreadTag
if nowUnread && !wasUnread {
for tag in tags {
unreadTags[tag] = (unreadTags[tag] ?? 0) + 1
}
} else if !nowUnread && wasUnread {
markChatTagRead_(chat, tags)
}
}
private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void {
for tag in tags {
if let count = unreadTags[tag] {
unreadTags[tag] = max(0, count - 1)
}
}
}
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
@@ -348,6 +436,7 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
ChatTagsModel.shared.addPresetChatTags(cInfo)
}
}
@@ -570,6 +659,7 @@ final class ChatModel: ObservableObject {
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.updateFloatingButtons(unreadCount: 0)
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
}
// update current chat
@@ -608,7 +698,9 @@ final class ChatModel: ObservableObject {
// update preview
let markedCount = chat.chatStats.unreadCount - unreadBelow
if markedCount > 0 {
let wasUnread = chat.unreadTag
chat.chatStats.unreadCount -= markedCount
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
@@ -621,7 +713,9 @@ final class ChatModel: ObservableObject {
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
_updateChat(cInfo.id) { chat in
let wasUnread = chat.unreadTag
chat.chatStats.unreadChat = unreadChat
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
}
}
@@ -630,6 +724,7 @@ final class ChatModel: ObservableObject {
if let chat = getChat(cInfo.id) {
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
chat.chatItems = []
ChatTagsModel.shared.markChatTagRead(chat)
chat.chatStats = ChatStats()
chat.chatInfo = cInfo
}
@@ -756,7 +851,9 @@ final class ChatModel: ObservableObject {
}
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
let wasUnread = chats[chatIndex].unreadTag
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
changeUnreadCounter(user: currentUser!, by: count)
}
@@ -861,7 +958,10 @@ final class ChatModel: ObservableObject {
func removeChat(_ id: String) {
withAnimation {
chats.removeAll(where: { $0.id == id })
if let i = getChatIndex(id) {
let removed = chats.remove(at: i)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo)
}
}
}
@@ -968,6 +1068,10 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
}
}
var unreadTag: Bool {
chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat)
}
var id: ChatId { get { chatInfo.id } }
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
+55
View File
@@ -318,6 +318,20 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r
}
func apiGetChatTags() throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r = chatSendCmdSync(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r
}
func apiGetChatTagsAsync() async throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r = await chatSendCmd(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r
}
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
@@ -367,6 +381,34 @@ func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: Ch
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] {
let r = await chatSendCmd(.apiCreateChatTag(tag: tag))
if case let .chatTags(_, userTags) = r {
return userTags
}
throw r
}
func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) {
let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds))
if case let .tagsUpdated(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r
}
func apiDeleteChatTag(tagId: Int64) async throws {
try await sendCommandOkResp(.apiDeleteChatTag(tagId: tagId))
}
func apiUpdateChatTag(tagId: Int64, tag: ChatTagData) async throws {
try await sendCommandOkResp(.apiUpdateChatTag(tagId: tagId, tagData: tag))
}
func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
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)
@@ -1745,24 +1787,37 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
let tags = try apiGetChatTags()
m.updateChats(chats)
let tm = ChatTagsModel.shared
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
}
private func getUserChatDataAsync() async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if m.currentUser != nil {
let userAddress = try await apiGetUserAddressAsync()
let chatItemTTL = try await getChatItemTTLAsync()
let chats = try await apiGetChatsAsync()
let tags = try await apiGetChatTagsAsync()
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
m.updateChats(chats)
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
}
} else {
await MainActor.run {
m.userAddress = nil
m.updateChats([])
tm.activeFilter = nil
tm.userTags = []
tm.presetTags = [:]
}
}
}
@@ -332,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
}
@@ -2068,6 +2068,9 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
do {
try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings)
await MainActor.run {
let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false
ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite)
let wasUnread = chat.unreadTag
switch chat.chatInfo {
case var .direct(contact):
contact.chatSettings = chatSettings
@@ -2077,6 +2080,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
ChatModel.shared.updateGroup(groupInfo)
default: ()
}
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
}
} catch let error {
logger.error("apiSetChatSettings error \(responseError(error))")
@@ -162,7 +162,7 @@ struct GroupMemberInfoView: View {
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited
|| !member.sendMsgEnabled
)
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
Button("Abort changing address") {
@@ -170,7 +170,7 @@ struct GroupMemberInfoView: View {
}
.disabled(
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|| connStats.ratchetSyncSendProhibited
|| !member.sendMsgEnabled
)
}
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
@@ -297,7 +297,7 @@ struct GroupMemberInfoView: View {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton(width: buttonWidth)
createMemberContactButton(member, width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
}
@@ -373,31 +373,41 @@ struct GroupMemberInfoView: View {
}
}
func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
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
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)
func createMemberContactButton(_ member: GroupMember, width: CGFloat) -> some View {
InfoViewButton(
image: "message.fill",
title: "message",
disabledLook:
!(
member.sendMsgEnabled ||
(member.activeConn?.connectionStats?.ratchetSyncAllowed ?? false)
),
width: width
) {
if member.sendMsgEnabled {
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)
}
}
} else if connStats.ratchetSyncAllowed {
}
} else if let connStats = connectionStats {
if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
@@ -409,13 +419,21 @@ struct GroupMemberInfoView: View {
),
id: "can't message member, fix connection"
))
} else {
} else if connStats.ratchetSyncInProgress {
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"
id: "can't message member, encryption renegotiation in progress"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Connection not ready."
),
id: "can't message member, connection not ready"
))
}
}
@@ -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,67 @@ 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 screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
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 +560,400 @@ struct ChatListNavLink: View {
}
}
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct ChatListTag: View {
var chat: Chat? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var editMode = EditMode.inactive
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
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)
}
}
.contentShape(Rectangle())
.onTapGesture {
if let c = chat {
setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
} else {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("All chats will be removed from the list \(text), and the list 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")
}
} header: {
if chat == nil {
editTagsButton()
.textCase(nil)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.modifier(ThemedBackground(grouped: true))
.environment(\.editMode, $editMode)
}
private func editTagsButton() -> some View {
if editMode.isEditing {
Button("Done") {
editMode = .inactive
dismiss()
}
} else {
Button("Edit") {
editMode = .active
}
}
}
@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) {
Task {
do {
var tags = chatTagsModel.userTags
tags.move(fromOffsets: source, toOffset: destination)
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
await MainActor.run {
chatTagsModel.userTags = tags
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
Task {
try await apiDeleteChatTag(tagId: tagId)
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 setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
Task {
do {
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
let (userTags, chatTags) = try await apiSetChatTags(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagIds: tagIds
)
await MainActor.run {
let m = ChatModel.shared
ChatTagsModel.shared.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)
}
closeSheet()
}
} catch let error {
showAlert(
NSLocalizedString("Error saving chat list", comment: "alert title"),
message: responseError(error)
)
}
}
}
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 {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var theme: AppTheme
var chat: Chat? = nil
var tagId: Int64? = nil
var emoji: String?
var name: String = ""
@State private var newEmoji: String?
@State private var newName: String = ""
@State private var isPickerPresented = false
@State private var saving: Bool?
var body: some View {
VStack {
List {
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
tag.chatTagId != tagId &&
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
}
Section {
HStack {
Button {
isPickerPresented = true
} label: {
if let newEmoji {
Text(newEmoji)
} else {
Image(systemName: "face.smiling")
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $newName)
}
Button {
saving = true
if let tId = tagId {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
} else {
createChatTag()
}
} label: {
Text(
chat != nil
? "Add to list"
: tagId == nil
? "Create list"
: "Save list"
)
}
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
} footer: {
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text("List name and emoji should be different for all lists.")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if isPickerPresented {
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
newEmoji = emoji
newName = name
}
}
var trimmedName: String {
newName.trimmingCharacters(in: .whitespaces)
}
private func createChatTag() {
Task {
do {
let text = trimmedName
let userTags = try await apiCreateChatTag(
tag: ChatTagData(emoji: newEmoji , text: text)
)
await MainActor.run {
saving = false
chatTagsModel.userTags = userTags
}
if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
} else {
await MainActor.run { dismiss() }
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
Task {
do {
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
await MainActor.run {
saving = false
for i in 0..<chatTagsModel.userTags.count {
if chatTagsModel.userTags[i].chatTagId == tagId {
chatTagsModel.userTags[i] = ChatTag(
chatTagId: tagId,
chatTagText: chatTagData.text,
chatTagEmoji: chatTagData.emoji
)
}
}
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
@@ -585,15 +1055,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))
}
+314 -56
View File
@@ -31,6 +31,29 @@ enum UserPickerSheet: Identifiable {
}
}
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 0
case contacts = 1
case groups = 2
case business = 3
var id: Int { rawValue }
}
enum ActiveFilter: Identifiable, Equatable {
case presetTag(PresetTag)
case userTag(ChatTag)
case unread
var id: String {
switch self {
case let .presetTag(tag): "preset \(tag.id)"
case let .userTag(tag): "user \(tag.chatTagId)"
case .unread: "unread"
}
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
@@ -117,13 +140,14 @@ struct ChatListView: View {
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
@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
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
@@ -131,7 +155,7 @@ struct ChatListView: View {
viewBody
}
}
private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
@@ -161,8 +185,9 @@ struct ChatListView: View {
}
}
}
.environmentObject(chatTagsModel)
}
private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial)
return withToolbar(tm) {
@@ -197,15 +222,22 @@ struct ChatListView: View {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
}
.sheet(item: $sheet) { sheet in
if #available(iOS 16.0, *) {
sheet.content.presentationDetents([.fraction(sheet.fraction)])
} else {
sheet.content
}
}
}
static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0
} else { false }
}()
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) {
if oneHandUI {
@@ -226,13 +258,13 @@ struct ChatListView: View {
}
}
}
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
@@ -247,7 +279,7 @@ struct ChatListView: View {
.onTapGesture { scrollToSearchBar = true }
}
}
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: .bottomBar) {
@@ -258,7 +290,7 @@ struct ChatListView: View {
trailingToolbarItem.padding(.bottom, padding)
}
}
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
@@ -275,7 +307,7 @@ struct ChatListView: View {
userPickerShown = true
}
}
@ViewBuilder var trailingToolbarItem: some View {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton()
@@ -283,7 +315,7 @@ struct ChatListView: View {
case .none: EmptyView()
}
}
@ViewBuilder private var chatList: some View {
let cs = filteredChats()
ZStack {
@@ -295,7 +327,8 @@ struct ChatListView: View {
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
parentSheet: $sheet
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
@@ -306,7 +339,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 +351,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)
}
@@ -363,80 +396,97 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats")
noChatsView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
@ViewBuilder private func noChatsView() -> some View {
if searchString().isEmpty {
switch chatTagsModel.activeFilter {
case .presetTag: Text("No filtered chats") // this should not happen
case let .userTag(tag): Text("No chats in list \(tag.chatTagText)")
case .unread:
Button {
chatTagsModel.activeFilter = nil
} label: {
HStack {
Image(systemName: "line.3.horizontal.decrease")
Text("No unread chats")
}
}
case .none: Text("No chats")
}
} else {
Text("No chats found")
}
}
private func unreadBadge(size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat)
}
}
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
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.chatInfo)
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 {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
}
struct SubsStatusIndicator: View {
@@ -500,18 +550,20 @@ 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
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var parentSheet: SomeSheet<AnyView>?
@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(parentSheet: $parentSheet) }
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -578,16 +630,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 +662,185 @@ struct ChatListSearchBar: View {
}
}
struct ChatTagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var parentSheet: SomeSheet<AnyView>?
var body: some View {
HStack {
tagsView()
}
}
@ViewBuilder private func tagsView() -> some View {
if chatTagsModel.presetTags.count > 1 {
if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
}
}
let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let current = tag == selectedTag
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 {
let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? "" : "").font(.footnote)
(Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear)
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary)
}
}
.onTapGesture {
setActiveFilter(filter: .userTag(tag))
}
.onLongPressGesture {
let screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTag(chat: nil)
.modifier(ThemedBackground(grouped: true))
}
)
},
id: "tag list",
fraction: fraction
)
}
}
}
Button {
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTagEditor()
}
)
},
id: "tag create"
)
} label: {
if chatTagsModel.userTags.isEmpty {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add list")
}
} else {
Image(systemName: "plus")
}
}
.foregroundColor(.secondary)
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
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 {
if selectedPresetTag != nil {
Button {
chatTagsModel.activeFilter = nil
} label: {
HStack {
Image(systemName: "list.bullet")
Text("All")
}
}
}
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
Image(systemName: systemName)
Text(text)
}
}
}
}
} label: {
if let tag = selectedPresetTag {
let (systemName, _) = presetTagLabel(tag: tag, active: true)
Image(systemName: systemName)
.foregroundColor(.accentColor)
} else {
Image(systemName: "list.bullet")
.foregroundColor(.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")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
} else {
chatTagsModel.activeFilter = nil
}
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
@@ -616,6 +852,28 @@ func chatStoppedIcon() -> some View {
}
}
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool {
switch tag {
case .favorites:
chatInfo.chatSettings?.favorite == true
case .contacts:
switch chatInfo {
case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chatInfo {
case let .group(groupInfo): groupInfo.businessChat == nil
default: false
}
case .business:
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) {
@@ -262,8 +262,7 @@ struct DatabaseView: View {
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
primaryButton: .destructive(Text("Import")) {
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
_ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert)
return true
await DatabaseView.importArchive(fileURL, $progressIndicator, $alert, false)
}
},
secondaryButton: .cancel()
@@ -467,9 +466,13 @@ struct DatabaseView: View {
static func importArchive(
_ archivePath: URL,
_ progressIndicator: Binding<Bool>,
_ alert: Binding<DatabaseAlert?>
_ alert: Binding<DatabaseAlert?>,
_ migration: Bool
) async -> Bool {
if archivePath.startAccessingSecurityScopedResource() {
defer {
archivePath.stopAccessingSecurityScopedResource()
}
await MainActor.run {
progressIndicator.wrappedValue = true
}
@@ -483,17 +486,17 @@ struct DatabaseView: View {
_ = kcDatabasePassword.remove()
if archiveErrors.isEmpty {
await operationEnded(.archiveImported, progressIndicator, alert)
return true
} else {
await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert)
return migration
}
return true
} catch let error {
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
}
} catch let error {
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
}
archivePath.stopAccessingSecurityScopedResource()
} else {
showAlert("Error accessing database file")
}
@@ -542,6 +545,8 @@ struct DatabaseView: View {
} else if case .chatDeleted = dbAlert {
let (title, message) = chatDeletedAlertText()
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
} else if case let .error(title, error) = dbAlert {
showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] })
} else {
alert.wrappedValue = dbAlert
cont.resume()
@@ -587,13 +592,13 @@ struct DatabaseView: View {
}
}
private func archiveImportedAlertText() -> (String, String) {
func archiveImportedAlertText() -> (String, String) {
(
NSLocalizedString("Chat database imported", comment: ""),
NSLocalizedString("Restart the app to use imported chat database", comment: "")
)
}
private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
(
NSLocalizedString("Chat database imported", comment: ""),
NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs)
@@ -96,6 +96,7 @@ struct MigrateToDevice: View {
@Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert?
@State private var databaseAlert: DatabaseAlert? = nil
private let tempDatabaseUrl = urlForTemporaryDatabase()
@State private var chatReceiver: MigrationChatReceiver? = nil
// Prevent from hiding the view until migration is finished or app deleted
@@ -178,6 +179,20 @@ struct MigrateToDevice: View {
return Alert(title: Text(title), message: Text(error))
}
}
.alert(item: $databaseAlert) { item in
switch item {
case .archiveImported:
let (title, message) = archiveImportedAlertText()
return Alert(title: Text(title), message: Text(message))
case let .archiveImportedWithErrors(errs):
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
return Alert(title: Text(title), message: Text(message))
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
default: // not expected this branch to be called because this alert is used only for importArchive purpose
return Alert(title: Text("Error"))
}
}
.interactiveDismissDisabled(backDisabled)
}
@@ -243,7 +258,7 @@ struct MigrateToDevice: View {
) { result in
if case let .success(files) = result, let fileURL = files.first {
Task {
let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil))
let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true)
if success {
DatabaseView.startChat(
Binding.constant(false),
@@ -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) {
@@ -174,7 +174,6 @@ struct CreateFirstProfile: View {
}
.onAppear() {
focusDisplayName = true
setLastVersionDefault()
}
.padding(.horizontal, 25)
.padding(.top, 10)
@@ -89,6 +89,9 @@ struct SimpleXInfo: View {
)
}
}
.onAppear() {
setLastVersionDefault()
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 25)
.padding(.top, 75)
+45 -28
View File
@@ -167,9 +167,9 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; };
649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; };
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -206,6 +206,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 */; };
@@ -519,9 +520,9 @@
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a"; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = "<group>"; };
649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -642,6 +643,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 */,
@@ -677,9 +679,9 @@
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */,
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -763,8 +765,8 @@
649B28D82CFE07CF00536B68 /* libffi.a */,
649B28DC2CFE07CF00536B68 /* libgmp.a */,
649B28DA2CFE07CF00536B68 /* libgmpxx.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1195,6 +1197,7 @@
D7197A1729AE89660055C05A /* WebRTC */,
8C8118712C220B5B00E6FC94 /* Yams */,
8CB3476B2CF5CFFA006787A5 /* Ink */,
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */,
);
productName = "SimpleX (iOS)";
productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */;
@@ -1339,6 +1342,7 @@
D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */,
8C73C1162C21E17B00892670 /* XCRemoteSwiftPackageReference "Yams" */,
8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */,
B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */,
);
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
projectDirPath = "";
@@ -1943,7 +1947,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1968,7 +1972,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1992,7 +1996,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2017,7 +2021,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -2033,11 +2037,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2053,11 +2057,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2078,7 +2082,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2093,7 +2097,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2115,7 +2119,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2130,7 +2134,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2152,7 +2156,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2178,7 +2182,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2203,7 +2207,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2229,7 +2233,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2254,7 +2258,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2269,7 +2273,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2288,7 +2292,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 255;
CURRENT_PROJECT_VERSION = 256;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2303,7 +2307,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.2.1;
MARKETING_VERSION = 6.2.2;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2399,6 +2403,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";
@@ -2441,6 +2453,11 @@
package = 8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */;
productName = Ink;
};
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */ = {
isa = XCSwiftPackageProductDependency;
package = B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */;
productName = ElegantEmojiPicker;
};
CE38A29B2C3FCD72005ED185 /* SwiftyGif */ = {
isa = XCSwiftPackageProductDependency;
package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */;
@@ -1,5 +1,5 @@
{
"originHash" : "33afc44be5f4225325b3cb940ed71b6cbf3ef97290d348d7b6803697bcd0637d",
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"pins" : [
{
"identity" : "codescanner",
@@ -10,6 +10,15 @@
"version" : "2.5.0"
}
},
{
"identity" : "elegant-emoji-picker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Finalet/Elegant-Emoji-Picker",
"state" : {
"branch" : "main",
"revision" : "71d2d46092b4d550cc593614efc06438f845f6e6"
}
},
{
"identity" : "ink",
"kind" : "remoteSourceControl",
+45 -7
View File
@@ -40,10 +40,16 @@ 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(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData)
case apiReorderChatTags(tagIds: [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 +204,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 +213,11 @@ 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(tag): return "/_create tag \(encodeJSON(tag))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))"
case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
@@ -367,10 +379,16 @@ 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 .apiSetChatTags: return "apiSetChatTags"
case .apiDeleteChatTag: return "apiDeleteChatTag"
case .apiUpdateChatTag: return "apiUpdateChatTag"
case .apiReorderChatTags: return "apiReorderChatTags"
case .apiCreateChatItems: return "apiCreateChatItems"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
@@ -564,6 +582,7 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData, navInfo: NavigationInfo?)
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 +609,7 @@ 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 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 +761,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 +788,7 @@ public enum ChatResponse: Decodable, Error {
case .contactCode: return "contactCode"
case .groupMemberCode: return "groupMemberCode"
case .connectionVerified: return "connectionVerified"
case .tagsUpdated: return "tagsUpdated"
case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .connectionUserChanged: return "connectionUserChanged"
@@ -914,6 +936,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, navInfo): return withUser(u, "chat: \(String(describing: chat))\nnavInfo: \(String(describing: navInfo))")
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 +965,7 @@ 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 .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))")
@@ -1176,6 +1200,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?
@@ -1409,7 +1443,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable {
}
set { `operator` = newValue }
}
public static var sampleData1 = UserOperatorServers(
operator: ServerOperator.sampleData1,
smpServers: [UserServer.sampleData.preset],
@@ -1720,7 +1754,7 @@ public struct NetCfg: Codable, Equatable {
rcvConcurrency: 8,
smpPingInterval: 1200_000_000
)
public var withProxyTimeouts: NetCfg {
var cfg = self
cfg.tcpConnectTimeout = NetCfg.proxyDefaults.tcpConnectTimeout
@@ -1730,7 +1764,7 @@ public struct NetCfg: Codable, Equatable {
cfg.smpPingInterval = NetCfg.proxyDefaults.smpPingInterval
return cfg
}
public var hasProxyTimeouts: Bool {
tcpConnectTimeout == NetCfg.proxyDefaults.tcpConnectTimeout &&
tcpTimeout == NetCfg.proxyDefaults.tcpTimeout &&
@@ -1863,7 +1897,7 @@ public struct NetworkProxy: Equatable, Codable {
public static var def: NetworkProxy {
NetworkProxy()
}
public var valid: Bool {
let hostOk = switch NWEndpoint.Host(host) {
case .ipv4: true
@@ -1874,7 +1908,7 @@ public struct NetworkProxy: Equatable, Codable {
port > 0 && port <= 65535 &&
NetworkProxy.validCredential(username) && NetworkProxy.validCredential(password)
}
public static func validCredential(_ s: String) -> Bool {
!s.contains(":") && !s.contains("@")
}
@@ -2014,6 +2048,10 @@ public struct ConnectionStats: Decodable, Hashable {
public var ratchetSyncSendProhibited: Bool {
[.required, .started, .agreed].contains(ratchetSyncState)
}
public var ratchetSyncInProgress: Bool {
[.started, .agreed].contains(ratchetSyncState)
}
}
public struct RcvQueueInfo: Codable, Hashable {
@@ -2151,7 +2189,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
case .instant: "Instant"
}
}
public var icon: String {
switch self {
case .off: return "arrow.clockwise"
@@ -2631,7 +2669,7 @@ public struct AppSettings: Codable, Equatable {
public var uiThemes: [ThemeOverrides]? = nil
public var oneHandUI: Bool? = nil
public var chatBottomBar: Bool? = nil
public func prepareForExport() -> AppSettings {
var empty = AppSettings()
let def = AppSettings.defaults
+42 -1
View File
@@ -1334,6 +1334,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var contactCard: Bool {
switch self {
case let .direct(contact): contact.activeConn == nil && contact.profile.contactLink != nil && contact.active
default: false
}
}
public var groupInfo: GroupInfo? {
switch self {
case let .group(groupInfo): return groupInfo
@@ -1444,6 +1451,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
default: return nil
}
}
public var chatTags: [Int64]? {
switch self {
case let .direct(contact): return contact.chatTags
case let .group(groupInfo): return groupInfo.chatTags
default: return nil
}
}
var createdAt: Date {
switch self {
@@ -1545,6 +1560,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatTags: [Int64]
public var uiThemes: ThemeModeOverrides?
public var chatDeleted: Bool
@@ -1615,6 +1631,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false,
chatTags: [],
chatDeleted: false
)
}
@@ -1910,6 +1927,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var fullName: String { get { groupProfile.fullName } }
public var image: String? { get { groupProfile.image } }
public var localAlias: String { "" }
public var chatTags: [Int64]
public var isOwner: Bool {
return membership.memberRole == .owner && membership.memberCurrent
@@ -1932,7 +1950,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now
updatedAt: .now,
chatTags: []
)
}
@@ -1991,6 +2010,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var activeConn: Connection?
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var ready: Bool { get { activeConn?.connStatus == .ready } }
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var sendMsgEnabled: Bool { get {
sndReady
&& memberCurrent
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)
&& !(activeConn?.connDisabled ?? true)
} }
public var displayName: String {
get {
let p = memberProfile
@@ -4210,6 +4237,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]?
@@ -27,6 +27,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Allows to query app name and icon that can open specific file type -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
</queries>
<application
android:name="SimplexApp"
android:allowBackup="false"
@@ -32,8 +32,10 @@ object MessagesFetcherWorker {
SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
}
fun cancelAll() {
Log.d(TAG, "Worker: canceled all tasks")
fun cancelAll(withLog: Boolean = true) {
if (withLog) {
Log.d(TAG, "Worker: canceled all tasks")
}
SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG)
}
}
@@ -33,6 +33,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.map
import java.io.*
import java.util.*
import java.util.concurrent.TimeUnit
@@ -151,6 +152,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
* */
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartServiceAfterAppExit()) {
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
return@launch
}
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
@@ -172,6 +174,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
if (!allowToStartPeriodically()) {
MessagesFetcherWorker.cancelAll(withLog = false)
return@launch
}
MessagesFetcherWorker.scheduleWork()
@@ -227,7 +230,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
SimplexService.safeStopService()
}
}
if (mode != NotificationsMode.SERVICE) {
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
}
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
@@ -244,6 +249,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
override fun androidChatStopped() {
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
SimplexService.safeStopService()
MessagesFetcherWorker.cancelAll()
}
@@ -139,6 +139,7 @@ class SimplexService: Service() {
if (chatDbStatus != DBMigrationResult.OK) {
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
showPassphraseNotification(chatDbStatus)
androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
safeStopService()
return@withLongRunningApi
}
@@ -681,6 +682,7 @@ class SimplexService: Service() {
}
ChatController.appPrefs.notificationsMode.set(NotificationsMode.OFF)
StartReceiver.toggleReceiver(false)
androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
MessagesFetcherWorker.cancelAll()
safeStopService()
}
@@ -3,19 +3,30 @@ package chat.simplex.common.platform
import android.Manifest
import android.content.*
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.UriHandler
import androidx.core.graphics.drawable.toBitmap
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import java.io.BufferedOutputStream
import java.io.File
import chat.simplex.res.MR
import java.net.URI
import kotlin.math.min
data class OpenDefaultApp(
val name: String,
val icon: ImageBitmap,
val isSystemChooser: Boolean
)
actual fun ClipboardManager.shareText(text: String) {
var text = text
for (i in 10 downTo 1) {
@@ -37,7 +48,7 @@ actual fun ClipboardManager.shareText(text: String) {
}
}
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean, useChooser: Boolean = true) {
val uri = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
@@ -67,9 +78,35 @@ fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
type = mimeType
}
}
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
if (useChooser) {
val shareIntent = Intent.createChooser(sendIntent, null)
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(shareIntent)
} else {
sendIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
androidAppContext.startActivity(sendIntent)
}
}
fun queryDefaultAppForExtension(ext: String, encryptedFileUri: URI): OpenDefaultApp? {
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null
val openIntent = Intent(Intent.ACTION_VIEW)
openIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
openIntent.setDataAndType(encryptedFileUri.toUri(), mimeType)
val pm = androidAppContext.packageManager
//// This method returns the list of apps but no priority, nor default flag
// val resInfoList: List<ResolveInfo> = if (Build.VERSION.SDK_INT >= 33) {
// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong()))
// } else {
// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY)
// }.sortedBy { it.priority }
// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null
val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}")
val label = act.loadLabel(pm).toString()
val icon = act.loadIcon(pm).toBitmap().asImageBitmap()
val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true
return OpenDefaultApp(label, icon, chooser)
}
actual fun shareFile(text: String, fileSource: CryptoFile) {
@@ -0,0 +1,57 @@
package chat.simplex.common.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.DefaultDropdownMenu
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import java.net.URI
@Composable
actual fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
) {
val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null }
DefaultDropdownMenu(showMenu) {
if (defaultApp != null) {
if (!defaultApp.isSystemChooser) {
ItemAction(
stringResource(MR.strings.open_with_app).format(defaultApp.name),
defaultApp.icon,
textColor = MaterialTheme.colors.primary,
onClick = {
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
showMenu.value = false
}
)
} else {
ItemAction(
stringResource(MR.strings.open_with_app).format(""),
painterResource(MR.images.ic_open_in_new),
color = MaterialTheme.colors.primary,
onClick = {
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
showMenu.value = false
}
)
}
}
ItemAction(
stringResource(MR.strings.save_verb),
painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download),
color = MaterialTheme.colors.primary,
onClick = {
saveFile()
showMenu.value = false
}
)
}
}
@@ -1575,6 +1575,13 @@ data class GroupMember (
var activeConn: Connection? = null
): NamedChat {
val id: String get() = "#$groupId @$groupMemberId"
val ready get() = activeConn?.connStatus == ConnStatus.Ready
val sndReady get() = ready || activeConn?.connStatus == ConnStatus.SndReady
val sendMsgEnabled get() =
sndReady
&& memberCurrent
&& !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)
&& !(activeConn?.connDisabled ?: true)
override val displayName: String
get() {
val p = memberProfile
@@ -968,6 +968,7 @@ object ChatController {
val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live))
when {
r is CR.ChatItemUpdated -> return r.chatItem
r is CR.ChatItemNotChanged -> return r.chatItem
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.maximum_message_size_title),
@@ -6032,6 +6033,9 @@ class ConnectionStats(
val ratchetSyncSendProhibited: Boolean get() =
listOf(RatchetSyncState.Required, RatchetSyncState.Started, RatchetSyncState.Agreed).contains(ratchetSyncState)
val ratchetSyncInProgress: Boolean get() =
listOf(RatchetSyncState.Started, RatchetSyncState.Agreed).contains(ratchetSyncState)
}
@Serializable
@@ -697,13 +697,19 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = {
clipboard.setText(AnnotatedString(contact.profile.displayName))
showToast(generalGetString(MR.strings.copied))
}
Text(
text,
inlineContent = inlineContent,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
Text(
@@ -711,7 +717,8 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
}
}
@@ -9,6 +9,7 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
@@ -17,6 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -446,12 +449,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = {
clipboard.setText(AnnotatedString(cInfo.displayName))
showToast(generalGetString(MR.strings.copied))
}
Text(
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(
@@ -459,7 +468,8 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 8,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
}
}
@@ -8,6 +8,7 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -89,29 +90,34 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
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())
if (member.sendMsgEnabled) {
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)
}
progressIndicator = false
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
}
} else if (connectionStats.ratchetSyncAllowed) {
progressIndicator = false
}
} else if (connectionStats != null) {
if (connectionStats.ratchetSyncAllowed) {
showFixConnectionAlert(syncConnection = { syncMemberConnection() })
} else {
} else if (connectionStats.ratchetSyncInProgress) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_send_message_to_member_alert_title),
generalGetString(MR.strings.encryption_renegotiation_in_progress)
)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_send_message_to_member_alert_title),
generalGetString(MR.strings.connection_not_ready)
)
}
}
},
@@ -366,7 +372,11 @@ fun GroupMemberInfoLayout(
if (contactId != null) {
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group
} else {
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { createMemberContact() })
OpenChatButton(
modifier = Modifier.fillMaxWidth(0.33f),
disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)),
onClick = { createMemberContact() }
)
}
InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = {
showSendMessageToEnableCallsAlert()
@@ -437,12 +447,12 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
SwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited,
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || !member.sendMsgEnabled,
switchAddress = switchMemberAddress
)
if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) {
AbortSwitchAddressButton(
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited,
disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || !member.sendMsgEnabled,
abortSwitchAddress = abortSwitchMemberAddress
)
}
@@ -528,13 +538,19 @@ fun GroupMemberInfoHeader(member: GroupMember) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = {
clipboard.setText(AnnotatedString(member.displayName))
showToast(generalGetString(MR.strings.copied))
}
Text(
text,
inlineContent = inlineContent,
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
if (member.fullName != "" && member.fullName != member.displayName) {
Text(
@@ -542,7 +558,8 @@ fun GroupMemberInfoHeader(member: GroupMember) {
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
)
}
}
@@ -602,6 +619,7 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(
modifier: Modifier,
disabledLook: Boolean = false,
onClick: () -> Unit
) {
InfoViewActionButton(
@@ -609,7 +627,7 @@ fun OpenChatButton(
icon = painterResource(MR.images.ic_chat_bubble),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
disabledLook = false,
disabledLook = disabledLook,
onClick = onClick
)
}
@@ -1,12 +1,12 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -184,14 +184,26 @@ fun CIFileView(
}
}
val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) }
val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() }
val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null
if (loadedFilePath != null && file?.fileSource != null) {
val encrypted = file.fileSource.cryptoArgs != null
SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() })
}
Row(
Modifier
.combinedClickable(
onClick = { fileAction() },
onClick = {
if (appPlatform.isAndroid && loadedFilePath != null) {
showOpenSaveMenu.value = true
} else {
fileAction()
}
},
onLongClick = { showMenu.value = true }
)
.padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())),
//Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.sp.toDp())
) {
@@ -223,6 +235,16 @@ fun CIFileView(
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
@Composable
expect fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
)
@Composable
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
@@ -867,6 +867,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on
}
}
@Composable
fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) {
val finalColor = if (textColor == Color.Unspecified) {
MenuTextColor
} else textColor
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F)
.padding(end = 15.dp),
color = finalColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (iconColor == Color.Unspecified) {
Image(icon, text, Modifier.size(22.dp))
} else {
Icon(icon, text, Modifier.size(22.dp), tint = iconColor)
}
}
}
}
@Composable
fun ItemAction(
text: String,
@@ -60,8 +60,7 @@ fun DatabaseView() {
if (to != null) {
importArchiveAlert {
stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) {
importArchive(to, appFilesCountAndSize, progressIndicator)
true
importArchive(to, appFilesCountAndSize, progressIndicator, false)
}
}
}
@@ -645,6 +644,7 @@ suspend fun importArchive(
importedArchiveURI: URI,
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
progressIndicator: MutableState<Boolean>,
migration: Boolean
): Boolean {
val m = chatModel
progressIndicator.value = true
@@ -666,12 +666,13 @@ suspend fun importArchive(
if (chatModel.localUserCreated.value == false) {
chatModel.chatRunning.value = false
}
return true
} else {
operationEnded(m, progressIndicator) {
showArchiveImportedWithErrorsAlert(archiveErrors)
}
return migration
}
return true
} catch (e: Error) {
operationEnded(m, progressIndicator) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_importing_database), e.toString())
@@ -174,7 +174,7 @@ private fun SectionByState(
is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath)
is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value)
is MigrationFromState.LinkCreation -> LinkCreationView()
is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl)
is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl, chatReceiver.value)
is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion)
}
}
@@ -335,7 +335,7 @@ private fun LinkCreationView() {
}
@Composable
private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) {
private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) {
SectionView {
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_close),
@@ -356,7 +356,7 @@ private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: S
confirmText = generalGetString(MR.strings.continue_to_next_step),
destructive = true,
onConfirm = {
finishMigration(fileId, ctrl)
finishMigration(fileId, ctrl, chatReceiver)
}
)
}
@@ -450,6 +450,7 @@ private fun MutableState<MigrationFromState>.stopChat() {
try {
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation
platform.androidChatStopped()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.migrate_from_device_error_saving_settings),
@@ -617,9 +618,11 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) {
}
}
private fun MutableState<MigrationFromState>.finishMigration(fileId: Long, ctrl: ChatCtrl) {
private fun MutableState<MigrationFromState>.finishMigration(fileId: Long, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) {
withBGApi {
cancelUploadedArchive(fileId, ctrl)
chatReceiver?.stopAndCleanUp()
getMigrationTempFilesDirectory().deleteRecursively()
state = MigrationFromState.Finished(false)
}
}
@@ -655,6 +658,7 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) {
} else if (user != null) {
startChat(user)
}
platform.androidChatStartedAfterBeingOff()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_starting_chat),
@@ -239,7 +239,7 @@ private fun ArchiveImportView(progressIndicator: MutableState<Boolean>, close: (
val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? ->
if (to != null) {
withLongRunningApi {
val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator)
val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator, true)
if (success) {
startChat(
chatModel,
@@ -691,6 +691,7 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit)
if (user != null) {
startChat(user)
}
platform.androidChatStartedAfterBeingOff()
hideView(close)
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration))
} catch (e: Exception) {
@@ -78,7 +78,7 @@ fun NotificationsSettingsLayout(
)
}
if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) {
SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization))
SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization))
}
}
SectionBottomSpacer()
@@ -95,7 +95,7 @@ fun NotificationsModeView(
AppBarTitle(stringResource(MR.strings.settings_notifications_mode_title).lowercase().capitalize(Locale.current))
SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected)
if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) {
SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization))
SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization))
}
}
}
@@ -482,6 +482,7 @@
<string name="loading_remote_file_desc">Please, wait while the file is being loaded from the linked mobile</string>
<string name="file_error">File error</string>
<string name="temporary_file_error">Temporary file error</string>
<string name="open_with_app">Open with %s</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
@@ -1696,6 +1697,7 @@
<string name="cant_call_member_alert_title">Can\'t call group member</string>
<string name="cant_call_member_send_message_alert_text">Send message to enable calls.</string>
<string name="cant_send_message_to_member_alert_title">Can\'t message group member</string>
<string name="connection_not_ready">Connection not ready.</string>
<!-- GroupWelcomeView.kt -->
<string name="group_welcome_title">Welcome message</string>
@@ -0,0 +1,18 @@
package chat.simplex.common.views.chat.item
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import chat.simplex.common.model.CryptoFile
import java.net.URI
@Composable
actual fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
encrypted: Boolean,
ext: String?,
encryptedUri: URI,
fileSource: CryptoFile,
saveFile: () -> Unit
) {
}
+4 -4
View File
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.2.1
android.version_code=261
android.version_name=6.2.2
android.version_code=263
desktop.version_name=6.2.1
desktop.version_code=83
desktop.version_name=6.2.2
desktop.version_code=84
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
+1 -1
View File
@@ -19,9 +19,9 @@ import GHC.Generics (Generic)
import Network.Socket
import qualified Network.WebSockets as WS
import Numeric.Natural (Natural)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Library.Commands
import Simplex.Chat.Options
import Simplex.Messaging.Transport.Server (runLocalTCPServer)
import Simplex.Messaging.Util (raceAny_)
+2
View File
@@ -10,6 +10,8 @@ permalink: "/blog/20241016-wired-attack-on-privacy.html"
# Wireds Attack on Privacy
**Published:** Oct 16, 2024
<img src="./images/20241016-wired-privacy.jpg" width="330" class="float-to-right">
The [Wired article](https://www.wired.com/story/neo-nazis-flee-telegram-encrypted-app-simplex/) by David Gilbert focusing on neo-Nazis moving to SimpleX Chat following the Telegram's changes in privacy policy is biased and misleading. By cherry-picking information from [the report](https://www.isdglobal.org/digital_dispatches/neo-nazi-accelerationists-seek-new-digital-refuge-amid-looming-telegram-crackdown/) by the Institute for Strategic Dialogue (ISD), Wired fails to mention that SimpleX network design prioritizes privacy in order to protect human rights defenders, journalists, and everyday users who value their privacy &mdash; many people feel safer using SimpleX than non-private apps, being protected from strangers contacting them.
@@ -72,7 +72,7 @@ This is a small but important change - you can now see who reacted to your messa
### 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.
iOS notifications in a decentralized network is a complex problem. 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.
@@ -0,0 +1,53 @@
---
layout: layouts/article.html
title: "Oppose digital IDs they break the law and lead to mass scale surveillance"
date: 2024-12-18
preview: Starting next year, the UK government plans to introduce digital ID cards for the young people to prove their age when visiting pubs.
image: images/20241218-pub.jpg
imageWide: true
permalink: "/blog/20241218-oppose-digital-ids-they-break-law-lead-to-mass-scale-surveillance.html"
---
# Oppose digital IDs they break the law and lead to mass scale surveillance
**Published:** Dec 18, 2024
<img src="./images/20241218-pub.jpg" width="330" class="float-to-right">
Starting next year, the UK government plans to introduce [digital ID cards](https://www.telegraph.co.uk/politics/2024/12/08/digital-id-to-be-introduced-for-pubs-and-clubs/) for the young people to prove their age when visiting pubs. While officials claim this system will remain optional, it's part of a broader government initiative to move more state functions online so that people can prove their identity for everything from paying taxes to opening a bank account using the government-backed app. This will be a step toward a society where every pub visit, purchase, and social interaction becomes a permanent digital record linked to a government-issued ID a step to normalizing mass surveillance at scale.
Digital IDs are promoted as a way to fight law violations, and some politicians support them as [a way to tackle illegal immigration](https://www.telegraph.co.uk/politics/2024/07/10/id-cards-inevitable-tackle-immigration-lord-blunkett-labour/). But digital IDs themselves break the law. Article 8 of the European Convention of Human Rights says: “Everyone has the right to respect for his private and family life”. It means that not only our right to privacy is enshrined in the law, but the right to have our privacy respected is also part of the law. Asking to present a digital ID when visiting a pub, even if it is optional, disrespects our privacy, and is therefore illegal.
Digital IDs would not stop people who decide to break laws. Pubs already can refuse to serve alcohol to young people and require the ID in case the age is in doubt. And illegal immigration can also be reduced without any digital IDs. But introducing digital IDs and collecting our actions, names and locations in one government-controlled database will result in making this information easier to access for criminals, and being exploited for financial and identity crimes.
What starts as a "convenient option" is likely to end as a mandatory requirement. The digital ID systems being pushed by governments and corporations aren't about making our lives easier. They're about tracking, monitoring, and controlling every move we make. And we can see [where this road leads in China](https://www.wired.com/story/china-social-credit-system-explained/), when IDs and social scores created for convenience are used to prevent access to basic services and bank accounts as a punishment for legal social media posts that the government disagrees with. What started as a convenience, is now trialed [to track the duration of public toilet visits](https://www.thesun.ie/news/13154812/china-installs-toilet-timers-to-broadcast-time-spent/).
The United Kingdom is a democratic country, and the law protects our right to privacy and freedom of speech. If we accept digital IDs as something required for simple things, like buying a drink, it leaves the door wide open to a range of privacy violations.
We call on everyone to oppose the digital ID systems. Do not use them. Do not install these systems in your pub, for as long as it is not legally required. Support local businesses that dont use them. Protect your privacy and freedom by using software that respects them. Demand that your privacy is respected, as required by law.
To make your voice heard, email your MP expressing your rejection of digital IDs as a violation of European Convention of Human Rights in three simple steps:
1. **Copy the text below** or [click this link](mailto:?subject=Please%20oppose%20the%20plan%20for%20Digital%20IDs&body=Dear%20%E2%80%A6%2C%0A%0AI%20object%20to%20the%20introduction%20of%20digital%20IDs%20in%20pubs%20or%20any%20other%20public%20places%20for%20these%20reasons%3A%0A%0A1.%20It%20violates%20the%20European%20Convention%20of%20Human%20Rights%2C%20article%208%3A%20%E2%80%9CEveryone%20has%20the%20right%20to%20respect%20for%20his%20private%20and%20family%20life%E2%80%9D%20(https%3A%2F%2Ffra.europa.eu%2Fen%2Flaw-reference%2Feuropean-convention-human-rights-article-8-0).%0AAsking%20to%20present%20digital%20IDs%20when%20proof%20of%20identity%20is%20not%20legally%20required%2C%20even%20if%20it%20is%20optional%2C%20disrespects%20our%20privacy%2C%20and%20is%20therefore%20illegal.%0A%0A2.%20It%20will%20not%20be%20an%20effective%20measure%20in%20reducing%20the%20violations%20of%20the%20law.%20People%20who%20want%20to%20circumvent%20it%2C%20will%20find%20a%20way.%0A%0A3.%20It%20will%20increase%20crime%2C%20because%20combining%20a%20large%20amount%20of%20private%20information%20in%20a%20single%20system%20increases%20the%20risks%20of%20this%20information%20becoming%20available%20to%20criminals%2C%20who%20will%20exploit%20it%20for%20financial%20crimes%20and%20identity%20theft.%0A%0AI%20kindly%20ask%20you%20to%20oppose%20this%20plan%2C%20both%20publicly%20and%20during%20any%20discussions%20in%20the%20UK%20Parliament.%0A%0ASincerely%20yours%2C%0A%E2%80%A6) to copy it into email app:
*Dear …,*
*I object to the introduction of digital IDs in pubs or any other public places for these reasons:*
1. *It violates the European Convention of Human Rights, article 8: “Everyone has the right to respect for his private and family life” (https://fra.europa.eu/en/law-reference/european-convention-human-rights-article-8-0).*
*Asking to present digital IDs when proof of identity is not legally required, even if it is optional, disrespects our privacy, and is therefore illegal.*
2. *It will not be an effective measure in reducing the violations of the law. People who want to circumvent it, will find a way.*
3. *It will increase crime, because combining a large amount of private information in a single system increases the risks of this information becoming available to criminals, who will exploit it for financial crimes and identity theft.*
*I kindly ask you to oppose this plan, both publicly and during any discussions in the UK Parliament.*
*Sincerely yours,*
*…*
2. [**Find the email address of your MP**](https://members.parliament.uk/members/Commons) and copy it to the email.
3. Fill in the blanks, edit the text if needed, and **send it**!
Public opposition changed government decisions in many cases.
It is your opportunity to tell the government which country you want to live in &mdash; please use it!
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 0a827307d666de819d5cef17f70a78be5cd0e410
tag: bf289023273f2b94f8649b4c641e1cc9996b8a4b
source-repository-package
type: git
+2 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 6.2.0.7
version: 6.2.2.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -10,6 +10,7 @@ copyright: 2020-22 simplex.chat
category: Web, System, Services, Cryptography
extra-source-files:
- README.md
- PRIVACY.md
- cabal.project
dependencies:
@@ -38,6 +38,25 @@
</description>
<releases>
<release version="6.2.2" date="2024-12-25">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>
<p>New in v6.2.1-2:</p>
<ul>
<li>important fixes</li>
<li>offer to "fix" encryption when calling or making direct connection with member.</li>
<li>broken layout.</li>
<li>option to enable debug logs (disabled by default).</li>
<li>show who reacted in direct chats.</li>
</ul>
<p>New in v6.2:</p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app to improve metadata privacy.</li>
<li>Business chats your customers privacy.</li>
<li>Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.</li>
</ul>
</description>
</release>
<release version="6.2.1" date="2024-12-12">
<url type="details">https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html</url>
<description>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."0a827307d666de819d5cef17f70a78be5cd0e410" = "09z0y6w1c51szd9r90fl259inn76yahfbb164mawd34sr41ccp7g";
"https://github.com/simplex-chat/simplexmq.git"."bf289023273f2b94f8649b4c641e1cc9996b8a4b" = "1qcyh8n3mws2vbnjw44ih2ji6s9p1dy5rmhs49zf3ia7llnsqzdl";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+7 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.2.0.7
version: 6.2.2.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -16,6 +16,7 @@ license-file: LICENSE
build-type: Simple
extra-source-files:
README.md
PRIVACY.md
cabal.project
flag swift
@@ -35,6 +36,9 @@ library
Simplex.Chat.Core
Simplex.Chat.Files
Simplex.Chat.Help
Simplex.Chat.Library.Commands
Simplex.Chat.Library.Internal
Simplex.Chat.Library.Subscriber
Simplex.Chat.Markdown
Simplex.Chat.Messages
Simplex.Chat.Messages.Batch
@@ -155,6 +159,8 @@ library
Simplex.Chat.Migrations.M20241125_indexes
Simplex.Chat.Migrations.M20241128_business_chats
Simplex.Chat.Migrations.M20241205_business_chat_members
Simplex.Chat.Migrations.M20241222_operator_conditions
Simplex.Chat.Migrations.M20241223_chat_tags
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared
+10 -8554
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -294,11 +294,17 @@ 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 ChatTagData
| APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId))
| APIDeleteChatTag ChatTagId
| APIUpdateChatTag ChatTagId ChatTagData
| APIReorderChatTags (NonEmpty ChatTagId)
| APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
@@ -587,6 +593,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 +624,7 @@ 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]}
| CRNewChatItems {user :: User, chatItems :: [AChatItem]}
| CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]}
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
@@ -1068,6 +1076,16 @@ instance FromJSON ComposedMessage where
parseJSON invalid =
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
data ChatTagData = ChatTagData
{ emoji :: Maybe 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 +1621,5 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig)
$(JQ.deriveToJSON defaultJSON ''ComposedMessage)
$(JQ.deriveToJSON defaultJSON ''ChatTagData)
+2 -1
View File
@@ -21,11 +21,12 @@ import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..))
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types
import Simplex.Chat.View (serializeChatResponse)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction, MigrationConfirmation (..))
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore, withTransaction)
import System.Exit (exitFailure)
import System.IO (hFlush, stdout)
import Text.Read (readMaybe)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241222_operator_conditions where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241222_operator_conditions :: Query
m20241222_operator_conditions =
[sql|
ALTER TABLE operator_usage_conditions ADD COLUMN auto_accepted INTEGER DEFAULT 0;
|]
down_m20241222_operator_conditions :: Query
down_m20241222_operator_conditions =
[sql|
ALTER TABLE operator_usage_conditions DROP COLUMN auto_accepted;
|]
@@ -0,0 +1,47 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241223_chat_tags where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241223_chat_tags :: Query
m20241223_chat_tags =
[sql|
CREATE TABLE chat_tags (
chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users,
chat_tag_text TEXT NOT NULL,
chat_tag_emoji TEXT,
tag_order INTEGER NOT NULL
);
CREATE TABLE chat_tags_chats (
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE
);
CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji);
CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id);
|]
down_m20241223_chat_tags :: Query
down_m20241223_chat_tags =
[sql|
DROP INDEX idx_chat_tags_user_id;
DROP INDEX idx_chat_tags_user_id_chat_tag_text;
DROP INDEX idx_chat_tags_user_id_chat_tag_emoji;
DROP INDEX idx_chat_tags_chats_chat_tag_id;
DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id;
DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id;
DROP TABLE chat_tags_chats;
DROP TABLE chat_tags;
|]
@@ -622,6 +622,20 @@ CREATE TABLE operator_usage_conditions(
conditions_commit TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
,
auto_accepted INTEGER DEFAULT 0
);
CREATE TABLE chat_tags(
chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users,
chat_tag_text TEXT NOT NULL,
chat_tag_emoji TEXT,
tag_order INTEGER NOT NULL
);
CREATE TABLE chat_tags_chats(
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
@@ -929,3 +943,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items(
created_at
);
CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id);
CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(
user_id,
chat_tag_text
);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(
user_id,
chat_tag_emoji
);
CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(
contact_id,
chat_tag_id
);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(
group_id,
chat_tag_id
);
+1
View File
@@ -36,6 +36,7 @@ import Foreign.Storable (poke)
import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands
import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList)
import Simplex.Chat.Mobile.File
import Simplex.Chat.Mobile.Shared
+1 -1
View File
@@ -167,7 +167,7 @@ conditionsRequiredOrDeadline createdAt notifiedAtOrNow =
conditionsDeadline = addUTCTime (31 * nominalDay)
data ConditionsAcceptance
= CAAccepted {acceptedAt :: Maybe UTCTime}
= CAAccepted {acceptedAt :: Maybe UTCTime, autoAccepted :: Bool}
| CARequired {deadline :: Maybe UTCTime}
deriving (Show)
+13 -9
View File
@@ -21,11 +21,14 @@ where
import Control.Applicative ((<|>))
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Data.Bitraversable (bitraverse)
import Data.Int (Int64)
import Data.Maybe (catMaybes, fromMaybe)
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Profiles
@@ -93,8 +96,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
(userId, agentConnId)
getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact
getContactRec_ contactId c = ExceptT $ do
toContact' contactId c
<$> DB.query
chatTags <- getDirectChatTags db contactId
firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $
DB.query
db
[sql|
SELECT
@@ -105,17 +109,16 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [ContactRow'] -> Either StoreError Contact
toContact' contactId conn [(profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)] =
toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact
toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
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}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData}
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ groupMemberId c = ExceptT $ do
firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $
getGroupAndMember_ groupMemberId c = do
gm <- ExceptT $ firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $
DB.query
db
[sql|
@@ -141,9 +144,10 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ?
|]
(groupMemberId, userId, userContactId)
liftIO $ bitraverse (addGroupChatTags db) pure gm
toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember)
toGroupAndMember c (groupInfoRow :. memberRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = Just c})
getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer
+52 -8
View File
@@ -79,6 +79,8 @@ module Simplex.Chat.Store.Direct
setContactCustomData,
setContactUIThemes,
setContactChatDeleted,
getDirectChatTags,
updateDirectChatTags,
)
where
@@ -180,8 +182,8 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash = do
(userId, cReqHash)
getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact)
getContactByConnReqHash db vr user@User {userId} cReqHash =
maybeFirstRow (toContact vr user) $
getContactByConnReqHash db vr user@User {userId} cReqHash = do
ct_ <- maybeFirstRow (toContact vr user []) $
DB.query
db
[sql|
@@ -201,6 +203,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash =
LIMIT 1
|]
(userId, cReqHash, CSActive)
mapM (addDirectChatTags db) ct_
createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection
createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do
@@ -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
@@ -636,8 +640,8 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact
)
insertedRowId db
getContact' :: XContactId -> IO (Maybe Contact)
getContact' xContactId =
maybeFirstRow (toContact vr user) $
getContact' xContactId = do
ct_ <- maybeFirstRow (toContact vr user []) $
DB.query
db
[sql|
@@ -657,13 +661,15 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact
LIMIT 1
|]
(userId, xContactId)
mapM (addDirectChatTags db) ct_
getGroupInfo' :: XContactId -> IO (Maybe GroupInfo)
getGroupInfo' xContactId =
maybeFirstRow (toGroupInfo vr userContactId) $
getGroupInfo' xContactId = do
g_ <- maybeFirstRow (toGroupInfo vr userContactId []) $
DB.query
db
(groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?")
(xContactId, userId, userContactId)
mapM (addGroupChatTags db) g_
getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest)
getContactRequestByXContactId xContactId =
maybeFirstRow toContactRequest $
@@ -819,6 +825,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
chatTs = Just createdAt,
contactGroupMemberId = Nothing,
contactGrpInvSent = False,
chatTags = [],
uiThemes = Nothing,
chatDeleted = False,
customData = Nothing
@@ -845,8 +852,9 @@ getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT Stor
getContact db vr user contactId = getContact_ db vr user contactId False
getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact
getContact_ db vr user@User {userId} contactId deleted =
ExceptT . firstRow (toContact vr user) (SEContactNotFound contactId) $
getContact_ db vr user@User {userId} contactId deleted = do
chatTags <- liftIO $ getDirectChatTags db contactId
ExceptT . firstRow (toContact vr user chatTags) (SEContactNotFound contactId) $
DB.query
db
[sql|
@@ -1018,3 +1026,39 @@ 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)
updateDirectChatTags :: DB.Connection -> ContactId -> [ChatTagId] -> IO ()
updateDirectChatTags db contactId tIds = do
currentTags <- getDirectChatTags db contactId
let tagsToAdd = filter (`notElem` currentTags) tIds
tagsToDelete = filter (`notElem` tIds) currentTags
forM_ tagsToDelete $ untagDirectChat db contactId
forM_ tagsToAdd $ tagDirectChat db 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 -> 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)
addDirectChatTags :: DB.Connection -> Contact -> IO Contact
addDirectChatTags db ct = do
chatTags <- getDirectChatTags db $ contactId' ct
pure (ct :: Contact) {chatTags}
+55 -17
View File
@@ -122,6 +122,8 @@ module Simplex.Chat.Store.Groups
updateUserMemberProfileSentAt,
setGroupCustomData,
setGroupUIThemes,
updateGroupChatTags,
getGroupChatTags,
)
where
@@ -130,6 +132,7 @@ import Control.Monad.Except
import Control.Monad.IO.Class
import Crypto.Random (ChaChaDRG)
import Data.Bifunctor (second)
import Data.Bitraversable (bitraverse)
import Data.Either (rights)
import Data.Int (Int64)
import Data.List (partition, sortOn)
@@ -249,8 +252,8 @@ setGroupLinkMemberRole db User {userId} userContactLinkId memberRole =
DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId)
getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember db User {userId, userContactId} groupMemberId vr =
ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $
getGroupAndMember db User {userId, userContactId} groupMemberId vr = do
gm <- ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $
DB.query
db
[sql|
@@ -285,10 +288,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr =
WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ?
|]
(userId, groupMemberId, userId, userContactId)
liftIO $ bitraverse (addGroupChatTags db) pure gm
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow})
@@ -333,6 +337,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 +406,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
updatedAt = currentTs,
chatTs = Just currentTs,
userMemberProfileSentAt = Just currentTs,
chatTags = [],
uiThemes = Nothing,
customData = Nothing
},
@@ -624,8 +630,8 @@ getUserGroups db vr user@User {userId} = do
rights <$> mapM (runExceptT . getGroup db vr user) groupIds
getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo]
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ =
map (toGroupInfo vr userContactId)
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do
g_ <- map (toGroupInfo vr userContactId [])
<$> DB.query
db
[sql|
@@ -643,6 +649,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ =
AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%')
|]
(userId, userContactId, search, search, search)
mapM (addGroupChatTags db) g_
where
search = fromMaybe "" search_
@@ -1362,8 +1369,8 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange
createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff
getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember))
getViaGroupMember db vr User {userId, userContactId} Contact {contactId} =
maybeFirstRow toGroupAndMember $
getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do
gm_ <- maybeFirstRow toGroupAndMember $
DB.query
db
[sql|
@@ -1399,10 +1406,11 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} =
WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0
|]
(userId, userId, contactId, userContactId)
mapM (bitraverse (addGroupChatTags db) pure) gm_
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow})
@@ -1482,22 +1490,24 @@ 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) $
getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do
chatTags <- getGroupChatTags db groupId
firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $
DB.query
db
(groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?")
@@ -2053,7 +2063,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 +2100,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 +2311,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)
updateGroupChatTags :: DB.Connection -> GroupId -> [ChatTagId] -> IO ()
updateGroupChatTags db gId tIds = do
currentTags <- getGroupChatTags db gId
let tagsToAdd = filter (`notElem` currentTags) tIds
tagsToDelete = filter (`notElem` tIds) currentTags
forM_ tagsToDelete $ untagGroupChat db gId
forM_ tagsToAdd $ tagGroupChat db gId
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 -> 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)
+5 -1
View File
@@ -119,6 +119,8 @@ 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.M20241222_operator_conditions
import Simplex.Chat.Migrations.M20241223_chat_tags
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -237,7 +239,9 @@ 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),
("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions),
("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags)
]
-- | The list of migrations in ascending order by date
+36 -21
View File
@@ -627,13 +627,13 @@ getUpdateServerOperators db presetOps newUser = do
DBNewEntity -> do
op' <- insertOperator op
case (operatorTag op', acceptForSimplex_) of
(Just OTSimplex, Just cond) -> autoAcceptConditions op' cond
(Just OTSimplex, Just cond) -> autoAcceptConditions op' cond now
_ -> pure op'
DBEntityId _ -> do
updateOperator op
getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case
CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds
CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds
CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds now
CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now
ca -> pure op {conditionsAcceptance = ca}
where
insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} =
@@ -667,9 +667,9 @@ getUpdateServerOperators db presetOps newUser = do
(operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles)
opId <- insertedRowId db
pure op {operatorId = DBEntityId opId}
autoAcceptConditions op UsageConditions {conditionsCommit} =
acceptConditions_ db op conditionsCommit Nothing
$> op {conditionsAcceptance = CAAccepted Nothing}
autoAcceptConditions op UsageConditions {conditionsCommit} now =
acceptConditions_ db op conditionsCommit now True
$> op {conditionsAcceptance = CAAccepted (Just now) True}
serverOperatorQuery :: Query
serverOperatorQuery =
@@ -708,7 +708,7 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition
DB.query
db
[sql|
SELECT conditions_commit, accepted_at
SELECT conditions_commit, accepted_at, auto_accepted
FROM operator_usage_conditions
WHERE server_operator_id = ?
ORDER BY operator_usage_conditions_id DESC
@@ -716,10 +716,10 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition
|]
(Only operatorId)
pure $ case operatorAcceptedConds_ of
Just (operatorCommit, acceptedAt_)
Just (operatorCommit, acceptedAt_, autoAccept)
| operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled?
| currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
| otherwise -> CAAccepted acceptedAt_
| otherwise -> CAAccepted acceptedAt_ autoAccept
_ -> CARequired Nothing -- no conditions were accepted for this operator
getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions
@@ -763,24 +763,39 @@ acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> Excep
acceptConditions db condId opIds acceptedAt = do
UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId
operators <- mapM getServerOperator_ opIds
let ts = Just acceptedAt
liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts
liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit acceptedAt False
where
getServerOperator_ opId =
ExceptT $
firstRow toServerOperator (SEOperatorNotFound opId) $
DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId)
acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO ()
acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt =
DB.execute
db
[sql|
INSERT INTO operator_usage_conditions
(server_operator_id, server_operator_tag, conditions_commit, accepted_at)
VALUES (?,?,?,?)
|]
(operatorId, operatorTag, conditionsCommit, acceptedAt)
acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> UTCTime -> Bool -> IO ()
acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt autoAccepted = do
acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit == ?" (operatorId, conditionsCommit)
case acceptedAt_ of
Just Nothing ->
DB.execute
db
(q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?")
(operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted, acceptedAt, autoAccepted)
Just (Just _) ->
DB.execute
db
(q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING")
(operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted)
Nothing ->
DB.execute
db
q
(operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted)
where
q =
[sql|
INSERT INTO operator_usage_conditions
(server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted)
VALUES (?,?,?,?,?)
|]
getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions
getUsageConditionsById_ db conditionsId =
+80 -6
View File
@@ -9,6 +9,7 @@
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Shared where
@@ -391,14 +392,14 @@ type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Mayb
type ContactRow = Only ContactId :. ContactRow'
toContact :: VersionRangeChat -> User -> ContactRow :. MaybeConnectionRow -> Contact
toContact vr user ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) =
toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact
toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
activeConn = toMaybeConnection vr connRow
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 =
@@ -552,14 +553,14 @@ type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageD
type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences))
toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) =
toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) =
let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
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 +593,76 @@ 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 -> Maybe 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, tag_order)
VALUES (?,?,?, COALESCE((SELECT MAX(tag_order) + 1 FROM chat_tags WHERE user_id = ?), 1))
|]
(userId, emoji, text, userId)
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)
updateChatTag :: DB.Connection -> User -> ChatTagId -> Maybe Text -> Text -> IO ()
updateChatTag db User {userId} tId emoji text =
DB.execute
db
[sql|
UPDATE chat_tags
SET chat_tag_emoji = ?, chat_tag_text = ?
WHERE user_id = ? AND chat_tag_id = ?
|]
(emoji, text, userId, tId)
updateChatTagOrder :: DB.Connection -> User -> ChatTagId -> Int -> IO ()
updateChatTagOrder db User {userId} tId order =
DB.execute
db
[sql|
UPDATE chat_tags
SET tag_order = ?
WHERE user_id = ? AND chat_tag_id = ?
|]
(order, userId, tId)
reorderChatTags :: DB.Connection -> User -> [ChatTagId] -> IO ()
reorderChatTags db user tIds =
forM_ (zip [1 ..] tIds) $ \(order, tId) ->
updateChatTagOrder db user tId order
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 = ?
ORDER BY tag_order
|]
(Only userId)
where
toChatTag :: (ChatTagId, Maybe Text, Text) -> ChatTag
toChatTag (chatTagId, chatTagEmoji, chatTagText) = ChatTag {chatTagId, chatTagEmoji, chatTagText}
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)
addGroupChatTags :: DB.Connection -> GroupInfo -> IO GroupInfo
addGroupChatTags db g@GroupInfo {groupId} = do
chatTags <- getGroupChatTags db groupId
pure (g :: GroupInfo) {chatTags}
+2 -1
View File
@@ -14,10 +14,11 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB
import Simplex.Chat (_defaultNtfServers, defaultChatConfig, operatorSimpleXChat)
import Simplex.Chat (defaultChatConfig, operatorSimpleXChat)
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Help (chatWelcome)
import Simplex.Chat.Library.Commands (_defaultNtfServers)
import Simplex.Chat.Operators
import Simplex.Chat.Options
import Simplex.Chat.Terminal.Input
+1 -1
View File
@@ -29,8 +29,8 @@ import Database.SQLite.Simple (Only (..))
import qualified Database.SQLite.Simple as SQL
import Database.SQLite.Simple.QQ (sql)
import GHC.Weak (deRefWeak)
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Styled
+1 -1
View File
@@ -21,8 +21,8 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Time.LocalTime (getCurrentTimeZone)
import Simplex.Chat (execChatCommand, processChatCommand)
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands (execChatCommand, processChatCommand)
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent (CIContent (..), SMsgDirection (..))
+13
View File
@@ -160,6 +160,8 @@ type ContactId = Int64
type ProfileId = Int64
type ChatTagId = Int64
data Contact = Contact
{ contactId :: ContactId,
localDisplayName :: ContactName,
@@ -176,6 +178,7 @@ data Contact = Contact
chatTs :: Maybe UTCTime,
contactGroupMemberId :: Maybe GroupMemberId,
contactGrpInvSent :: Bool,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
chatDeleted :: Bool,
customData :: Maybe CustomData
@@ -380,6 +383,7 @@ data GroupInfo = GroupInfo
updatedAt :: UTCTime,
chatTs :: Maybe UTCTime,
userMemberProfileSentAt :: Maybe UTCTime,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
customData :: Maybe CustomData
}
@@ -1637,6 +1641,13 @@ data CommandData = CommandData
}
deriving (Show)
data ChatTag = ChatTag
{ chatTagId :: Int64,
chatTagText :: Text,
chatTagEmoji :: Maybe 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)
+5 -2
View File
@@ -36,10 +36,11 @@ import Data.Time.Format (defaultTimeLocale, formatTime)
import qualified Data.Version as V
import qualified Network.HTTP.Types as Q
import Numeric (showFFloat)
import Simplex.Chat (defaultChatConfig, maxImageSize)
import Simplex.Chat (defaultChatConfig)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Help
import Simplex.Chat.Library.Commands (maxImageSize)
import Simplex.Chat.Markdown
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent
@@ -96,6 +97,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 +151,7 @@ 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 tags updated"]
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
@@ -1311,7 +1314,7 @@ viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of
viewOpConditions :: ConditionsAcceptance -> Text
viewOpConditions = \case
CAAccepted ts -> viewCond "accepted" ts
CAAccepted ts _ -> viewCond "accepted" ts
CARequired ts -> viewCond "required" ts
where
viewCond w ts = w <> maybe "" (parens . tshow) ts
+3
View File
@@ -27,6 +27,7 @@ import Network.Socket
import Simplex.Chat
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg)
import Simplex.Chat.Core
import Simplex.Chat.Library.Commands
import Simplex.Chat.Options
import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion)
import Simplex.Chat.Store
@@ -463,6 +464,8 @@ smpServerCfg =
logStatsStartTime = 0,
serverStatsLogFile = "tests/smp-server-stats.daily.log",
serverStatsBackupFile = Nothing,
prometheusInterval = Nothing,
prometheusMetricsFile = "tests/smp-server-metrics.txt",
pendingENDInterval = 500000,
ntfDeliveryInterval = 200000,
smpServerVRange = supportedServerSMPRelayVRange,
+1 -1
View File
@@ -14,8 +14,8 @@ import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Network.HTTP.Types.URI (urlEncode)
import Simplex.Chat (roundedFDCount)
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Chat.Library.Internal (roundedFDCount)
import Simplex.Chat.Mobile.File
import Simplex.Chat.Options (ChatOpts (..))
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..))
+3 -3
View File
@@ -9,9 +9,9 @@ import Control.Concurrent (threadDelay)
import qualified Data.ByteString.Char8 as B
import Data.List (intercalate)
import qualified Data.Text as T
import System.Directory (copyFile, doesFileExist, removeFile)
import Simplex.Chat (fixedImagePreview)
import Simplex.Chat.Library.Commands (fixedImagePreview)
import Simplex.Chat.Types (ImageData (..))
import System.Directory (copyFile, doesFileExist, removeFile)
import Test.Hspec hiding (it)
chatForwardTests :: SpecWith FilePath
@@ -740,7 +740,7 @@ testMultiForwardFiles =
-- IDs to forward
let msgId1 = (read msgIdZero :: Int) + 1
msgIds = intercalate "," $ map (show . (msgId1 +)) [0..5]
msgIds = intercalate "," $ map (show . (msgId1 +)) [0 .. 5]
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "Files can be received: 1, 2, 3, 4"
bob <## "5 message(s) out of 6 can be forwarded"
+1 -1
View File
@@ -1,6 +1,6 @@
module ValidNames where
import Simplex.Chat
import Simplex.Chat.Library.Commands
import Test.Hspec
validNameTests :: Spec