Merge branch 'master' into ep/donate-page

This commit is contained in:
M Sarmad Qadeer
2024-08-02 16:26:27 +05:00
81 changed files with 4516 additions and 1422 deletions
-18
View File
@@ -871,24 +871,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
@@ -21,7 +21,6 @@ struct ChatItemForwardingView: View {
@State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert?
@State private var hasSimplexLink_: Bool?
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
var body: some View {
@@ -67,34 +66,6 @@ struct ChatItemForwardingView: View {
}
}
private func prohibitedByPref(_ chat: Chat) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
return switch chat.chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
private var hasSimplexLink: Bool {
if let hasSimplexLink_ { return hasSimplexLink_ }
let r =
if let mcText = ci.content.msgContent?.text,
let parsedMsg = parseSimpleXMarkdown(mcText) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
hasSimplexLink_ = r
return r
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
@@ -102,7 +73,11 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = prohibitedByPref(chat)
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
Button {
if prohibited {
alert = SomeAlert(
+319 -118
View File
@@ -43,6 +43,9 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
var body: some View {
if #available(iOS 16.0, *) {
@@ -80,25 +83,58 @@ struct ChatView: View {
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
}
connectingText()
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
if selectedChatItems == nil {
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
} else {
SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
allowToDeleteSelectedMessagesForAll = forAll
showDeleteSelectedMessages = true
},
moderateItems: {
if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
}
)
}
}
.navigationTitle(cInfo.chatViewName)
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.environmentObject(theme)
.confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) }
}
if allowToDeleteSelectedMessagesForAll {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
if let selected = selectedChatItems {
allowToDeleteSelectedMessagesForAll = false
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages)
}
}
}
}
.onAppear {
loadChat(chat: chat)
initChatView()
selectedChatItems = nil
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
stopAudioPlayer()
if let cId {
selectedChatItems = nil
if let c = chatModel.getChat(cId) {
chat = c
}
@@ -138,7 +174,9 @@ struct ChatView: View {
}
.toolbar {
ToolbarItem(placement: .principal) {
if case let .direct(contact) = cInfo {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else if case let .direct(contact) = cInfo {
Button {
Task {
do {
@@ -192,66 +230,76 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
} label: {
Text("Cancel")
}
} else {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
}
}
case .local:
searchButton()
default:
EmptyView()
}
case .local:
searchButton()
default:
EmptyView()
}
}
}
@@ -553,6 +601,33 @@ struct ChatView: View {
}
}
private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) {
guard let count = selectedChatItems?.count, count > 0 else { return }
AlertManager.shared.showAlert(Alert(
title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"),
message: Text(
groupInfo.fullGroupPreferences.fullDelete.on
? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.")
: (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.")
),
primaryButton: .destructive(Text("Delete")) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages)
}
},
secondaryButton: .cancel()
))
}
private func deletedSelectedMessages() async {
await MainActor.run {
withAnimation {
selectedChatItems = nil
}
}
}
private func loadChatItems(_ cInfo: ChatInfo) {
Task {
if loadingItems || firstPage { return }
@@ -604,7 +679,8 @@ struct ChatView: View {
maxWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems
)
}
@@ -626,6 +702,8 @@ struct ChatView: View {
@State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false
@Binding var selectedChatItems: Set<Int64>?
@State private var allowMenu: Bool = true
var revealed: Bool { chatItem == revealedChatItem }
@@ -642,9 +720,29 @@ struct ChatView: View {
ForEach(items, id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
} else {
chatItemView(chatItem, range, prevItem)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
}
.onAppear {
@@ -689,11 +787,11 @@ struct ChatView: View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
@@ -706,41 +804,64 @@ struct ChatView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, memberImageSize + 14)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await m.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await m.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
}
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
}
.padding(.bottom, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.bottom, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
.padding(.bottom, 5)
}
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
.padding(.bottom, 5)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
if chat.chatInfo.chatType == .group {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
} else {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading)
}
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
}
.padding(.bottom, 5)
}
}
@@ -775,17 +896,17 @@ struct ChatView: View {
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
deleteMessage(.cidmInternal, moderate: false)
}
if let di = deletingItem, di.meta.deletable && !di.localNote {
Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast)
Button(broadcastDeleteButtonText(chat), role: .destructive) {
deleteMessage(.cidmBroadcast, moderate: false)
}
}
}
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages()
deleteMessages(chat, deletingItems, moderate: false)
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
@@ -894,7 +1015,7 @@ struct ChatView: View {
if !live || !ci.meta.isLive {
deleteButton(ci)
}
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd {
moderateButton(ci, groupInfo)
}
} else if ci.meta.itemDeleted != nil {
@@ -918,6 +1039,10 @@ struct ChatView: View {
} else {
EmptyView()
}
if selectedChatItems == nil && ci.canBeDeletedForSelf {
Divider()
selectButton(ci)
}
}
var replyButton: Button<some View> {
@@ -1090,6 +1215,21 @@ struct ChatView: View {
}
}
private func selectButton(_ ci: ChatItem) -> Button<some View> {
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
selectUnselectChatItem(select: true, ci)
}
}
} label: {
Label(
NSLocalizedString("Select", comment: "chat item action"),
systemImage: "checkmark.circle"
)
}
}
private func viewInfoButton(_ ci: ChatItem) -> Button<some View> {
Button {
Task {
@@ -1200,7 +1340,7 @@ struct ChatView: View {
),
primaryButton: .destructive(Text("Delete")) {
deletingItem = ci
deleteMessage(.cidmBroadcast)
deleteMessage(.cidmBroadcast, moderate: true)
},
secondaryButton: .cancel()
))
@@ -1251,47 +1391,46 @@ struct ChatView: View {
}
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
}
private func deleteMessages() {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
do {
let deletedItems = try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemIds: itemIds,
mode: .cidmInternal
)
await MainActor.run {
for di in deletedItems {
m.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
}
} else {
itemIds.append(ci.id)
}
} else {
itemIds.append(ci.id)
}
if select {
if let sel = selectedChatItems {
selectedChatItems = sel.union(itemIds)
}
} else {
itemIds.forEach { selectedChatItems?.remove($0) }
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
let r = if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
moderate,
let (groupInfo, _) = di.memberToModerate(chat.chatInfo) {
try await apiDeleteMemberChatItems(
groupId: groupInfo.apiId,
itemIds: [di.id]
@@ -1320,6 +1459,68 @@ struct ChatView: View {
}
}
}
private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme
var ciId: Int64
@Binding var selectedChatItems: Set<Int64>?
@State var checked: Bool = false
var body: some View {
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
.resizable()
.foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel))
.frame(width: 24, height: 24)
.onAppear {
checked = selectedChatItems?.contains(ciId) == true
}
.onChange(of: selectedChatItems) { selected in
checked = selected?.contains(ciId) == true
}
}
}
}
}
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
do {
let deletedItems = if case .cidmBroadcast = mode,
moderate,
case .group = chat.chatInfo {
try await apiDeleteMemberChatItems(
groupId: chatInfo.apiId,
itemIds: itemIds
)
} else {
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemIds: itemIds,
mode: mode
)
}
await MainActor.run {
for di in deletedItems {
if let toItem = di.toChatItem {
_ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
}
}
await onSuccess()
} catch {
logger.error("ChatView.deleteMessages error: \(error.localizedDescription)")
}
}
}
}
@@ -1120,10 +1120,6 @@ struct ComposeView: View {
}
}
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@@ -0,0 +1,130 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
(deleteEnabled, deleteForEveryoneEnabled, moderateEnabled, _, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && !onlyOwnGroupItems && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -9,8 +9,6 @@
import SwiftUI
import SimpleXChat
let defaultProfileImageCorner = 22.5
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
@@ -34,26 +32,6 @@ struct ProfileImage: View {
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
extension Color {
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
return self
@@ -76,7 +76,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
@@ -143,7 +143,8 @@ struct AppearanceSettings: View {
Text("Themes")
.foregroundColor(theme.colors.secondary)
}
.onChange(of: profileImageCornerRadius) { _ in
.onChange(of: profileImageCornerRadius) { cornerRadius in
profileImageCornerRadiusGroupDefault.set(cornerRadius)
saveThemeToDatabase(nil)
}
.onChange(of: colorMode) { mode in
@@ -70,6 +70,9 @@ struct PrivacySettings: View {
Section {
settingsRow("network", color: theme.colors.secondary) {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
}
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
@@ -365,6 +368,7 @@ struct SimplexLockView: View {
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
@AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false
@State private var performLAToggleReset = false
@State private var performLAModeReset = false
@State private var performLASelfDestructReset = false
@@ -436,6 +440,12 @@ struct SimplexLockView: View {
}
}
if performLA {
Section("Share to SimpleX") {
Toggle("Allow sharing", isOn: $allowShareExtension)
}
}
if performLA && laMode == .passcode {
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
Toggle(isOn: $selfDestruct) {
@@ -460,6 +470,7 @@ struct SimplexLockView: View {
}
}
.onChange(of: performLA) { performLAToggle in
performLAGroupDefault.set(performLAToggle)
prefLANoticeShown = true
if performLAToggleReset {
performLAToggleReset = false
@@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group
let DEFAULT_LA_MODE = "localAuthenticationMode"
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
@@ -28,7 +28,7 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
@@ -165,6 +165,9 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def
func setGroupDefaults() {
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
performLAGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA))
privacyLinkPreviewsGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS))
profileImageCornerRadiusGroupDefault.set(UserDefaults.standard.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS))
}
public class StringDefault {
@@ -772,6 +772,10 @@
<target>Разреши изпращането на изчезващи съобщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Позволи необратимо изтриване на изпратените съобщения. (24 часа)</target>
@@ -1857,6 +1861,10 @@ This is your own one-time link!</source>
<target>Изтрий</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Изтриване на %lld съобщения?</target>
@@ -4304,6 +4312,10 @@ This is your link for group %@!</source>
<target>Несъвместим!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Известия</target>
@@ -5441,6 +5453,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Избери</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5790,6 +5806,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Сподели този еднократен линк за връзка</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Сподели с контактите</target>
@@ -6243,6 +6263,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>Съобщението ще бъде маркирано като модерирано за всички членове.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Ново поколение поверителни съобщения</target>
@@ -8426,10 +8454,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8442,6 +8482,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8450,6 +8494,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8458,10 +8506,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8518,10 +8562,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8530,10 +8582,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8546,6 +8594,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -749,6 +749,10 @@
<target>Povolit odesílání mizících zpráv.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Povolit nevratné smazání odeslaných zpráv. (24 hodin)</target>
@@ -1787,6 +1791,10 @@ This is your own one-time link!</source>
<target>Smazat</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<note>No comment provided by engineer.</note>
@@ -4143,6 +4151,10 @@ This is your link for group %@!</source>
<source>Not compatible!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Oznámení</target>
@@ -5240,6 +5252,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Vybrat</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5584,6 +5600,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Sdílet s kontakty</target>
@@ -6026,6 +6046,14 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován
<target>Zpráva bude pro všechny členy označena jako moderovaná.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Nová generace soukromých zpráv</target>
@@ -8123,10 +8151,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8139,6 +8179,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8147,6 +8191,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8155,10 +8203,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8215,10 +8259,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8227,10 +8279,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8243,6 +8291,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Das Senden von verschwindenden Nachrichten erlauben.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden)</target>
@@ -1894,6 +1898,10 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Löschen</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>%lld Nachrichten löschen?</target>
@@ -4387,6 +4395,10 @@ Das ist Ihr Link für die Gruppe %@!</target>
<target>Nicht kompatibel!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Benachrichtigungen</target>
@@ -5560,6 +5572,10 @@ Aktivieren Sie es in den *Netzwerk &amp; Server* Einstellungen.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Auswählen</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Aktivieren Sie es in den *Netzwerk &amp; Server* Einstellungen.</target>
<target>Teilen Sie diesen Einmal-Einladungslink</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Mit Kontakten teilen</target>
@@ -6392,6 +6412,14 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro
<target>Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Die nächste Generation von privatem Messaging</target>
@@ -8609,10 +8637,22 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ Zuletzt empfangene Nachricht: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,11 @@
<target>Allow sending disappearing messages.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<target>Allow sharing</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Allow to irreversibly delete sent messages. (24 hours)</target>
@@ -1896,6 +1901,11 @@ This is your own one-time link!</target>
<target>Delete</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<target>Delete %lld messages of members?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Delete %lld messages?</target>
@@ -4398,6 +4408,11 @@ This is your link for group %@!</target>
<target>Not compatible!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<target>Nothing selected</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Notifications</target>
@@ -5571,6 +5586,11 @@ Enable in *Network &amp; servers* settings.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Select</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<target>Selected %lld</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5938,6 +5958,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Share this 1-time invite link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<target>Share to SimpleX</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Share with contacts</target>
@@ -6405,6 +6430,16 @@ It can happen because of some bug or when the connection is compromised.</target
<target>The message will be marked as moderated for all members.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<target>The messages will be deleted for all members.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<target>The messages will be marked as moderated for all members.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>The next generation of private messaging</target>
@@ -8626,11 +8661,26 @@ last received msg: %2$@</target>
<target>%@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<target>App is locked!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<target>Cancel</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<target>Cannot access keychain to save database password</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<target>Cannot forward message</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<target>Comment</target>
@@ -8646,6 +8696,11 @@ last received msg: %2$@</target>
<target>Database downgrade required</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<target>Database encrypted!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<target>Database error</target>
@@ -8656,6 +8711,11 @@ last received msg: %2$@</target>
<target>Database passphrase is different from saved in the keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<target>Database passphrase is required to open chat.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<target>Database upgrade required</target>
@@ -8666,11 +8726,6 @@ last received msg: %2$@</target>
<target>Dismiss Sheet</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<target>Encrypted database</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<target>Error preparing file</target>
@@ -8741,11 +8796,21 @@ last received msg: %2$@</target>
<target>Open the app to upgrade the database.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<target>Passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<target>Please create a profile in the SimpleX app</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<target>Selected chat preferences prohibit this message.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<target>Sending File</target>
@@ -8756,11 +8821,6 @@ last received msg: %2$@</target>
<target>Share</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<target>Sharing is not supported when passphrase is not stored in KeyChain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<target>Unknown database error: %@</target>
@@ -8776,6 +8836,11 @@ last received msg: %2$@</target>
<target>Wrong database passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<target>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Permites el envío de mensajes temporales.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Se permite la eliminación irreversible de mensajes. (24 horas)</target>
@@ -1894,6 +1898,10 @@ This is your own one-time link!</source>
<target>Eliminar</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>¿Elimina %lld mensajes?</target>
@@ -4387,6 +4395,10 @@ This is your link for group %@!</source>
<target>¡No compatible!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Notificaciones</target>
@@ -5560,6 +5572,10 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Seleccionar</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<target>Comparte este enlace de un solo uso</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Compartir con contactos</target>
@@ -6392,6 +6412,14 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida.</target>
<target>El mensaje será marcado como moderado para todos los miembros.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>La nueva generación de mensajería privada</target>
@@ -8609,10 +8637,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -744,6 +744,10 @@
<target>Salli katoavien viestien lähettäminen.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Salli lähetettyjen viestien peruuttamaton poistaminen. (24 tuntia)</target>
@@ -1780,6 +1784,10 @@ This is your own one-time link!</source>
<target>Poista</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<note>No comment provided by engineer.</note>
@@ -4132,6 +4140,10 @@ This is your link for group %@!</source>
<source>Not compatible!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Ilmoitukset</target>
@@ -5228,6 +5240,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Valitse</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5571,6 +5587,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Jaa kontaktien kanssa</target>
@@ -6012,6 +6032,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.</t
<target>Viesti merkitään moderoiduksi kaikille jäsenille.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Seuraavan sukupolven yksityisviestit</target>
@@ -8107,10 +8135,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8123,6 +8163,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8131,6 +8175,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8139,10 +8187,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8199,10 +8243,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8211,10 +8263,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8227,6 +8275,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Autorise lenvoi de messages éphémères.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Autoriser la suppression irréversible de messages envoyés. (24 heures)</target>
@@ -1894,6 +1898,10 @@ Il s'agit de votre propre lien unique !</target>
<target>Supprimer</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Supprimer %lld messages ?</target>
@@ -4387,6 +4395,10 @@ Voici votre lien pour le groupe %@ !</target>
<target>Non compatible !</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Notifications</target>
@@ -5560,6 +5572,10 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Choisir</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<target>Partager ce lien d'invitation unique</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Partager avec vos contacts</target>
@@ -6391,6 +6411,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise.
<target>Le message sera marqué comme modéré pour tous les membres.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>La nouvelle génération de messagerie privée</target>
@@ -8608,10 +8636,22 @@ dernier message reçu: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8624,6 +8664,10 @@ dernier message reçu: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8632,6 +8676,10 @@ dernier message reçu: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8640,10 +8688,6 @@ dernier message reçu: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8700,10 +8744,18 @@ dernier message reçu: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8712,10 +8764,6 @@ dernier message reçu: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8728,6 +8776,10 @@ dernier message reçu: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Az eltűnő üzenetek küldése engedélyezve van.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Elküldött üzenetek végleges törlésének engedélyezése. (24 óra)</target>
@@ -1894,6 +1898,10 @@ Ez az egyszer használatos hivatkozása!</target>
<target>Törlés</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Töröl %lld üzenetet?</target>
@@ -4387,6 +4395,10 @@ Ez az ön hivatkozása a(z) %@ csoporthoz!</target>
<target>Nem kompatibilis!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Értesítések</target>
@@ -5560,6 +5572,10 @@ Engedélyezze a beállításokban a *Hálózat és kiszolgálók* menüpontban.<
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Választás</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Engedélyezze a beállításokban a *Hálózat és kiszolgálók* menüpontban.<
<target>Egyszer használatos meghívó hivatkozás megosztása</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Megosztás ismerősökkel</target>
@@ -6392,6 +6412,14 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő.</target>
<target>Az üzenet minden tag számára moderáltként lesz megjelölve.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>A privát üzenetküldés következő generációja</target>
@@ -8609,10 +8637,22 @@ utoljára fogadott üzenet: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ utoljára fogadott üzenet: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ utoljára fogadott üzenet: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ utoljára fogadott üzenet: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ utoljára fogadott üzenet: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ utoljára fogadott üzenet: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ utoljára fogadott üzenet: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Permetti l'invio di messaggi a tempo.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore)</target>
@@ -1894,6 +1898,10 @@ Questo è il tuo link una tantum!</target>
<target>Elimina</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Eliminare %lld messaggi?</target>
@@ -4387,6 +4395,10 @@ Questo è il tuo link per il gruppo %@!</target>
<target>Non compatibile!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Notifiche</target>
@@ -5560,6 +5572,10 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Seleziona</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<target>Condividi questo link di invito una tantum</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Condividi con i contatti</target>
@@ -6392,6 +6412,14 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.</ta
<target>Il messaggio sarà segnato come moderato per tutti i membri.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>La nuova generazione di messaggistica privata</target>
@@ -8609,10 +8637,22 @@ ultimo msg ricevuto: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ ultimo msg ricevuto: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ ultimo msg ricevuto: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ ultimo msg ricevuto: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ ultimo msg ricevuto: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ ultimo msg ricevuto: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ ultimo msg ricevuto: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -762,6 +762,10 @@
<target>消えるメッセージの送信を許可する。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>送信済みメッセージの永久削除を許可する。(24時間)</target>
@@ -1804,6 +1808,10 @@ This is your own one-time link!</source>
<target>削除</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<note>No comment provided by engineer.</note>
@@ -4157,6 +4165,10 @@ This is your link for group %@!</source>
<source>Not compatible!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>通知</target>
@@ -5253,6 +5265,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>選択</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5589,6 +5605,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>連絡先と共有する</target>
@@ -6031,6 +6051,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>メッセージは、すべてのメンバーに対してモデレートされたものとして表示されます。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>次世代のプライバシー・メッセンジャー</target>
@@ -8125,10 +8153,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8141,6 +8181,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8149,6 +8193,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8157,10 +8205,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8217,10 +8261,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8229,10 +8281,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8245,6 +8293,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Toestaan dat verdwijnende berichten worden verzonden.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Sta toe om verzonden berichten onomkeerbaar te verwijderen. (24 uur)</target>
@@ -1894,6 +1898,10 @@ Dit is uw eigen eenmalige link!</target>
<target>Verwijderen</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>%lld berichten verwijderen?</target>
@@ -4387,6 +4395,10 @@ Dit is jouw link voor groep %@!</target>
<target>Niet compatibel!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Meldingen</target>
@@ -5560,6 +5572,10 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Selecteer</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<target>Deel deze eenmalige uitnodigingslink</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Delen met contacten</target>
@@ -6392,6 +6412,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast.</target>
<target>Het bericht wordt gemarkeerd als gemodereerd voor alle leden.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>De volgende generatie privéberichten</target>
@@ -8609,10 +8637,22 @@ laatst ontvangen bericht: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ laatst ontvangen bericht: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ laatst ontvangen bericht: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ laatst ontvangen bericht: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ laatst ontvangen bericht: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ laatst ontvangen bericht: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ laatst ontvangen bericht: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -783,6 +783,10 @@
<target>Zezwól na wysyłanie znikających wiadomości.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny)</target>
@@ -1894,6 +1898,10 @@ To jest twój jednorazowy link!</target>
<target>Usuń</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Usunąć %lld wiadomości?</target>
@@ -4387,6 +4395,10 @@ To jest twój link do grupy %@!</target>
<target>Nie kompatybilny!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Powiadomienia</target>
@@ -5560,6 +5572,10 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Wybierz</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5927,6 +5943,10 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<target>Udostępnij ten jednorazowy link</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Udostępnij kontaktom</target>
@@ -6392,6 +6412,14 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom
<target>Wiadomość zostanie oznaczona jako moderowana dla wszystkich członków.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Następna generacja prywatnych wiadomości</target>
@@ -8609,10 +8637,22 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8625,6 +8665,10 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8633,6 +8677,10 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8641,10 +8689,6 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8701,10 +8745,18 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8713,10 +8765,6 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8729,6 +8777,10 @@ ostatnia otrzymana wiadomość: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -773,6 +773,10 @@
<target>Разрешить посылать исчезающие сообщения.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Разрешить необратимо удалять отправленные сообщения. (24 часа)</target>
@@ -1861,6 +1865,10 @@ This is your own one-time link!</source>
<target>Удалить</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Удалить %lld сообщений?</target>
@@ -4320,6 +4328,10 @@ This is your link for group %@!</source>
<target>Несовместимая версия!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Уведомления</target>
@@ -5464,6 +5476,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Выбрать</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5817,6 +5833,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Поделиться одноразовой ссылкой-приглашением</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Поделиться с контактами</target>
@@ -6273,6 +6293,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>Сообщение будет помечено как удаленное для всех членов группы.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Новое поколение приватных сообщений</target>
@@ -8466,10 +8494,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8482,6 +8522,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8490,6 +8534,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8498,10 +8546,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8558,10 +8602,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8571,10 +8623,6 @@ last received msg: %2$@</source>
<target>Поделиться</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8587,6 +8635,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -736,6 +736,10 @@
<target>อนุญาตให้ส่งข้อความที่จะหายไปหลังปิดแชท (disappearing message)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>อนุญาตให้ลบข้อความที่ส่งไปแล้วอย่างถาวร</target>
@@ -1769,6 +1773,10 @@ This is your own one-time link!</source>
<target>ลบ</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<note>No comment provided by engineer.</note>
@@ -4113,6 +4121,10 @@ This is your link for group %@!</source>
<source>Not compatible!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>การแจ้งเตือน</target>
@@ -5207,6 +5219,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>เลือก</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5548,6 +5564,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>แชร์กับผู้ติดต่อ</target>
@@ -5988,6 +6008,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>ข้อความจะถูกทำเครื่องหมายว่ากลั่นกรองสำหรับสมาชิกทุกคน</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>การส่งข้อความส่วนตัวรุ่นต่อไป</target>
@@ -8075,10 +8103,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8091,6 +8131,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8099,6 +8143,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8107,10 +8155,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8167,10 +8211,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8179,10 +8231,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8195,6 +8243,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -773,6 +773,10 @@
<target>Kendiliğinden yok olan mesajlar göndermeye izin ver.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Gönderilen mesajların kalıcı olarak silinmesine izin ver. (24 saat içinde)</target>
@@ -1862,6 +1866,10 @@ Bu senin kendi tek kullanımlık bağlantın!</target>
<target>Sil</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>%lld mesaj silinsin mi?</target>
@@ -4322,6 +4330,10 @@ Bu senin grup için bağlantın %@!</target>
<target>Uyumlu değil!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Bildirimler</target>
@@ -5466,6 +5478,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Seç</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5819,6 +5835,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Bu tek kullanımlık bağlantı davetini paylaş</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Kişilerle paylaş</target>
@@ -6275,6 +6295,14 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir.
<target>Mesaj tüm üyeler için yönetilmiş olarak işaretlenecektir.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Gizli mesajlaşmanın yeni nesli</target>
@@ -8471,10 +8499,22 @@ son alınan msj: %2$@</target>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8487,6 +8527,10 @@ son alınan msj: %2$@</target>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8495,6 +8539,10 @@ son alınan msj: %2$@</target>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8503,10 +8551,6 @@ son alınan msj: %2$@</target>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8563,10 +8607,18 @@ son alınan msj: %2$@</target>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8575,10 +8627,6 @@ son alınan msj: %2$@</target>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8591,6 +8639,10 @@ son alınan msj: %2$@</target>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -773,6 +773,10 @@
<target>Дозволити надсилання зникаючих повідомлень.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>Дозволяє безповоротно видаляти надіслані повідомлення. (24 години)</target>
@@ -1862,6 +1866,10 @@ This is your own one-time link!</source>
<target>Видалити</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<target>Видалити %lld повідомлень?</target>
@@ -4322,6 +4330,10 @@ This is your link for group %@!</source>
<target>Не сумісні!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>Сповіщення</target>
@@ -5466,6 +5478,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>Виберіть</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5819,6 +5835,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Поділіться цим одноразовим посиланням-запрошенням</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>Поділіться з контактами</target>
@@ -6275,6 +6295,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>Повідомлення буде позначено як модероване для всіх учасників.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>Наступне покоління приватних повідомлень</target>
@@ -8471,10 +8499,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8487,6 +8527,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8495,6 +8539,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8503,10 +8551,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8563,10 +8607,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8575,10 +8627,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8591,6 +8639,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -759,6 +759,10 @@
<target>允许发送限时消息。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow sharing" xml:space="preserve">
<source>Allow sharing</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Allow to irreversibly delete sent messages. (24 hours)" xml:space="preserve">
<source>Allow to irreversibly delete sent messages. (24 hours)</source>
<target>允许不可撤回地删除已发送消息。</target>
@@ -1835,6 +1839,10 @@ This is your own one-time link!</source>
<target>删除</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Delete %lld messages of members?" xml:space="preserve">
<source>Delete %lld messages of members?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete %lld messages?" xml:space="preserve">
<source>Delete %lld messages?</source>
<note>No comment provided by engineer.</note>
@@ -4263,6 +4271,10 @@ This is your link for group %@!</source>
<target>不兼容!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Nothing selected" xml:space="preserve">
<source>Nothing selected</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Notifications" xml:space="preserve">
<source>Notifications</source>
<target>通知</target>
@@ -5391,6 +5403,10 @@ Enable in *Network &amp; servers* settings.</source>
<trans-unit id="Select" xml:space="preserve">
<source>Select</source>
<target>选择</target>
<note>chat item action</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
@@ -5740,6 +5756,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>分享此一次性邀请链接</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share to SimpleX" xml:space="preserve">
<source>Share to SimpleX</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share with contacts" xml:space="preserve">
<source>Share with contacts</source>
<target>与联系人分享</target>
@@ -6193,6 +6213,14 @@ It can happen because of some bug or when the connection is compromised.</source
<target>该消息将对所有成员标记为已被管理员移除。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be deleted for all members." xml:space="preserve">
<source>The messages will be deleted for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The messages will be marked as moderated for all members." xml:space="preserve">
<source>The messages will be marked as moderated for all members.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The next generation of private messaging" xml:space="preserve">
<source>The next generation of private messaging</source>
<target>下一代私密通讯软件</target>
@@ -8353,10 +8381,22 @@ last received msg: %2$@</source>
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="App is locked!" xml:space="preserve">
<source>App is locked!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot forward message" xml:space="preserve">
<source>Cannot forward message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
@@ -8369,6 +8409,10 @@ last received msg: %2$@</source>
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database encrypted!" xml:space="preserve">
<source>Database encrypted!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
@@ -8377,6 +8421,10 @@ last received msg: %2$@</source>
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is required to open chat." xml:space="preserve">
<source>Database passphrase is required to open chat.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
@@ -8385,10 +8433,6 @@ last received msg: %2$@</source>
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
@@ -8445,10 +8489,18 @@ last received msg: %2$@</source>
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Passphrase" xml:space="preserve">
<source>Passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected chat preferences prohibit this message." xml:space="preserve">
<source>Selected chat preferences prohibit this message.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
@@ -8457,10 +8509,6 @@ last received msg: %2$@</source>
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
@@ -8473,6 +8521,10 @@ last received msg: %2$@</source>
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can allow sharing in Privacy &amp; Security / SimpleX Lock settings." xml:space="preserve">
<source>You can allow sharing in Privacy &amp; Security / SimpleX Lock settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
+169 -129
View File
@@ -19,14 +19,16 @@ private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000
class ShareModel: ObservableObject {
@Published var sharedContent: SharedContent?
@Published var chats = Array<ChatData>()
@Published var profileImages = Dictionary<ChatInfo.ID, UIImage>()
@Published var search = String()
@Published var comment = String()
@Published var chats: [ChatData] = []
@Published var profileImages: [ChatInfo.ID: UIImage] = [:]
@Published var search = ""
@Published var comment = ""
@Published var selected: ChatData?
@Published var isLoaded = false
@Published var bottomBar: BottomBar = .loadingSpinner
@Published var errorAlert: ErrorAlert?
@Published var hasSimplexLink = false
@Published var alertRequiresPassword = false
enum BottomBar {
case sendButton
@@ -48,9 +50,22 @@ class ShareModel: ObservableObject {
private var itemProvider: NSItemProvider?
var isSendDisbled: Bool { sharedContent == nil || selected == nil }
var isSendDisbled: Bool { sharedContent == nil || selected == nil || isProhibited(selected) }
var isLinkPreview: Bool {
switch sharedContent {
case .url: true
default: false
}
}
var filteredChats: Array<ChatData> {
func isProhibited(_ chat: ChatData?) -> Bool {
if let chat, let sharedContent {
sharedContent.prohibited(in: chat, hasSimplexLink: hasSimplexLink)
} else { false }
}
var filteredChats: [ChatData] {
search.isEmpty
? filterChatsToForwardTo(chats: chats)
: filterChatsToForwardTo(chats: chats)
@@ -58,6 +73,10 @@ class ShareModel: ObservableObject {
}
func setup(context: NSExtensionContext) {
if performLAGroupDefault.get() && !allowShareExtensionGroupDefault.get() {
errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Privacy & Security / SimpleX Lock settings.")
return
}
if let item = context.inputItems.first as? NSExtensionItem,
let itemProvider = item.attachments?.first {
self.itemProvider = itemProvider
@@ -67,43 +86,47 @@ class ShareModel: ObservableObject {
apiSuspendChat(expired: $0)
}
}
// Init Chat
Task {
if let e = initChat() {
await MainActor.run { errorAlert = e }
} else {
// Load Chats
Task {
switch fetchChats() {
case let .success(chats):
// Decode base64 images on background thread
let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in
if let profileImage = chatData.chatInfo.image,
let uiImage = UIImage(base64Encoded: profileImage) {
dict[chatData.id] = uiImage
}
setup()
}
}
func setup(with dbKey: String? = nil) {
// Init Chat
Task {
if let e = initChat(with: dbKey) {
await MainActor.run { errorAlert = e }
} else {
// Load Chats
Task {
switch fetchChats() {
case let .success(chats):
// Decode base64 images on background thread
let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in
if let profileImage = chatData.chatInfo.image,
let uiImage = UIImage(base64Encoded: profileImage) {
dict[chatData.id] = uiImage
}
await MainActor.run {
self.chats = chats
self.profileImages = profileImages
withAnimation { isLoaded = true }
}
case let .failure(error):
await MainActor.run { errorAlert = error }
}
await MainActor.run {
self.chats = chats
self.profileImages = profileImages
withAnimation { isLoaded = true }
}
case let .failure(error):
await MainActor.run { errorAlert = error }
}
// Process Attachment
Task {
switch await self.itemProvider!.sharedContent() {
case let .success(chatItemContent):
await MainActor.run {
self.sharedContent = chatItemContent
self.bottomBar = .sendButton
if case let .text(string) = chatItemContent { comment = string }
}
case let .failure(errorAlert):
await MainActor.run { self.errorAlert = errorAlert }
}
// Process Attachment
Task {
switch await getSharedContent(self.itemProvider!) {
case let .success(chatItemContent):
await MainActor.run {
self.sharedContent = chatItemContent
self.bottomBar = .sendButton
if case let .text(string) = chatItemContent { comment = string }
}
case let .failure(errorAlert):
await MainActor.run { self.errorAlert = errorAlert }
}
}
}
@@ -145,14 +168,15 @@ class ShareModel: ObservableObject {
}
}
private func initChat() -> ErrorAlert? {
private func initChat(with dbKey: String? = nil) -> ErrorAlert? {
do {
if hasChatCtrl() {
if hasChatCtrl() && dbKey == nil {
try apiActivateChat()
} else {
resetChatCtrl() // Clears retained migration result
registerGroupDefaults()
haskell_init_se()
let (_, result) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation())
let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation())
if let e = migrationError(result) { return e }
try apiSetAppFilePaths(
filesFolder: getAppFilesDirectory().path,
@@ -171,8 +195,9 @@ class ShareModel: ObservableObject {
private func migrationError(_ r: DBMigrationResult) -> ErrorAlert? {
let useKeychain = storeDBPassphraseGroupDefault.get()
let storedDBKey = kcDatabasePassword.get()
// This switch duplicates DatabaseErrorView.
// TODO allow entering passphrase and make messages the same as in DatabaseErrorView.
if case .errorNotADatabase = r {
Task { await MainActor.run { self.alertRequiresPassword = true } }
}
return switch r {
case .errorNotADatabase:
if useKeychain && storedDBKey != nil && storedDBKey != "" {
@@ -182,8 +207,8 @@ class ShareModel: ObservableObject {
)
} else {
ErrorAlert(
title: "Encrypted database",
message: "Sharing is not supported when passphrase is not stored in KeyChain."
title: "Database encrypted!",
message: "Database passphrase is required to open chat."
)
}
case let .errorMigration(_, migrationError):
@@ -363,100 +388,104 @@ enum SharedContent {
switch self {
case let .image(preview, _): .image(text: comment, image: preview)
case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration)
case let .url(preview): .link(text: comment, preview: preview)
case let .url(preview): .link(text: preview.uri.absoluteString + (comment == "" ? "" : "\n" + comment), preview: preview)
case .text: .text(comment)
case .data: .file(comment)
}
}
func prohibited(in chatData: ChatData, hasSimplexLink: Bool) -> Bool {
chatData.prohibitedByPref(
hasSimplexLink: hasSimplexLink,
isMediaOrFileAttachment: cryptoFile != nil,
isVoice: false
)
}
}
extension NSItemProvider {
fileprivate func sharedContent() async -> Result<SharedContent, ErrorAlert> {
if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) {
switch type {
// Prepare Image message
case .image:
// Animated
return if hasItemConformingToTypeIdentifier(UTType.gif.identifier) {
if let url = try? await inPlaceUrl(type: type),
let data = try? Data(contentsOf: url),
let image = UIImage(data: data),
let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
// Static
} else {
if let url = try? await inPlaceUrl(type: type),
let image = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE),
let cryptoFile = saveImage(image),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
}
// Prepare Movie message
case .movie:
fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedContent, ErrorAlert> {
if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) {
switch type {
// Prepare Image message
case .image:
// Animated
return if ip.hasItemConformingToTypeIdentifier(UTType.gif.identifier) {
if let url = try? await inPlaceUrl(type: type),
let trancodedUrl = await transcodeVideo(from: url),
let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE),
let cryptoFile = moveTempFileFromURL(trancodedUrl) {
try? FileManager.default.removeItem(at: trancodedUrl)
return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile))
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Data message
case .fileURL:
if let url = try? await inPlaceUrl(type: .data) {
if isFileTooLarge(for: url) {
let sizeString = ByteCountFormatter.string(
fromByteCount: Int64(getMaxFileSize(.xftp)),
countStyle: .binary
)
return .failure(
ErrorAlert(
title: "Large file!",
message: "Currently maximum supported file size is \(sizeString)."
)
)
}
if let file = saveFileFromURL(url) {
return .success(.data(cryptoFile: file))
}
}
return .failure(ErrorAlert("Error preparing file"))
// Prepare Link message
case .url:
if let url = try? await loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent =
// Option to disable previews needs to be taken into account
// if let linkPreview = await getLinkPreview(for: url) {
// .url(preview: linkPreview)
// } else {
.text(string: url.absoluteString)
// }
return .success(content)
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Text message
case .text:
return if let text = try? await loadItem(forTypeIdentifier: type.identifier) as? String {
.success(.text(string: text))
let data = try? Data(contentsOf: url),
let image = UIImage(data: data),
let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
// Static
} else {
if let image = await staticImage(),
let cryptoFile = saveImage(image),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) {
.success(.image(preview: preview, cryptoFile: cryptoFile))
} else { .failure(ErrorAlert("Error preparing message")) }
default: return .failure(ErrorAlert("Unsupported format"))
}
} else {
return .failure(ErrorAlert("Unsupported format"))
// Prepare Movie message
case .movie:
if let url = try? await inPlaceUrl(type: type),
let trancodedUrl = await transcodeVideo(from: url),
let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(),
let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE),
let cryptoFile = moveTempFileFromURL(trancodedUrl) {
try? FileManager.default.removeItem(at: trancodedUrl)
return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile))
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Data message
case .fileURL:
if let url = try? await inPlaceUrl(type: .data) {
if isFileTooLarge(for: url) {
let sizeString = ByteCountFormatter.string(
fromByteCount: Int64(getMaxFileSize(.xftp)),
countStyle: .binary
)
return .failure(
ErrorAlert(
title: "Large file!",
message: "Currently maximum supported file size is \(sizeString)."
)
)
}
if let file = saveFileFromURL(url) {
return .success(.data(cryptoFile: file))
}
}
return .failure(ErrorAlert("Error preparing file"))
// Prepare Link message
case .url:
if let url = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent =
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
.url(preview: linkPreview)
} else {
.text(string: url.absoluteString)
}
return .success(content)
} else { return .failure(ErrorAlert("Error preparing message")) }
// Prepare Text message
case .text:
return if let text = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? String {
.success(.text(string: text))
} else { .failure(ErrorAlert("Error preparing message")) }
default: return .failure(ErrorAlert("Unsupported format"))
}
} else {
return .failure(ErrorAlert("Unsupported format"))
}
private func inPlaceUrl(type: UTType) async throws -> URL {
func inPlaceUrl(type: UTType) async throws -> URL {
try await withCheckedThrowingContinuation { cont in
let _ = loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in
let _ = ip.loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in
if let url = url {
cont.resume(returning: url)
} else if let error = error {
@@ -468,12 +497,23 @@ extension NSItemProvider {
}
}
private func firstMatching(of types: Array<UTType>) -> UTType? {
func firstMatching(of types: Array<UTType>) -> UTType? {
for type in types {
if hasItemConformingToTypeIdentifier(type.identifier) { return type }
if ip.hasItemConformingToTypeIdentifier(type.identifier) { return type }
}
return nil
}
func staticImage() async -> UIImage? {
if let url = try? await inPlaceUrl(type: .image),
let downsampledImage = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE) {
downsampledImage
} else {
/// Fallback to loading image directly from `ItemProvider`
/// in case loading from disk is not possible. Required for sharing screenshots.
try? await ip.loadItem(forTypeIdentifier: UTType.image.identifier) as? UIImage
}
}
}
+43 -16
View File
@@ -12,24 +12,39 @@ import SimpleXChat
struct ShareView: View {
@ObservedObject var model: ShareModel
@Environment(\.colorScheme) var colorScheme
@State private var password = String()
@AppStorage(GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS, store: groupDefaults) private var radius = defaultProfileImageCorner
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
if model.isLoaded {
List(model.filteredChats) { chat in
let isProhibited = model.isProhibited(chat)
let isSelected = model.selected == chat
HStack {
profileImage(
chatInfoId: chat.chatInfo.id,
systemFallback: chatIconName(chat.chatInfo),
iconName: chatIconName(chat.chatInfo),
size: 30
)
Text(chat.chatInfo.displayName)
Text(chat.chatInfo.displayName).foregroundStyle(
isProhibited ? .secondary : .primary
)
Spacer()
radioButton(selected: chat == model.selected)
radioButton(selected: isSelected && !isProhibited)
}
.contentShape(Rectangle())
.onTapGesture { model.selected = model.selected == chat ? nil : chat }
.onTapGesture {
if isProhibited {
model.errorAlert = ErrorAlert(
title: "Cannot forward message",
message: "Selected chat preferences prohibit this message."
) { Button("Ok", role: .cancel) { } }
} else {
model.selected = isSelected ? nil : chat
}
}
.tag(chat)
}
} else {
@@ -53,7 +68,19 @@ struct ShareView: View {
placement: .navigationBarDrawer(displayMode: .always)
)
.alert($model.errorAlert) { alert in
Button("Ok") { model.completion() }
if model.alertRequiresPassword {
SecureField("Passphrase", text: $password)
Button("Ok") {
model.setup(with: password)
password = String()
}
Button("Cancel", role: .cancel) { model.completion() }
} else {
Button("Ok") { model.completion() }
}
}
.onChange(of: model.comment) {
model.hasSimplexLink = hasSimplexLink($0)
}
}
@@ -66,7 +93,7 @@ struct ShareView: View {
HStack {
Group {
if #available(iOSApplicationExtension 16.0, *) {
TextField("Comment", text: $model.comment, axis: .vertical)
TextField("Comment", text: $model.comment, axis: .vertical).lineLimit(6)
} else {
TextField("Comment", text: $model.comment)
}
@@ -159,17 +186,17 @@ struct ShareView: View {
.background(Material.ultraThin)
}
private func profileImage(chatInfoId: ChatInfo.ID, systemFallback: String, size: Double) -> some View {
Group {
if let uiImage = model.profileImages[chatInfoId] {
Image(uiImage: uiImage).resizable()
} else {
Image(systemName: systemFallback).resizable()
}
@ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View {
if let uiImage = model.profileImages[chatInfoId] {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
} else {
Image(systemName: iconName)
.resizable()
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: size, height: size)
// add background when adding themes to SE
// .background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear))
}
.foregroundStyle(Color(.tertiaryLabel))
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.225, style: .continuous))
}
private func radioButton(selected: Bool) -> some View {
@@ -193,6 +193,7 @@
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; };
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 */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; };
@@ -529,6 +530,7 @@
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = "<group>"; };
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
@@ -716,6 +718,7 @@
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */,
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */,
);
path = Chat;
sourceTree = "<group>";
@@ -1426,6 +1429,7 @@
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */,
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */,
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */,
64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */,
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */,
+1 -1
View File
@@ -2115,7 +2115,7 @@ public struct AppSettings: Codable, Equatable, Hashable {
public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
public var iosCallKitEnabled: Bool? = nil
public var iosCallKitCallsInRecents: Bool? = nil
public var uiProfileImageCornerRadius: Float? = nil
public var uiProfileImageCornerRadius: Double? = nil
public var uiColorScheme: String? = nil
public var uiDarkColorScheme: String? = nil
public var uiCurrentThemeIds: [String: String]? = nil
+21
View File
@@ -11,6 +11,8 @@ import SwiftUI
public let appSuspendTimeout: Int = 15 // seconds
public let defaultProfileImageCorner: Double = 22.5
let GROUP_DEFAULT_APP_STATE = "appState"
let GROUP_DEFAULT_NSE_STATE = "nseState"
let GROUP_DEFAULT_SE_STATE = "seState"
@@ -20,11 +22,18 @@ public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun"
let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode"
public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used
public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used
// replaces DEFAULT_PERFORM_LA
let GROUP_DEFAULT_PERFORM_LA = "performLocalAuthentication"
public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension"
// replaces DEFAULT_PRIVACY_LINK_PREVIEWS
let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles"
public let GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS = "privacyAskToApproveRelays"
// replaces DEFAULT_PROFILE_IMAGE_CORNER_RADIUS
public let GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount"
let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts"
let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode"
@@ -72,10 +81,14 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false,
GROUP_DEFAULT_PERFORM_LA: true,
GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
@@ -190,6 +203,12 @@ public let ntfPreviewModeGroupDefault = EnumDefault<NotificationPreviewMode>(
public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO)
public let performLAGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PERFORM_LA)
public let allowShareExtensionGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_ALLOW_SHARE_EXTENSION)
public let privacyLinkPreviewsGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS)
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES)
@@ -197,6 +216,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef
public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS)
public let profileImageCornerRadiusGroupDefault = Default<Double>(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
+8 -1
View File
@@ -2454,13 +2454,16 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
}
}
public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember)? {
public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? {
switch (chatInfo, chatDir) {
case let (.group(groupInfo), .groupRcv(groupMember)):
let m = groupInfo.membership
return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil
? (groupInfo, groupMember)
: nil
case let (.group(groupInfo), .groupSnd):
let m = groupInfo.membership
return m.memberRole >= .admin ? (groupInfo, nil) : nil
default: return nil
}
}
@@ -2475,6 +2478,10 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
}
}
public var canBeDeletedForSelf: Bool {
(content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete
}
public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> ChatItem {
ChatItem(
chatDir: dir,
+51
View File
@@ -14,6 +14,45 @@ public protocol ChatLike {
var chatStats: ChatStats { get }
}
extension ChatLike {
public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public func prohibitedByPref(
hasSimplexLink: Bool,
isMediaOrFileAttachment: Bool,
isVoice: Bool
) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !groupFeatureEnabled(.simplexLinks)
let fileProhibited = isMediaOrFileAttachment && !groupFeatureEnabled(.files)
let voiceProhibited = isVoice && !chatInfo.featureEnabled(.voice)
return switch chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
}
public func filterChatsToForwardTo<C: ChatLike>(chats: [C]) -> [C] {
var filteredChats = chats.filter { c in
c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo)
@@ -60,3 +99,15 @@ public func chatIconName(_ cInfo: ChatInfo) -> String {
default: "circle.fill"
}
}
public func hasSimplexLink(_ text: String?) -> Bool {
if let text, let parsedMsg = parseSimpleXMarkdown(text) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
}
public func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
+18 -18
View File
@@ -72,25 +72,25 @@ extension View {
_ errorAlert: Binding<ErrorAlert?>,
@ViewBuilder actions: (ErrorAlert) -> A = { _ in EmptyView() }
) -> some View {
if let alert = errorAlert.wrappedValue {
self.alert(
alert.title,
isPresented: Binding<Bool>(
get: { errorAlert.wrappedValue != nil },
set: { if !$0 { errorAlert.wrappedValue = nil } }
),
actions: {
if let actions_ = alert.actions {
actions_()
} else {
actions(alert)
}
},
message: {
if let message = alert.message { Text(message) }
alert(
errorAlert.wrappedValue?.title ?? "",
isPresented: Binding<Bool>(
get: { errorAlert.wrappedValue != nil },
set: { if !$0 { errorAlert.wrappedValue = nil } }
),
actions: {
if let actions_ = errorAlert.wrappedValue?.actions {
actions_()
} else {
if let alert = errorAlert.wrappedValue { actions(alert) }
}
)
} else { self }
},
message: {
if let message = errorAlert.wrappedValue?.message {
Text(message)
}
}
)
}
}
+19
View File
@@ -428,3 +428,22 @@ public func getLinkPreview(for url: URL) async -> LinkPreview? {
getLinkPreview(url: url) { cont.resume(returning: $0) }
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
@@ -18,7 +18,7 @@ import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DefaultTheme
@@ -27,7 +27,6 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.withLock
import java.io.*
import java.util.*
import java.util.concurrent.TimeUnit
@@ -86,7 +85,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
Lifecycle.Event.ON_START -> {
isAppOnForeground = true
if (chatModel.chatRunning.value == true) {
updatingChatsMutex.withLock {
withChats {
kotlin.runCatching {
val currentUserId = chatModel.currentUser.value?.userId
val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId()))
@@ -99,7 +98,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
}
chatModel.updateChats(chats)
updateChats(chats)
}
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
}
@@ -6,6 +6,7 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
@@ -19,8 +20,14 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (oneHandUI != null && oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
if (!disabled) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
@@ -36,7 +36,7 @@ private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val source = remember { MutableInteractionSource() }
@@ -78,7 +78,7 @@ fun AppearanceScope.AppearanceLayout(
Modifier.fillMaxWidth(),
) {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) {
SectionView(stringResource(MR.strings.settings_section_title_interface), padding = PaddingValues()) {
val context = LocalContext.current
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// SectionItemWithValue(
@@ -104,6 +104,8 @@ fun AppearanceScope.AppearanceLayout(
}
}
// }
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI)
}
SectionDividerSpaced(maxTopPadding = true)
@@ -278,7 +278,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) })
ActiveCallInteractiveArea(call)
}
}
}
@@ -54,7 +54,9 @@ object ChatModel {
val ctrlInitInProgress = mutableStateOf(false)
val dbMigrationInProgress = mutableStateOf(false)
val incompleteInitializedDbRemoved = mutableStateOf(false)
val chats = mutableStateListOf<Chat>()
private val _chats = mutableStateOf(SnapshotStateList<Chat>())
val chats: State<List<Chat>> = _chats
private val chatsContext = ChatsContext()
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
@@ -126,7 +128,7 @@ object ChatModel {
val updatingProgress = mutableStateOf(null as Float?)
var updatingRequest: Closeable? = null
val updatingChatsMutex: Mutex = Mutex()
private val updatingChatsMutex: Mutex = Mutex()
val changingActiveUserMutex: Mutex = Mutex()
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
@@ -170,11 +172,11 @@ object ChatModel {
}
// toList() here is to prevent ConcurrentModificationException that is rarely happens but happens
fun hasChat(rhId: Long?, id: String): Boolean = chats.toList().firstOrNull { it.id == id && it.remoteHostId == rhId } != null
fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
// TODO pass rhId?
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
fun populateGroupMembersIndexes() {
groupMembersIndexes.clear()
@@ -192,97 +194,102 @@ object ChatModel {
}
}
private fun getChatIndex(rhId: Long?, id: String): Int = chats.toList().indexOfFirst { it.id == id && it.remoteHostId == rhId }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
suspend fun <T> withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock {
chatsContext.action()
}
fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) {
val i = getChatIndex(rhId, cInfo.id)
if (i >= 0) {
val currentCInfo = chats[i].chatInfo
var newCInfo = cInfo
if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) {
val currentStats = currentCInfo.contact.activeConn?.connectionStats
val newConn = newCInfo.contact.activeConn
val newStats = newConn?.connectionStats
if (currentStats != null && newConn != null && newStats == null) {
newCInfo = newCInfo.copy(
contact = newCInfo.contact.copy(
activeConn = newConn.copy(
connectionStats = currentStats
class ChatsContext {
val chats = _chats
fun addChat(chat: Chat) = chats.add(index = 0, chat)
fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) {
val i = getChatIndex(rhId, cInfo.id)
if (i >= 0) {
val currentCInfo = chats[i].chatInfo
var newCInfo = cInfo
if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) {
val currentStats = currentCInfo.contact.activeConn?.connectionStats
val newConn = newCInfo.contact.activeConn
val newStats = newConn?.connectionStats
if (currentStats != null && newConn != null && newStats == null) {
newCInfo = newCInfo.copy(
contact = newCInfo.contact.copy(
activeConn = newConn.copy(
connectionStats = currentStats
)
)
)
)
}
}
chats[i] = chats[i].copy(chatInfo = newCInfo)
}
}
fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection))
fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) {
val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats)
val updatedContact = contact.copy(activeConn = updatedConn)
updateContact(rhId, updatedContact)
}
fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo))
private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
if (hasChat(rhId, cInfo.id)) {
updateChatInfo(rhId, cInfo)
} else if (addMissing) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
}
}
fun updateChats(newChats: List<Chat>) {
chats.clear()
chats.addAll(newChats)
val cId = chatId.value
// If chat is null, it was deleted in background after apiGetChats call
if (cId != null && getChat(cId) == null) {
chatId.value = null
}
}
fun replaceChat(rhId: Long?, id: String, chat: Chat) {
val i = getChatIndex(rhId, id)
if (i >= 0) {
chats[i] = chat
} else {
// invalid state, correcting
chats.add(index = 0, chat)
}
}
suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock {
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
cItem
}
}
else -> cItem
chats[i] = chats[i].copy(chatInfo = newCInfo)
}
chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatStats =
}
fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection))
fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) {
val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats)
val updatedContact = contact.copy(activeConn = updatedConn)
updateContact(rhId, updatedContact)
}
fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo))
private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) {
if (hasChat(rhId, cInfo.id)) {
updateChatInfo(rhId, cInfo)
} else if (addMissing) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
}
}
fun updateChats(newChats: List<Chat>) {
chats.clear()
chats.addAll(newChats)
val cId = chatId.value
// If chat is null, it was deleted in background after apiGetChats call
if (cId != null && getChat(cId) == null) {
chatId.value = null
}
}
fun replaceChat(rhId: Long?, id: String, chat: Chat) {
val i = getChatIndex(rhId, id)
if (i >= 0) {
chats[i] = chat
} else {
// invalid state, correcting
chats.add(index = 0, chat)
}
}
suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val newPreviewItem = when (cInfo) {
is ChatInfo.Group -> {
val currentPreviewItem = chat.chatItems.firstOrNull()
if (currentPreviewItem != null) {
if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) {
cItem
} else {
currentPreviewItem
}
} else {
cItem
}
}
else -> cItem
}
chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
increaseUnreadCounter(rhId, currentUser.value!!)
@@ -290,123 +297,197 @@ object ChatModel {
}
else
chat.chatStats
)
if (i > 0) {
popChat_(i)
)
if (i > 0) {
chats.add(index = 0, chats.removeAt(i))
}
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
withContext(Dispatchers.Main) {
// add to current chat
if (chatId.value == cInfo.id) {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else {
chatItems.add(cItem)
withContext(Dispatchers.Main) {
// add to current chat
if (chatId.value == cInfo.id) {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else {
chatItems.add(cItem)
}
}
}
}
}
}
suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock {
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
val res: Boolean
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChat(rhId, cInfo.id)
suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean {
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
val res: Boolean
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChat(rhId, cInfo.id)
}
}
res = false
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true
}
return withContext(Dispatchers.Main) {
// update current chat
if (chatId.value == cInfo.id) {
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
items[itemIndex] = cItem
false
} else {
val status = chatItemStatuses.remove(cItem.id)
val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) {
cItem.copy(meta = cItem.meta.copy(itemStatus = status))
} else {
cItem
}
chatItems.add(ci)
true
}
} else {
res
}
}
res = false
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true
}
return withContext(Dispatchers.Main) {
// update current chat
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
withContext(Dispatchers.Main) {
if (chatId.value == cInfo.id) {
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
items[itemIndex] = cItem
}
} else if (status != null) {
chatItemStatuses[cItem.id] = status
}
}
}
fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
}
// remove from current chat
if (chatId.value == cInfo.id) {
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
items[itemIndex] = cItem
chatItems.removeAll {
val remove = it.id == cItem.id
if (remove) { AudioPlayer.stop(it) }
remove
}
}
}
fun clearChat(rhId: Long?, cInfo: ChatInfo) {
// clear preview
val i = getChatIndex(rhId, cInfo.id)
if (i >= 0) {
decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount)
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
}
// clear current chat
if (chatId.value == cInfo.id) {
chatItemStatuses.clear()
chatItems.clear()
}
}
fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
val cInfo = chat.chatInfo
val markedRead = markItemsReadInCurrentChat(chat, range)
// update preview
val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
if (lastId != null) {
val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0
decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCount,
// Can't use minUnreadItemId currently since chat items can have unread items between read items
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
)
)
}
}
}
private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) {
val chatIndex = getChatIndex(rhId, chatId)
if (chatIndex == -1) return
val chat = chats[chatIndex]
val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIndex] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCount,
)
)
}
fun removeChat(rhId: Long?, id: String) {
chats.removeAll { it.id == id && it.remoteHostId == rhId }
}
fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean {
// user member was updated
if (groupInfo.membership.groupMemberId == member.groupMemberId) {
updateGroup(rhId, groupInfo)
return false
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembersIndexes[member.groupMemberId]
if (memberIndex != null) {
groupMembers[memberIndex] = member
false
} else {
val status = chatItemStatuses.remove(cItem.id)
val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) {
cItem.copy(meta = cItem.meta.copy(itemStatus = status))
} else {
cItem
}
chatItems.add(ci)
groupMembers.add(member)
groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1
true
}
} else {
res
false
}
}
fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) {
val memberConn = member.activeConn
if (memberConn != null) {
val updatedConn = memberConn.copy(connectionStats = connectionStats)
val updatedMember = member.copy(activeConn = updatedConn)
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
withContext(Dispatchers.Main) {
if (chatId.value == cInfo.id) {
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
items[itemIndex] = cItem
}
} else if (status != null) {
chatItemStatuses[cItem.id] = status
}
}
}
fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
val pItem = chat.chatItems.lastOrNull()
if (pItem?.id == cItem.id) {
chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy))
}
}
// remove from current chat
if (chatId.value == cInfo.id) {
chatItems.removeAll {
val remove = it.id == cItem.id
if (remove) { AudioPlayer.stop(it) }
remove
}
}
}
fun clearChat(rhId: Long?, cInfo: ChatInfo) {
// clear preview
val i = getChatIndex(rhId, cInfo.id)
if (i >= 0) {
decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount)
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
}
// clear current chat
if (chatId.value == cInfo.id) {
chatItemStatuses.clear()
chatItems.clear()
}
}
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
val current = currentUser.value ?: return
@@ -447,28 +528,6 @@ object ChatModel {
}
}
fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
val cInfo = chat.chatInfo
val markedRead = markItemsReadInCurrentChat(chat, range)
// update preview
val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
if (lastId != null) {
val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0
decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCount,
// Can't use minUnreadItemId currently since chat items can have unread items between read items
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
)
)
}
}
}
private fun markItemsReadInCurrentChat(chat: Chat, range: CC.ItemRange? = null): Int {
val cInfo = chat.chatInfo
var markedRead = 0
@@ -493,20 +552,6 @@ object ChatModel {
return markedRead
}
private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) {
val chatIndex = getChatIndex(rhId, chatId)
if (chatIndex == -1) return
val chat = chats[chatIndex]
val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIndex] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCount,
)
)
}
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounter(rhId, user, 1)
}
@@ -600,11 +645,6 @@ object ChatModel {
// }
// }
private fun popChat_(i: Int) {
val chat = chats.removeAt(i)
chats.add(index = 0, chat)
}
fun replaceConnReqView(id: String, withId: String) {
if (id == showingInvitation.value?.connId) {
showingInvitation.value = null
@@ -629,41 +669,6 @@ object ChatModel {
showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true)
}
fun removeChat(rhId: Long?, id: String) {
chats.removeAll { it.id == id && it.remoteHostId == rhId }
}
fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean {
// user member was updated
if (groupInfo.membership.groupMemberId == member.groupMemberId) {
updateGroup(rhId, groupInfo)
return false
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembersIndexes[member.groupMemberId]
if (memberIndex != null) {
groupMembers[memberIndex] = member
false
} else {
groupMembers.add(member)
groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1
true
}
} else {
false
}
}
fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) {
val memberConn = member.activeConn
if (memberConn != null) {
val updatedConn = memberConn.copy(connectionStats = connectionStats)
val updatedMember = member.copy(activeConn = updatedConn)
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) {
val conn = contact.activeConn
if (conn != null) {
@@ -802,6 +807,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -882,6 +888,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val chatDeleted get() = contact.chatDeleted
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
@@ -906,6 +913,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val chatDeleted get() = groupInfo.chatDeleted
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
@@ -930,6 +938,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = noteFolder.id
override val apiId get() = noteFolder.apiId
override val ready get() = noteFolder.ready
override val chatDeleted get() = noteFolder.chatDeleted
override val sendMsgEnabled get() = noteFolder.sendMsgEnabled
override val ntfsEnabled get() = noteFolder.ntfsEnabled
override val incognito get() = noteFolder.incognito
@@ -954,6 +963,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val chatDeleted get() = contactRequest.chatDeleted
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
@@ -978,6 +988,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactConnection.id
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val chatDeleted get() = contactConnection.chatDeleted
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
@@ -1003,6 +1014,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = ""
override val apiId get() = 0L
override val ready get() = false
override val chatDeleted get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
@@ -1079,6 +1091,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
@@ -1153,6 +1166,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1161,7 +1175,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1304,6 +1319,7 @@ data class GroupInfo (
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val chatDeleted get() = false
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito
@@ -1609,6 +1625,7 @@ class NoteFolder(
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = false
@@ -1645,6 +1662,7 @@ class UserContactRequest (
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -1684,6 +1702,7 @@ class PendingContactConnection(
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -2108,45 +2127,55 @@ data class ChatItem (
}
}
fun MutableState<SnapshotStateList<ChatItem>>.add(index: Int, chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, chatItem) }
fun <T> MutableState<SnapshotStateList<T>>.add(index: Int, elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(index, elem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.add(chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(chatItem) }
fun <T> MutableState<SnapshotStateList<T>>.add(elem: T) {
value = SnapshotStateList<T>().apply { addAll(value); add(elem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(index: Int, chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(index, chatItems) }
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(value); addAll(index, elems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(chatItems) }
fun <T> MutableState<SnapshotStateList<T>>.addAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(value); addAll(elems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAll(block: (ChatItem) -> Boolean) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAll(block) }
fun <T> MutableState<SnapshotStateList<T>>.removeAll(block: (T) -> Boolean) {
value = SnapshotStateList<T>().apply { addAll(value); removeAll(block) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAt(index: Int) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAt(index) }
fun <T> MutableState<SnapshotStateList<T>>.removeAt(index: Int): T {
val new = SnapshotStateList<T>()
new.addAll(value)
val res = new.removeAt(index)
value = new
return res
}
fun MutableState<SnapshotStateList<ChatItem>>.removeLast() {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeLast() }
fun <T> MutableState<SnapshotStateList<T>>.removeLast() {
value = SnapshotStateList<T>().apply { addAll(value); removeLast() }
}
fun MutableState<SnapshotStateList<ChatItem>>.replaceAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(chatItems) }
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
value = SnapshotStateList<T>().apply { addAll(elems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.clear() {
value = SnapshotStateList<ChatItem>()
fun <T> MutableState<SnapshotStateList<T>>.clear() {
value = SnapshotStateList<T>()
}
fun State<SnapshotStateList<ChatItem>>.asReversed(): MutableList<ChatItem> = value.asReversed()
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()
val State<List<ChatItem>>.size: Int get() = value.size
fun <T> State<SnapshotStateList<T>>.toList(): List<T> = value.toList()
operator fun <T> State<SnapshotStateList<T>>.get(i: Int): T = value[i]
operator fun <T> State<SnapshotStateList<T>>.set(index: Int, elem: T) { value[index] = elem }
val State<List<Any>>.size: Int get() = value.size
enum class CIMergeCategory {
MemberConnected,
@@ -15,8 +15,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
import chat.simplex.common.model.ChatModel.withChats
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
@@ -217,13 +217,16 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true)
val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, false)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = settings.getInt(prefName, default),
@@ -381,6 +384,7 @@ class AppPreferences {
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI"
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used
@@ -401,6 +405,8 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
@@ -478,9 +484,9 @@ object ChatController {
}
Log.d(TAG, "startChat: started")
} else {
updatingChatsMutex.withLock {
withChats {
val chats = apiGetChats(null)
chatModel.updateChats(chats)
updateChats(chats)
}
Log.d(TAG, "startChat: running")
}
@@ -558,9 +564,9 @@ object ChatController {
val hasUser = chatModel.currentUser.value != null
chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null
chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None
updatingChatsMutex.withLock {
withChats {
val chats = apiGetChats(rhId)
chatModel.updateChats(chats)
updateChats(chats)
}
}
@@ -1177,16 +1183,18 @@ object ChatController {
}
}
suspend fun deleteChat(chat: Chat, notify: Boolean? = null) {
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val cInfo = chat.chatInfo
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) {
chatModel.removeChat(chat.remoteHostId, cInfo.id)
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
withChats {
removeChat(chat.remoteHostId, cInfo.id)
}
}
}
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean {
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify))
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val success = when {
r is CR.ContactDeleted && type == ChatType.Direct -> true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true
@@ -1207,11 +1215,29 @@ object ChatController {
return success
}
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
val type = ChatType.Direct
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val contact = when {
r is CR.ContactDeleted -> r.contact
else -> {
val titleId = MR.strings.error_deleting_contact
apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
null
}
}
chatModel.deletedChats.value -= rh to type.type + id
return contact
}
fun clearChat(chat: Chat, close: (() -> Unit)? = null) {
withBGApi {
val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId)
if (updatedChatInfo != null) {
chatModel.clearChat(chat.remoteHostId, updatedChatInfo)
withChats {
clearChat(chat.remoteHostId, updatedChatInfo)
}
ntfManager.cancelNotificationsForChat(chat.chatInfo.id)
close?.invoke()
}
@@ -1546,10 +1572,12 @@ object ChatController {
val r = sendCmd(rh, CC.ApiJoinGroup(groupId))
when (r) {
is CR.UserAcceptedGroupSent ->
chatModel.updateGroup(rh, r.groupInfo)
withChats {
updateGroup(rh, r.groupInfo)
}
is CR.ChatCmdError -> {
val e = r.chatError
suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { chatModel.removeChat(rh, "#$groupId") } }
suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withChats { removeChat(rh, "#$groupId") } } }
if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) {
deleteGroup()
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired))
@@ -1703,7 +1731,9 @@ object ChatController {
val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param)
val toContact = apiSetContactPrefs(rh, contact.contactId, prefs)
if (toContact != null) {
chatModel.updateContact(rh, toContact)
withChats {
updateContact(rh, toContact)
}
}
}
@@ -1973,16 +2003,20 @@ object ChatController {
when (r) {
is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact)
withChats {
updateContact(rhId, r.contact)
}
}
}
is CR.ContactConnected -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
chatModel.removeChat(rhId, conn.id)
withChats {
updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
removeChat(rhId, conn.id)
}
}
}
if (r.contact.directOrUsed) {
@@ -1992,21 +2026,25 @@ object ChatController {
}
is CR.ContactConnecting -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
chatModel.removeChat(rhId, conn.id)
withChats {
updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
removeChat(rhId, conn.id)
}
}
}
}
is CR.ContactSndReady -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
chatModel.removeChat(rhId, conn.id)
withChats {
updateContact(rhId, r.contact)
val conn = r.contact.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}")
removeChat(rhId, conn.id)
}
}
}
chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected())
@@ -2015,10 +2053,12 @@ object ChatController {
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
if (active(r.user)) {
if (chatModel.hasChat(rhId, contactRequest.id)) {
chatModel.updateChatInfo(rhId, cInfo)
} else {
chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf()))
withChats {
if (chatModel.hasChat(rhId, contactRequest.id)) {
updateChatInfo(rhId, cInfo)
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf()))
}
}
}
ntfManager.notifyContactRequestReceived(r.user, cInfo)
@@ -2026,12 +2066,16 @@ object ChatController {
is CR.ContactUpdated -> {
if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) {
val cInfo = ChatInfo.Direct(r.toContact)
chatModel.updateChatInfo(rhId, cInfo)
withChats {
updateChatInfo(rhId, cInfo)
}
}
}
is CR.GroupMemberUpdated -> {
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.toMember)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.toMember)
}
}
}
is CR.ContactsMerged -> {
@@ -2039,7 +2083,9 @@ object ChatController {
if (chatModel.chatId.value == r.mergedContact.id) {
chatModel.chatId.value = r.intoContact.id
}
chatModel.removeChat(rhId, r.mergedContact.id)
withChats {
removeChat(rhId, r.mergedContact.id)
}
}
}
// ContactsSubscribed, ContactsDisconnected and ContactSubSummary are only used in CLI,
@@ -2049,7 +2095,9 @@ object ChatController {
is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) {
if (active(r.user)) {
chatModel.updateContact(rhId, sub.contact)
withChats {
updateContact(rhId, sub.contact)
}
}
val err = sub.contactError
if (err == null) {
@@ -2073,7 +2121,15 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (active(r.user)) {
chatModel.addChatItem(rhId, cInfo, cItem)
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
withChats {
updateContact(rhId, updatedContact)
}
}
withChats {
addChatItem(rhId, cInfo, cItem)
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
}
@@ -2094,14 +2150,18 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (!cItem.isDeletedContent && active(r.user)) {
chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
withChats {
updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
}
}
is CR.ChatItemUpdated ->
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
is CR.ChatItemReaction -> {
if (active(r.user)) {
chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
withChats {
updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
}
is CR.ChatItemsDeleted -> {
@@ -2127,82 +2187,113 @@ object ChatController {
generalGetString(if (toChatItem != null) MR.strings.marked_deleted_description else MR.strings.deleted_description)
)
}
if (toChatItem == null) {
chatModel.removeChatItem(rhId, cInfo, cItem)
} else {
chatModel.upsertChatItem(rhId, cInfo, toChatItem.chatItem)
withChats {
if (toChatItem == null) {
removeChatItem(rhId, cInfo, cItem)
} else {
upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
}
}
is CR.ReceivedGroupInvitation -> {
if (active(r.user)) {
chatModel.updateGroup(rhId, r.groupInfo) // update so that repeat group invitations are not duplicated
withChats {
// update so that repeat group invitations are not duplicated
updateGroup(rhId, r.groupInfo)
}
// TODO NtfManager.shared.notifyGroupInvitation
}
}
is CR.UserAcceptedGroupSent -> {
if (!active(r.user)) return
chatModel.updateGroup(rhId, r.groupInfo)
val conn = r.hostContact?.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}")
chatModel.removeChat(rhId, conn.id)
withChats {
updateGroup(rhId, r.groupInfo)
val conn = r.hostContact?.activeConn
if (conn != null) {
chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}")
removeChat(rhId, conn.id)
}
}
}
is CR.GroupLinkConnecting -> {
if (!active(r.user)) return
chatModel.updateGroup(rhId, r.groupInfo)
val hostConn = r.hostMember.activeConn
if (hostConn != null) {
chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}")
chatModel.removeChat(rhId, hostConn.id)
withChats {
updateGroup(rhId, r.groupInfo)
val hostConn = r.hostMember.activeConn
if (hostConn != null) {
chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}")
removeChat(rhId, hostConn.id)
}
}
}
is CR.JoinedGroupMemberConnecting ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.DeletedMemberUser -> // TODO update user member
if (active(r.user)) {
chatModel.updateGroup(rhId, r.groupInfo)
withChats {
updateGroup(rhId, r.groupInfo)
}
}
is CR.DeletedMember ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
}
}
is CR.LeftMember ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRole ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRoleUser ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberBlockedForAll ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.GroupDeleted -> // TODO update user member
if (active(r.user)) {
chatModel.updateGroup(rhId, r.groupInfo)
withChats {
updateGroup(rhId, r.groupInfo)
}
}
is CR.UserJoinedGroup ->
if (active(r.user)) {
chatModel.updateGroup(rhId, r.groupInfo)
withChats {
updateGroup(rhId, r.groupInfo)
}
}
is CR.JoinedGroupMember ->
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.ConnectedToGroupMember -> {
if (active(r.user)) {
chatModel.upsertGroupMember(rhId, r.groupInfo, r.member)
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
if (r.memberContact != null) {
chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected())
@@ -2210,11 +2301,15 @@ object ChatController {
}
is CR.GroupUpdated ->
if (active(r.user)) {
chatModel.updateGroup(rhId, r.toGroup)
withChats {
updateGroup(rhId, r.toGroup)
}
}
is CR.NewMemberContactReceivedInv ->
if (active(r.user)) {
chatModel.updateContact(rhId, r.contact)
withChats {
updateContact(rhId, r.contact)
}
}
is CR.RcvFileStart ->
chatItemSimpleUpdate(rhId, r.user, r.chatItem)
@@ -2314,19 +2409,27 @@ object ChatController {
}
is CR.ContactSwitch ->
if (active(r.user)) {
chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats)
withChats {
updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats)
}
}
is CR.GroupMemberSwitch ->
if (active(r.user)) {
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats)
withChats {
updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats)
}
}
is CR.ContactRatchetSync ->
if (active(r.user)) {
chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats)
withChats {
updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats)
}
}
is CR.GroupMemberRatchetSync ->
if (active(r.user)) {
chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
withChats {
updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats)
}
}
is CR.RemoteHostSessionCode -> {
chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode)
@@ -2338,7 +2441,9 @@ object ChatController {
}
is CR.ContactDisabled -> {
if (active(r.user)) {
chatModel.updateContact(rhId, r.contact)
withChats {
updateContact(rhId, r.contact)
}
}
}
is CR.RemoteHostStopped -> {
@@ -2459,7 +2564,9 @@ object ChatController {
}
is CR.ContactPQEnabled ->
if (active(r.user)) {
chatModel.updateContact(rhId, r.contact)
withChats {
updateContact(rhId, r.contact)
}
}
is CR.ChatRespError -> when {
r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> {
@@ -2535,7 +2642,9 @@ object ChatController {
suspend fun leaveGroup(rh: Long?, groupId: Long) {
val groupInfo = apiLeaveGroup(rh, groupId)
if (groupInfo != null) {
chatModel.updateGroup(rh, groupInfo)
withChats {
updateGroup(rh, groupInfo)
}
}
}
@@ -2545,7 +2654,7 @@ object ChatController {
val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) }
if (!activeUser(rh, user)) {
notify()
} else if (chatModel.upsertChatItem(rh, cInfo, cItem)) {
} else if (withChats { upsertChatItem(rh, cInfo, cItem) }) {
notify()
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
notify()
@@ -2589,7 +2698,9 @@ object ChatController {
chatModel.currentUser.value = user
if (user == null) {
chatModel.chatItems.clear()
chatModel.chats.clear()
withChats {
chats.clear()
}
}
val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) {
@@ -2790,7 +2901,7 @@ sealed class CC {
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -2943,11 +3054,7 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -3169,8 +3276,6 @@ sealed class CC {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
@@ -3180,6 +3285,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -5147,6 +5254,19 @@ fun chatError(r: CR): ChatErrorType? {
)
}
@Serializable
sealed class ChatDeleteMode {
@Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("messages") class Messages: ChatDeleteMode()
val cmdString: String get() = when (this) {
is ChatDeleteMode.Full -> "full notify=${onOff(notify)}"
is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}"
is ChatDeleteMode.Messages -> "messages"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -6034,6 +6154,7 @@ data class AppSettings(
var uiDarkColorScheme: String? = null,
var uiCurrentThemeIds: Map<String, String>? = null,
var uiThemes: List<ThemeOverrides>? = null,
var oneHandUI: Boolean? = null
) {
fun prepareForExport(): AppSettings {
val empty = AppSettings()
@@ -6064,6 +6185,7 @@ data class AppSettings(
if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme }
if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds }
if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes }
if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI }
return empty
}
@@ -6102,6 +6224,7 @@ data class AppSettings(
uiDarkColorScheme?.let { def.systemDarkTheme.set(it) }
uiCurrentThemeIds?.let { def.currentThemeIds.set(it) }
uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) }
oneHandUI?.let { def.oneHandUI.set(it) }
}
companion object {
@@ -6133,6 +6256,7 @@ data class AppSettings(
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = null,
uiThemes = null,
oneHandUI = false
)
val current: AppSettings
@@ -6165,6 +6289,7 @@ data class AppSettings(
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = def.currentThemeIds.get(),
uiThemes = def.themeOverrides.get(),
oneHandUI = def.oneHandUI.get()
)
}
}
@@ -12,18 +12,23 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.*
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -31,6 +36,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -54,10 +60,11 @@ fun ChatInfoView(
localAlias: String,
connectionCode: String?,
close: () -> Unit,
onSearchClicked: () -> Unit
) {
BackHandler(onBack = close)
val contact = rememberUpdatedState(contact).value
val chat = remember(contact.id) { chatModel.chats.firstOrNull { it.id == contact.id } }
val chat = remember(contact.id) { chatModel.chats.value.firstOrNull { it.id == contact.id } }
val currentUser = remember { chatModel.currentUser }.value
val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
@@ -102,7 +109,9 @@ fun ChatInfoView(
val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId)
connStats.value = cStats
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
close.invoke()
}
@@ -114,7 +123,9 @@ fun ChatInfoView(
val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId)
connStats.value = cStats
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
}
})
@@ -124,7 +135,9 @@ fun ChatInfoView(
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false)
connStats.value = cStats
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
close.invoke()
}
@@ -135,7 +148,9 @@ fun ChatInfoView(
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true)
connStats.value = cStats
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
close.invoke()
}
@@ -151,14 +166,16 @@ fun ChatInfoView(
verify = { code ->
chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.updateContact(
chatRh,
ct.copy(
activeConn = ct.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
withChats {
updateContact(
chatRh,
ct.copy(
activeConn = ct.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
)
}
r
}
},
@@ -166,7 +183,9 @@ fun ChatInfoView(
)
}
}
}
},
close = close,
onSearchClicked = onSearchClicked
)
}
}
@@ -201,34 +220,42 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contact = chatInfo.contact
when {
contact.sndReady && contact.active && !chatInfo.chatDeleted ->
deleteContactOrConversationDialog(chat, contact, chatModel, close)
contact.sndReady && contact.active && chatInfo.chatDeleted ->
deleteContactWithoutConversation(chat, chatModel, close)
else -> // !(contact.sndReady && contact.active)
deleteNotReadyContact(chat, chatModel, close)
}
}
}
private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
buttons = {
Column {
if (chatInfo is ChatInfo.Direct && chatInfo.contact.sndReady && chatInfo.contact.active) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = false)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteActiveContactDialog(chat, contact, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Cancel
SectionItemView({
@@ -241,13 +268,207 @@ fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? =
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
private fun showDeleteConversationNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.conversation_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteConversationNotice.set(false)
},
)
}
sealed class ContactDeleteMode {
class Full: ContactDeleteMode()
class Entity: ContactDeleteMode()
fun toChatDeleteMode(notify: Boolean): ChatDeleteMode =
when (this) {
is Full -> ChatDeleteMode.Full(notify)
is Entity -> ChatDeleteMode.Entity(notify)
}
}
private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) {
val contactDeleteMode = mutableStateOf<ContactDeleteMode>(ContactDeleteMode.Full())
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
Column {
// Keep conversation toggle
SectionItemView {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(stringResource(MR.strings.keep_conversation))
Spacer(Modifier.width(DEFAULT_PADDING))
DefaultSwitch(
checked = contactDeleteMode.value is ContactDeleteMode.Entity,
onCheckedChange = {
contactDeleteMode.value =
if (it) ContactDeleteMode.Entity() else ContactDeleteMode.Full()
},
)
}
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false))
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete contact and notify
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true))
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.confirm_delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
Column {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(
chat,
chatModel,
close,
chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = true)
)
}) {
Text(
generalGetString(MR.strings.delete_and_notify_contact),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.error
)
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(
chat,
chatModel,
close,
chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false)
)
}) {
Text(
generalGetString(MR.strings.delete_without_notification),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.error
)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(
stringResource(MR.strings.cancel_verb),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary
)
}
}
)
}
private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.confirm_delete_contact_question),
text = generalGetString(MR.strings.delete_contact_cannot_undo_warning),
buttons = {
// Confirm
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(
chat,
chatModel,
close,
chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false)
)
}) {
Text(
generalGetString(MR.strings.confirm_verb),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.error
)
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(
stringResource(MR.strings.cancel_verb),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary
)
}
}
)
}
private fun showDeleteContactNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.contact_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteContactNotice.set(false)
},
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val chatInfo = chat.chatInfo
withBGApi {
val chatRh = chat.remoteHostId
val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatRh, chatInfo.id)
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
withChats {
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
clearChat(chatRh, ChatInfo.Direct(ct))
}
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -300,6 +521,8 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
onSearchClicked: () -> Unit
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -319,7 +542,27 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
SearchButton(chat, contact, close, onSearchClicked)
Spacer(Modifier.weight(1f))
AudioCallButton(chat, contact)
Spacer(Modifier.weight(1f))
VideoButton(chat, contact)
Spacer(Modifier.weight(1f))
MuteButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -347,7 +590,7 @@ fun ChatInfoLayout(
WallpaperButton {
ModalManager.end.showModal {
val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } }
val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } }
val c = chat.value
if (c != null) {
ChatWallpaperEditorModal(c)
@@ -535,6 +778,174 @@ fun LocalAliasEditor(
}
}
@Composable
fun SearchButton(chat: Chat, contact: Contact, close: () -> Unit, onSearchClicked: () -> Unit) {
val disabled = !contact.ready || chat.chatItems.isEmpty()
InfoViewActionButton(
icon = painterResource(MR.images.ic_search),
title = generalGetString(MR.strings.info_view_search_button),
disabled = disabled,
disabledLook = disabled,
onClick = {
if (appPlatform.isAndroid) {
close.invoke()
}
onSearchClicked()
}
)
}
@Composable
fun MuteButton(chat: Chat, contact: Contact) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
val disabled = !contact.ready || !contact.active
InfoViewActionButton(
icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
disabled = disabled,
disabledLook = disabled,
onClick = {
toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled)
}
)
}
@Composable
fun AudioCallButton(chat: Chat, contact: Contact) {
CallButton(
chat,
contact,
icon = painterResource(MR.images.ic_call),
title = generalGetString(MR.strings.info_view_call_button),
mediaType = CallMediaType.Audio
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
CallButton(
chat,
contact,
icon = painterResource(MR.images.ic_videocam),
title = generalGetString(MR.strings.info_view_video_button),
mediaType = CallMediaType.Video
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact, icon: Painter, title: String, mediaType: CallMediaType) {
val canCall = contact.ready && contact.active && contact.mergedPreferences.calls.enabled.forUser && chatModel.activeCall.value == null
val needToAllowCallsToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.calls) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
val allowedCallsByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Calls) }
InfoViewActionButton(
icon = icon,
title = title,
disabled = chatModel.activeCall.value != null,
disabledLook = !canCall,
onClick =
when {
canCall -> { { startChatCall(chat, mediaType) } }
contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } }
!contact.active -> { { showCantCallContactDeletedAlert() } }
!contact.ready -> { { showCantCallContactConnectingAlert() } }
needToAllowCallsToContact -> { { showNeedToAllowCallsAlert(onConfirm = { allowCallsToContact(chat) }) } }
!allowedCallsByPrefs -> { { showCallsProhibitedAlert() }}
else -> { { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.cant_call_contact_alert_title)) } }
}
)
}
private fun showCantCallContactSendMessageAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_member_send_message_alert_text)
)
}
private fun showCantCallContactConnectingAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_contact_connecting_wait_alert_text)
)
}
private fun showCantCallContactDeletedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_contact_alert_title),
text = generalGetString(MR.strings.cant_call_contact_deleted_alert_text)
)
}
private fun showNeedToAllowCallsAlert(onConfirm: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.allow_calls_question),
text = generalGetString(MR.strings.you_need_to_allow_calls),
confirmText = generalGetString(MR.strings.allow_verb),
dismissText = generalGetString(MR.strings.cancel_verb),
onConfirm = onConfirm,
)
}
private fun allowCallsToContact(chat: Chat) {
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
withBGApi {
chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Calls)
}
}
private fun showCallsProhibitedAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.calls_prohibited_alert_title),
text = generalGetString(MR.strings.calls_prohibited_ask_to_enable_calls_alert_text)
)
}
// for ChatInfoView (it has most buttons - 4) we use Spacer(Modifier.weight(1f)) to fit,
// for GroupChat And GroupMemberInfoViews (2 to 3 buttons) we use this as approximately equal to spacing in ChatInfoView
val INFO_VIEW_BUTTONS_PADDING = 36.dp
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, disabledLook: Boolean, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = onClick,
enabled = !disabled
) {
Box(
modifier = Modifier
.background(
if (disabledLook) MaterialTheme.colors.secondaryVariant else MaterialTheme.colors.primary,
shape = CircleShape
)
.padding(16.dp)
) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp * fontSizeSqrtMultiplier),
tint = if (disabledLook) MaterialTheme.colors.secondary else MaterialTheme.colors.onPrimary
)
}
}
Text(
title.capitalize(Locale.current),
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colors.secondary,
modifier = Modifier.padding(top = DEFAULT_SPACE_AFTER_ICON)
)
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -768,10 +1179,12 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c
wallpaperFilesToDelete.forEach(::removeWallpaperFile)
if (controller.apiSetChatUIThemes(chat.remoteHostId, chat.id, changedThemes)) {
if (chat.chatInfo is ChatInfo.Direct) {
chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes)))
} else if (chat.chatInfo is ChatInfo.Group) {
chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes)))
withChats {
if (chat.chatInfo is ChatInfo.Direct) {
updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes)))
} else if (chat.chatInfo is ChatInfo.Group) {
updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes)))
}
}
}
}
@@ -779,7 +1192,9 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c
private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi {
val chatRh = chat.remoteHostId
chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let {
chatModel.updateContact(chatRh, it)
withChats {
updateContact(chatRh, it)
}
}
}
@@ -847,6 +1262,8 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
onSearchClicked = {}
)
}
}
@@ -11,7 +11,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.*
@@ -25,6 +24,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.group.*
@@ -45,8 +45,9 @@ import kotlin.math.sign
@Composable
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) {
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val activeChat = remember { mutableStateOf(chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
val searchText = rememberSaveable { mutableStateOf("") }
val showSearch = rememberSaveable { mutableStateOf(false) }
val user = chatModel.currentUser.value
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
@@ -74,6 +75,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
if (activeChat.value?.id != chatId) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
showSearch.value = false
activeChat.value = chatModel.getChat(chatId)
}
markUnreadChatAsRead(activeChat, chatModel)
@@ -87,7 +89,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
* */
try {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
} catch (e: ConcurrentModificationException) {
Log.e(TAG, e.stackTraceToString())
null
@@ -112,7 +114,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
@@ -190,7 +192,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) {
showSearch.value = true
}
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -201,7 +205,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}, close, { showSearch.value = true })
} else {
LaunchedEffect(Unit) {
close()
@@ -268,10 +272,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
if (deleted != null) {
deletedChatItem = deleted.deletedChatItem.chatItem
toChatItem = deleted.toChatItem?.chatItem
if (toChatItem != null) {
chatModel.upsertChatItem(chatRh, cInfo, toChatItem)
} else {
chatModel.removeChatItem(chatRh, cInfo, deletedChatItem)
withChats {
if (toChatItem != null) {
upsertChatItem(chatRh, cInfo, toChatItem)
} else {
removeChatItem(chatRh, cInfo, deletedChatItem)
}
}
}
}
@@ -284,8 +290,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
chatRh, chatInfo.chatType, chatInfo.apiId, itemIds, CIDeleteMode.cidmInternal
)
if (deleted != null) {
for (di in deleted) {
chatModel.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem)
withChats {
for (di in deleted) {
removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem)
}
}
}
}
@@ -303,18 +311,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
startCall = out@{ media -> startChatCall(chat, media) },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
@@ -351,7 +348,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
if (r != null) {
val contactStats = r.first
if (contactStats != null)
chatModel.updateContactConnectionStats(chatRh, contact, contactStats)
withChats {
updateContactConnectionStats(chatRh, contact, contactStats)
}
}
}
},
@@ -361,7 +360,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
if (r != null) {
val memStats = r.second
if (memStats != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats)
withChats {
updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats)
}
}
}
}
@@ -370,7 +371,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false)
if (cStats != null) {
chatModel.updateContactConnectionStats(chatRh, contact, cStats)
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
}
},
@@ -378,7 +381,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second)
withChats {
updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second)
}
}
}
},
@@ -399,7 +404,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
reaction = reaction
)
if (updatedCI != null) {
chatModel.updateChatItem(cInfo, updatedCI)
withChats {
updateChatItem(cInfo, updatedCI)
}
}
}
},
@@ -443,36 +450,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
addMembers = { groupInfo ->
hideKeyboard(view)
withBGApi {
setGroupMembers(chatRh, groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(chatRh, groupInfo, false, chatModel, close)
}
}
},
openGroupLink = { groupInfo ->
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
},
addMembers = { groupInfo -> addGroupMembers(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) },
openGroupLink = { groupInfo -> openGroupLink(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) },
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
withBGApi {
chatModel.controller.apiChatRead(
chatRh,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
withChats {
markChatItemsRead(chat, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
chatModel.controller.apiChatRead(
chatRh,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
range
)
}
}
},
changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) },
@@ -488,6 +479,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch
)
}
}
@@ -518,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
fun startChatCall(chat: Chat, media: CallMediaType) {
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
}
@Composable
fun ChatLayout(
chat: Chat,
@@ -559,10 +564,12 @@ fun ChatLayout(
onSearchValueChanged: (String) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
showViaProxy: Boolean,
showSearch: MutableState<Boolean>
) {
val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
Box(
Modifier
.fillMaxWidth()
@@ -602,7 +609,7 @@ fun ChatLayout(
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
@@ -648,16 +655,17 @@ fun ChatInfoToolbar(
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
showSearch: MutableState<Boolean>
) {
val scope = rememberCoroutineScope()
val showMenu = rememberSaveable { mutableStateOf(false) }
var showSearch by rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch) {
if (!showSearch.value) {
back()
} else {
onSearchValueChanged("")
showSearch = false
showSearch.value = false
}
}
if (appPlatform.isAndroid) {
@@ -668,9 +676,10 @@ fun ChatInfoToolbar(
val activeCall by remember { chatModel.activeCall }
if (chat.chatInfo is ChatInfo.Local) {
barButtons.add {
IconButton({
showMenu.value = false
showSearch = true
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chat.chatInfo.noteFolder.ready
) {
Icon(
@@ -684,7 +693,7 @@ fun ChatInfoToolbar(
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch = true
showSearch.value = true
})
}
}
@@ -783,6 +792,7 @@ fun ChatInfoToolbar(
}
}
}
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add {
@@ -810,10 +820,10 @@ fun ChatInfoToolbar(
}
DefaultTopAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } },
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chat.chatInfo) },
onTitleClick = if (chat.chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
)
@@ -1141,7 +1151,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
try {
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.size)) {
if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) {
if (appPlatform.isAndroid) listState.animateScrollToItem(0) else listState.scrollToItem(0)
} else {
if (appPlatform.isAndroid) listState.animateScrollBy(scrollDistance) else listState.scrollBy(scrollDistance)
@@ -1245,7 +1255,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
@@ -1332,6 +1342,28 @@ private fun TopEndFloatingButton(
val chatViewScrollState = MutableStateFlow(false)
fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
hideKeyboard(view)
withBGApi {
setGroupMembers(rhId, groupInfo, chatModel)
close?.invoke()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(rhId, groupInfo, false, chatModel, close)
}
}
}
fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
hideKeyboard(view)
withBGApi {
val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId)
close?.invoke()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, rhId, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
@@ -1388,8 +1420,10 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
false
)
if (success && chat.id == activeChat.value?.id) {
activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))
chatModel.replaceChat(chatRh, chat.id, activeChat.value!!)
withChats {
activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))
replaceChat(chatRh, chat.id, activeChat.value!!)
}
}
}
}
@@ -1587,6 +1621,7 @@ fun PreviewChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
)
}
}
@@ -1661,6 +1696,7 @@ fun PreviewGroupChatLayout() {
onComposed = {},
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) }
)
}
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.filesToDelete
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.*
@@ -393,7 +394,9 @@ fun ComposeView(
ttl = ttl
)
if (aChatItem != null) {
chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
withChats {
addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
}
return aChatItem.chatItem
}
if (file != null) removeFile(file.filePath)
@@ -421,7 +424,9 @@ fun ComposeView(
ttl = ttl
)
if (chatItem != null) {
chatModel.addChatItem(rhId, chat.chatInfo, chatItem)
withChats {
addChatItem(rhId, chat.chatInfo, chatItem)
}
}
return chatItem
}
@@ -458,7 +463,9 @@ fun ComposeView(
val mc = checkLinkPreview()
val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc)
if (contact != null) {
chatModel.updateContact(chat.remoteHostId, contact)
withChats {
updateContact(chat.remoteHostId, contact)
}
}
}
@@ -474,7 +481,9 @@ fun ComposeView(
mc = updateMsgContent(oldMsgContent),
live = live
)
if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem)
if (updatedItem != null) withChats {
upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem)
}
return updatedItem?.chatItem
}
return null
@@ -19,6 +19,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.PreferenceToggle
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
@@ -40,8 +41,10 @@ fun ContactPreferencesView(
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs)
if (toContact != null) {
m.updateContact(rhId, toContact)
currentFeaturesAllowed = featuresAllowed
withChats {
updateContact(rhId, toContact)
currentFeaturesAllowed = featuresAllowed
}
}
afterSave()
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
@@ -58,7 +59,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
for (contactId in selectedContacts) {
val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value)
if (member != null) {
chatModel.upsertGroupMember(rhId, groupInfo, member)
withChats {
upsertGroupMember(rhId, groupInfo, member)
}
} else {
break
}
@@ -81,7 +84,7 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
val memberContactIds = chatModel.groupMembers
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
return chatModel.chats
return chatModel.chats.value
.asSequence()
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -40,10 +41,10 @@ import kotlinx.coroutines.launch
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
BackHandler(onBack = close)
// TODO derivedStateOf?
val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
val currentUser = chatModel.currentUser.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
@@ -113,7 +114,8 @@ fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLi
leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) },
manageGroupLink = {
ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
}
},
onSearchClicked = onSearchClicked
)
}
}
@@ -131,13 +133,15 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl
withBGApi {
val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(chat.remoteHostId, chatInfo.id)
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
withChats {
removeChat(chat.remoteHostId, chatInfo.id)
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
},
@@ -169,7 +173,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
withBGApi {
val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId)
if (updatedMember != null) {
chatModel.upsertGroupMember(rhId, groupInfo, updatedMember)
withChats {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
},
@@ -177,6 +183,57 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
)
}
@Composable
fun SearchButton(chat: Chat, group: GroupInfo, close: () -> Unit, onSearchClicked: () -> Unit) {
val disabled = !group.ready || chat.chatItems.isEmpty()
InfoViewActionButton(
icon = painterResource(MR.images.ic_search),
title = generalGetString(MR.strings.info_view_search_button),
disabled = disabled,
disabledLook = disabled,
onClick = {
if (appPlatform.isAndroid) {
close.invoke()
}
onSearchClicked()
}
)
}
@Composable
fun MuteButton(chat: Chat, groupInfo: GroupInfo) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
InfoViewActionButton(
icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications),
title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat),
disabled = !groupInfo.ready,
disabledLook = !groupInfo.ready,
onClick = {
toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled)
}
)
}
@Composable
fun AddGroupMembersButton(chat: Chat, groupInfo: GroupInfo) {
InfoViewActionButton(
icon = if (groupInfo.incognito) painterResource(MR.images.ic_add_link) else painterResource(MR.images.ic_person_add_500),
title = stringResource(MR.strings.action_button_add_members),
disabled = !groupInfo.ready,
disabledLook = !groupInfo.ready,
onClick = {
if (groupInfo.incognito) {
openGroupLink(groupInfo = groupInfo, rhId = chat.remoteHostId)
} else {
addGroupMembers(groupInfo = groupInfo, rhId = chat.remoteHostId)
}
}
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
@@ -196,6 +253,8 @@ fun GroupChatInfoLayout(
clearChat: () -> Unit,
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
@@ -219,6 +278,24 @@ fun GroupChatInfoLayout(
}
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
SearchButton(chat, groupInfo, close, onSearchClicked)
if (groupInfo.canAddMembers) {
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
AddGroupMembersButton(chat, groupInfo)
}
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
MuteButton(chat, groupInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
@@ -235,7 +312,7 @@ fun GroupChatInfoLayout(
WallpaperButton {
ModalManager.end.showModal {
val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } }
val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } }
val c = chat.value
if (c != null) {
ChatWallpaperEditorModal(c)
@@ -584,7 +661,7 @@ fun PreviewGroupChatInfoLayout() {
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
)
}
}
@@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
@@ -52,7 +53,7 @@ fun GroupMemberInfoView(
closeAll: () -> Unit, // Close all open windows up to ChatView
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
val connStats = remember { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
@@ -72,13 +73,15 @@ fun GroupMemberInfoView(
withBGApi {
val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it)
if (c != null) {
if (chatModel.getContactChat(it) == null) {
chatModel.addChat(c)
withChats {
if (chatModel.getContactChat(it) == null) {
addChat(c)
}
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(c.chatItems)
chatModel.chatId.value = c.id
closeAll()
}
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(c.chatItems)
chatModel.chatId.value = c.id
closeAll()
}
}
},
@@ -88,8 +91,10 @@ fun GroupMemberInfoView(
val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId)
if (memberContact != null) {
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
chatModel.addChat(memberChat)
openLoadedChat(memberChat, chatModel)
withChats {
addChat(memberChat)
openLoadedChat(memberChat, chatModel)
}
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
}
@@ -114,7 +119,9 @@ fun GroupMemberInfoView(
withBGApi {
kotlin.runCatching {
val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it)
chatModel.upsertGroupMember(rhId, groupInfo, mem)
withChats {
upsertGroupMember(rhId, groupInfo, mem)
}
}.onFailure {
newRole.value = prevValue
}
@@ -127,7 +134,9 @@ fun GroupMemberInfoView(
val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId)
if (r != null) {
connStats.value = r.second
chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -139,7 +148,9 @@ fun GroupMemberInfoView(
val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId)
if (r != null) {
connStats.value = r.second
chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -150,7 +161,9 @@ fun GroupMemberInfoView(
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
connStats.value = r.second
chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -161,7 +174,9 @@ fun GroupMemberInfoView(
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true)
if (r != null) {
connStats.value = r.second
chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -177,15 +192,17 @@ fun GroupMemberInfoView(
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
chatModel.upsertGroupMember(
rhId,
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
withChats {
upsertGroupMember(
rhId,
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
)
)
}
r
}
},
@@ -211,7 +228,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
withBGApi {
val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId)
if (removedMember != null) {
chatModel.upsertGroupMember(rhId, groupInfo, removedMember)
withChats {
upsertGroupMember(rhId, groupInfo, removedMember)
}
}
close?.invoke()
}
@@ -246,10 +265,10 @@ fun GroupMemberInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
fun knownDirectChat(contactId: Long): Pair<Chat, Contact>? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
chat to chat.chatInfo.contact
} else {
null
}
@@ -309,17 +328,53 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
OpenChatButton(onClick = { openDirectChat(contact.contactId) })
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
AudioCallButton(chat, contact)
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = {
showSendMessageToEnableCallsAlert()
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = {
showSendMessageToEnableCallsAlert()
})
} else { // no known contact chat && directMessages are off
InfoViewActionButton(painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title))
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title))
})
Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING))
InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = {
showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title))
})
}
}
SectionSpacer()
if (member.memberActive) {
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
OpenChatButton(onClick = { createMemberContact() })
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@@ -420,6 +475,20 @@ fun GroupMemberInfoLayout(
}
}
private fun showSendMessageToEnableCallsAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.cant_call_member_alert_title),
text = generalGetString(MR.strings.cant_call_member_send_message_alert_text)
)
}
private fun showDirectMessagesProhibitedAlert(title: String) {
AlertManager.shared.showAlertMsg(
title = title,
text = generalGetString(MR.strings.direct_messages_are_prohibited_in_chat)
)
}
@Composable
fun GroupMemberInfoHeader(member: GroupMember) {
Column(
@@ -513,12 +582,12 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_chat),
stringResource(MR.strings.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
disabledLook = false,
onClick = onClick
)
}
@@ -621,7 +690,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem
withBGApi {
val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings)
if (success) {
ChatModel.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
withChats {
upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
}
}
}
}
@@ -652,7 +723,9 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
withBGApi {
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
chatModel.upsertGroupMember(rhId, gInfo, updatedMember)
withChats {
upsertGroupMember(rhId, gInfo, updatedMember)
}
}
}
@@ -17,6 +17,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@@ -43,8 +44,10 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp)
if (g != null) {
m.updateGroup(rhId, g)
currentPreferences = preferences
withChats {
updateGroup(rhId, g)
currentPreferences = preferences
}
}
afterSave()
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.*
@@ -38,7 +39,9 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
withBGApi {
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p)
if (gInfo != null) {
chatModel.updateGroup(rhId, gInfo)
withChats {
updateGroup(rhId, gInfo)
}
close.invoke()
}
}
@@ -28,6 +28,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatJsonLength
@@ -52,7 +53,9 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: ()
val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated)
if (res != null) {
gInfo = res
m.updateGroup(rhId, res)
withChats {
updateGroup(rhId, res)
}
welcomeText.value = welcome ?: ""
}
afterSave()
@@ -1,6 +1,7 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -19,6 +20,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
@@ -32,7 +34,7 @@ import kotlinx.coroutines.*
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
@@ -47,6 +49,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showChatPreviews = chatModel.showChatPreviews.value
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
@@ -77,6 +80,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.Group ->
@@ -96,6 +100,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.Local -> {
ChatListNavLinkLayout(
@@ -114,6 +119,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest ->
@@ -133,6 +139,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
@@ -153,6 +160,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
@@ -169,12 +177,13 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
}
@Composable
private fun ErrorChatListItem() {
fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
@@ -471,13 +480,13 @@ fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, sh
}
@Composable
fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>, onSuccess: ((chat: Chat) -> Unit)? = null) {
ItemAction(
stringResource(MR.strings.accept_contact_button),
painterResource(MR.images.ic_check),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel, onSuccess)
showMenu.value = false
}
)
@@ -486,7 +495,7 @@ fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chat
painterResource(MR.images.ic_theater_comedy),
color = MaterialTheme.colors.onBackground,
onClick = {
acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel)
acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel, onSuccess)
showMenu.value = false
}
)
@@ -556,7 +565,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) {
withApi {
if (chat.chatStats.unreadCount > 0) {
val minUnreadItemId = chat.chatStats.minUnreadItemId
chatModel.markChatItemsRead(chat)
withChats {
markChatItemsRead(chat)
}
chatModel.controller.apiChatRead(
chat.remoteHostId,
chat.chatInfo.chatType,
@@ -573,7 +584,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) {
false
)
if (success) {
chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
withChats {
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
}
}
}
}
@@ -591,12 +604,14 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
true
)
if (success) {
chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
withChats {
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
}
}
}
}
fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, onSucess: ((chat: Chat) -> Unit)? = null) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.accept_connection_request__question),
text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)),
@@ -604,13 +619,13 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque
Column {
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel)
acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel, onSucess)
}) {
Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel)
acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel, onSucess)
}) {
Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
@@ -626,13 +641,16 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque
)
}
fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel, close: ((chat: Chat) -> Unit)? = null ) {
withBGApi {
val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId)
if (contact != null && isCurrentUser && contactRequest != null) {
val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(rhId, contactRequest.id, chat)
withChats {
replaceChat(rhId, contactRequest.id, chat)
}
chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected())
close?.invoke(chat)
}
}
}
@@ -640,7 +658,9 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe
fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
withBGApi {
chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId)
chatModel.removeChat(rhId, contactRequest.id)
withChats {
removeChat(rhId, contactRequest.id)
}
}
}
@@ -656,7 +676,9 @@ fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnecti
withBGApi {
AlertManager.shared.hideAlert()
if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) {
chatModel.removeChat(rhId, connection.id)
withChats {
removeChat(rhId, connection.id)
}
onSuccess()
}
}
@@ -675,7 +697,9 @@ fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatMo
withBGApi {
val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId)
if (r) {
chatModel.removeChat(rhId, chatInfo.id)
withChats {
removeChat(rhId, chatInfo.id)
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -737,7 +761,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress(
suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean {
val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId)
if (contact != null) {
chatModel.updateContact(rhId, contact)
withChats {
updateContact(rhId, contact)
}
AlertManager.privacySensitive.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent),
text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted),
@@ -778,7 +804,9 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
withBGApi {
val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId)
if (r) {
chatModel.removeChat(rhId, groupInfo.id)
withChats {
removeChat(rhId, groupInfo.id)
}
if (chatModel.chatId.value == groupInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -827,7 +855,9 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
else -> false
}
if (res && newChatInfo != null) {
chatModel.updateChatInfo(chat.remoteHostId, newChatInfo)
withChats {
updateChatInfo(chat.remoteHostId, newChatInfo)
}
if (chatSettings.enableNtfs != MsgFilter.All) {
ntfManager.cancelNotificationsForChat(chat.id)
}
@@ -848,6 +878,7 @@ expect fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
)
@Preview/*(
@@ -890,7 +921,8 @@ fun PreviewChatListNavLinkDirect() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -935,7 +967,8 @@ fun PreviewChatListNavLinkGroup() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -957,7 +990,8 @@ fun PreviewChatListNavLinkContactRequest() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -12,7 +12,7 @@ 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.shadow
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
@@ -33,7 +33,6 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.group.ProgressIndicator
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
@@ -42,28 +41,31 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private fun showNewChatSheet(oneHandUI: State<Boolean>, barTitle: String) {
ModalManager.start.closeModals()
ModalManager.end.closeModals()
ModalManager.start.showModalCloseable(
closeOnTop = !oneHandUI.value,
closeBarTitle = if (oneHandUI.value) barTitle else null,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) }
) { close ->
NewChatSheet(rh = chatModel.currentRemoteHost.value, close)
}
}
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
@@ -77,7 +79,31 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(scaffoldState.drawerState, userPickerState, stopped)} },
Scaffold(
topBar = {
if (!oneHandUI.state.value) {
Box(Modifier.padding(end = endPadding)) {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
oneHandUI
)
}
}
},
bottomBar = {
if (oneHandUI.state.value) {
Box(Modifier.padding(end = endPadding)) {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
oneHandUI
)
}
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
@@ -89,11 +115,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
if (!oneHandUI.state.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat))
}
},
Modifier
@@ -108,25 +134,33 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier))
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
var modifier = Modifier.padding(it).padding(end = endPadding)
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
ChatList(chatModel, searchText = searchText, oneHandUI = oneHandUI)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) {
OnboardingButtons(showNewChatSheet)
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
var textModifier = Modifier.align(Alignment.Center)
if (oneHandUI.state.value) {
textModifier = textModifier.scale(scaleX = 1f, scaleY = -1f)
}
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), textModifier, color = MaterialTheme.colors.secondary)
}
}
}
@@ -135,17 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
ActiveCallInteractiveArea(call)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
contentAlignment = if (oneHandUI.state.value) Alignment.BottomStart else Alignment.TopStart
) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
@@ -153,27 +187,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
@Composable
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
val color = MaterialTheme.colors.primaryVariant
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
val trianglePath = Path().apply {
moveTo(0.dp.toPx(), 0f)
lineTo(16.dp.toPx(), 0.dp.toPx())
lineTo(8.dp.toPx(), 10.dp.toPx())
lineTo(0.dp.toPx(), 0.dp.toPx())
}
drawPath(
color = color,
path = trianglePath
)
})
Spacer(Modifier.height(62.dp))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
@@ -191,10 +204,37 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, oneHandUI: SharedPreference<Boolean>) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
if (oneHandUI.state.value) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
barButtons.add {
IconButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat))
}
},
) {
Box(
modifier = Modifier
.background(if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, shape = CircleShape)
.padding(DEFAULT_PADDING_HALF)
){
Icon(
painterResource(MR.images.ic_edit_filled),
stringResource(MR.strings.add_contact_or_create_group),
Modifier.size(sp16),
tint = if (!stopped) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSecondary)
}
}
}
}
if (updatingProgress != null) {
barButtons.add {
val interactionSource = remember { MutableInteractionSource() }
@@ -291,10 +331,12 @@ fun SubscriptionStatusIndicator(click: (() -> Unit)) {
val scope = rememberCoroutineScope()
suspend fun setSubsTotal() {
val r = chatModel.controller.getAgentSubsTotal(chatModel.remoteHostId())
if (r != null) {
subs = r.first
hasSess = r.second
if (chatModel.currentUser.value != null) {
val r = chatModel.controller.getAgentSubsTotal(chatModel.remoteHostId())
if (r != null) {
subs = r.first
hasSess = r.second
}
}
}
@@ -342,6 +384,7 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
@@ -377,7 +420,7 @@ private fun ToggleFilterEnabledButton() {
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
expect fun ActiveCallInteractiveArea(call: Call)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
@@ -391,11 +434,22 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>, oneHandUI: SharedPreference<Boolean>) {
var modifier = Modifier.fillMaxWidth();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
@@ -415,7 +469,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
}
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
if (chatModel.chats.value.isNotEmpty()) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
@@ -481,9 +535,35 @@ private fun ErrorSettingsView() {
private var lazyListState = 0 to 0
enum class ScrollDirection {
Up, Down, Idle
}
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>, oneHandUI: SharedPreference<Boolean>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentScrollOffset = listState.firstVisibleItemScrollOffset
val threshold = 25
scrollDirection = when {
currentIndex > previousIndex -> ScrollDirection.Down
currentIndex < previousIndex -> ScrollDirection.Up
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
else -> scrollDirection
}
previousIndex = currentIndex
previousScrollOffset = currentScrollOffset
}
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
@@ -494,7 +574,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.toList())
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
@@ -504,7 +584,9 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) {
0
} else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
@@ -512,7 +594,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
}
.background(MaterialTheme.colors.background)
) {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, oneHandUI)
Divider()
}
}
@@ -520,11 +602,17 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ChatListNavLinkView(chat, nextChatSelected)
ChatListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (chats.isEmpty() && chatModel.chats.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
var modifier = Modifier.fillMaxSize();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier, contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
@@ -543,17 +631,18 @@ private fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
@@ -207,7 +207,7 @@ fun ChatPreviewView(
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) {
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary)
} else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -719,7 +719,9 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta
val scope = rememberCoroutineScope()
suspend fun setServersSummary() {
serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId())
if (chatModel.currentUser.value != null) {
serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId())
}
}
LaunchedEffect(Unit) {
@@ -6,6 +6,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -21,7 +22,8 @@ fun ShareListNavLinkView(
chatModel: ChatModel,
isMediaOrFileAttachment: Boolean,
isVoice: Boolean,
hasSimplexLink: Boolean
hasSimplexLink: Boolean,
oneHandUI: State<Boolean>
) {
val stopped = chatModel.chatRunning.value == false
val scope = rememberCoroutineScope()
@@ -29,7 +31,7 @@ fun ShareListNavLinkView(
is ChatInfo.Direct -> {
val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited) },
chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited, oneHandUI = oneHandUI) },
click = {
if (voiceProhibited) {
showForwardProhibitedByPrefAlert()
@@ -46,7 +48,7 @@ fun ShareListNavLinkView(
val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
val prohibitedByPref = simplexLinkProhibited || fileProhibited || voiceProhibited
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref) },
chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref, oneHandUI = oneHandUI) },
click = {
if (prohibitedByPref) {
showForwardProhibitedByPrefAlert()
@@ -59,7 +61,7 @@ fun ShareListNavLinkView(
}
is ChatInfo.Local ->
ShareListNavLinkLayout(
chatLinkPreview = { SharePreviewView(chat, disabled = false) },
chatLinkPreview = { SharePreviewView(chat, disabled = false, oneHandUI = oneHandUI) },
click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
stopped
)
@@ -78,7 +80,7 @@ private fun showForwardProhibitedByPrefAlert() {
private fun ShareListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
stopped: Boolean
stopped: Boolean,
) {
SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) {
chatLinkPreview()
@@ -87,9 +89,15 @@ private fun ShareListNavLinkLayout(
}
@Composable
private fun SharePreviewView(chat: Chat, disabled: Boolean) {
private fun SharePreviewView(chat: Chat, disabled: Boolean, oneHandUI: State<Boolean>) {
var modifier = Modifier.fillMaxSize()
if (oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Row(
Modifier.fillMaxSize(),
modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -7,6 +7,7 @@ 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.scale
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -24,13 +25,16 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
Scaffold(
Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
scaffoldState = scaffoldState,
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
topBar = { if (!oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
bottomBar = { if (oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
) {
val sharedContent = chatModel.sharedContent.value
var isMediaOrFileAttachment = false
var isVoice = false
@@ -56,21 +60,27 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
null -> {}
}
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(Modifier.padding(it)) {
Column(
modifier = Modifier
.fillMaxSize()
modifier = modifier
) {
if (chatModel.chats.isNotEmpty()) {
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
chatModel,
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink
hasSimplexLink = hasSimplexLink,
oneHandUI = oneHandUI.state
)
} else {
EmptyList()
EmptyList(oneHandUI = oneHandUI.state)
}
}
}
@@ -91,8 +101,14 @@ private fun hasSimplexLink(msg: String): Boolean {
}
@Composable
private fun EmptyList() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
private fun EmptyList(oneHandUI: State<Boolean>) {
var modifier = Modifier.fillMaxSize()
if (oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier, contentAlignment = Alignment.Center) {
Text(stringResource(MR.strings.you_have_no_chats), color = MaterialTheme.colors.secondary)
}
}
@@ -127,7 +143,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
})
}
}
if (chatModel.chats.size >= 8) {
if (chatModel.chats.value.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
@@ -182,11 +198,12 @@ private fun ShareList(
search: String,
isMediaOrFileAttachment: Boolean,
isVoice: Boolean,
hasSimplexLink: Boolean
hasSimplexLink: Boolean,
oneHandUI: State<Boolean>
) {
val chats by remember(search) {
derivedStateOf {
val sorted = chatModel.chats.toList().sortedByDescending { it.chatInfo is ChatInfo.Local }
val sorted = chatModel.chats.value.toList().sortedByDescending { it.chatInfo is ChatInfo.Local }
if (search.isEmpty()) {
sorted.filter { it.chatInfo.ready }
} else {
@@ -203,7 +220,8 @@ private fun ShareList(
chatModel,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink
hasSimplexLink = hasSimplexLink,
oneHandUI = oneHandUI
)
}
}
@@ -40,6 +40,7 @@ fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
showSettings: Boolean = true,
contentAlignment: Alignment = Alignment.TopStart,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
@@ -149,7 +150,8 @@ fun UserPicker(
.graphicsLayer {
alpha = animatedFloat.value
translationY = (animatedFloat.value - 1) * xOffset
}
},
contentAlignment = contentAlignment
) {
Column(
Modifier
@@ -0,0 +1,143 @@
package chat.simplex.common.views.contacts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.ContactType
import chat.simplex.common.views.newchat.chatContactType
import chat.simplex.res.MR
import kotlinx.coroutines.delay
private fun onRequestAccepted(chat: Chat) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
ModalManager.start.closeModals()
if (chatInfo.contact.sndReady) {
openLoadedChat(chat, chatModel)
}
}
}
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val rhId = chat.remoteHostId
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(rhId to chat.chatInfo.id)
val contactType = chatContactType(chat)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
val view = LocalMultiplatformView()
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
hideKeyboard(view)
when (contactType) {
ContactType.RECENT -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
ModalManager.start.closeModals()
}
}
ContactType.CHAT_DELETED -> {
withApi {
openChat(rhId, chat.chatInfo, chatModel)
withChats {
updateContact(rhId, chat.chatInfo.contact.copy(chatDeleted = false))
}
ModalManager.start.closeModals()
}
}
ContactType.CARD -> {
askCurrentOrIncognitoProfileConnectContactViaAddress(
chatModel,
rhId,
chat.chatInfo.contact,
close = { ModalManager.start.closeModals() },
openChat = true
)
}
else -> {}
}
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
DeleteContactAction(chat, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
hideKeyboard(view)
contactRequestAlertDialog(
rhId,
chat.chatInfo,
chatModel,
onSucess = { onRequestAccepted(it) }
)
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactRequestMenuItems(
rhId = chat.remoteHostId,
chatInfo = chat.chatInfo,
chatModel = chatModel,
showMenu = showMenu,
onSuccess = { onRequestAccepted(it) }
)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
else -> {}
}
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@@ -0,0 +1,131 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.newchat.ContactType
import chat.simplex.common.views.newchat.chatContactType
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
val contactType = chatContactType(chat)
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
fun chatPreviewTitle() {
val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) }
val textColor = when {
deleting -> MaterialTheme.colors.secondary
contactType == ContactType.CARD -> MaterialTheme.colors.primary
contactType == ContactType.REQUEST -> MaterialTheme.colors.primary
contactType == ContactType.RECENT && chat.chatInfo.incognito -> Indigo
else -> Color.Unspecified
}
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
else -> {}
}
}
Row(
modifier = Modifier.padding(PaddingValues(horizontal = DEFAULT_PADDING_HALF)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 42.dp)
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Box(modifier = Modifier.weight(10f, fill = true)) {
chatPreviewTitle()
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (chat.chatInfo is ChatInfo.ContactRequest) {
Icon(
painterResource(MR.images.ic_check),
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(23.dp)
)
}
if (contactType == ContactType.CARD) {
Icon(
painterResource(MR.images.ic_mail),
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier
.size(21.dp)
)
}
if (chat.chatInfo.chatSettings?.favorite == true) {
Icon(
painterResource(MR.images.ic_star_filled),
contentDescription = generalGetString(MR.strings.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(17.dp)
)
if (chat.chatInfo.incognito) {
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
}
}
if (chat.chatInfo.incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
}
@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -502,7 +502,11 @@ fun deleteChatDatabaseFilesAndState() {
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
chatModel.chatId.value = null
chatModel.chatItems.clear()
chatModel.chats.clear()
withLongRunningApi {
withChats {
chats.clear()
}
}
chatModel.users.clear()
ntfManager.cancelAllNotifications()
}
@@ -714,10 +718,10 @@ private fun afterSetCiTTL(
appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath)
withApi {
try {
updatingChatsMutex.withLock {
withChats {
// this is using current remote host on purpose - if it changes during update, it will load correct chats
val chats = m.controller.apiGetChats(m.remoteHostId())
m.updateChats(chats)
updateChats(chats)
}
} catch (e: Exception) {
Log.e(TAG, "apiGetChats error: ${e.message}")
@@ -11,6 +11,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.*
@@ -18,17 +20,26 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) {
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}) {
var rowModifier = Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
if (!closeBarTitle.isNullOrEmpty()) {
rowModifier = rowModifier.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
}
Column(
Modifier
verticalArrangement = arrangement,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.padding(horizontal = AppBarHorizontalPadding)
) {
Row(
modifier = Modifier.padding(horizontal = AppBarHorizontalPadding),
content = {
Row(
Modifier.fillMaxWidth().height(AppBarHeight * fontSizeSqrtMultiplier),
rowModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -37,6 +48,18 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
} else {
Spacer(Modifier)
}
if (!closeBarTitle.isNullOrEmpty()) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
closeBarTitle,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
}
Row {
endButtons()
}
@@ -16,7 +16,7 @@ import chat.simplex.res.MR
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
@@ -126,5 +126,6 @@ private fun TopAppBar(
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
@@ -24,6 +25,8 @@ fun ModalView(
enableClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
modifier: Modifier = Modifier,
closeOnTop: Boolean = true,
closeBarTitle: String? = null,
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
@@ -32,8 +35,16 @@ fun ModalView(
}
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
Box(modifier) { content() }
if (closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
}
Box(if (closeOnTop) modifier else modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier)) {
content()
}
}
if (!closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons, arrangement = Arrangement.Bottom, closeBarTitle = closeBarTitle)
}
}
}
@@ -60,17 +71,17 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null,endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
val data = ModalData()
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content() })
ModalView(close, showClose = showClose, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, endButtons = endButtons, content = { data.content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
val data = ModalData()
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content(close) })
ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, content = { data.content(close) })
}
}
@@ -105,6 +116,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
val hasModalsOpen: Boolean
@Composable get () = remember { modalCount }.value > 0
fun openModalCount() = modalCount.value
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
@@ -18,6 +18,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.group.AddGroupMembersView
import chat.simplex.common.views.chatlist.setGroupMembers
@@ -32,19 +33,22 @@ import kotlinx.coroutines.launch
import java.net.URI
@Composable
fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) {
fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) {
val rhId = rh?.remoteHostId
AddGroupLayout(
createGroup = { incognito, groupProfile ->
withBGApi {
val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile)
if (groupInfo != null) {
chatModel.updateGroup(rhId = rhId, groupInfo)
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
withChats {
updateGroup(rhId = rhId, groupInfo)
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
}
setGroupMembers(rhId, groupInfo, chatModel)
close.invoke()
closeAll.invoke()
if (!groupInfo.incognito) {
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close)
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
@@ -331,7 +332,9 @@ suspend fun connectViaUri(
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
if (pcc != null) {
chatModel.updateContactConnection(rhId, pcc)
withChats {
updateContactConnection(rhId, pcc)
}
close?.invoke()
AlertManager.privacySensitive.showAlertMsg(
title = generalGetString(MR.strings.connection_request_sent),
@@ -22,6 +22,7 @@ import chat.simplex.common.views.chat.LocalAliasEditor
import chat.simplex.common.views.chatlist.deleteContactConnectionAlert
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.PendingContactConnection
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.*
@@ -186,7 +187,9 @@ fun DeleteButton(onClick: () -> Unit) {
private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withBGApi {
chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let {
chatModel.updateContactConnection(rhId, it)
withChats {
updateContactConnection(rhId, it)
}
}
}
@@ -1,177 +1,597 @@
package chat.simplex.common.views.newchat
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import SectionDividerSpaced
import SectionItemView
import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.*
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.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatModel
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.ScrollDirection
import chat.simplex.common.views.contacts.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.distinctUntilChanged
import java.net.URI
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedViewState>, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) {
// TODO close new chat if remote host changes in model
if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) }
NewChatSheetLayout(
newChatSheetState,
stopped,
addContact = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) }
},
closeNewChatSheet,
)
fun NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) {
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
Column(
modifier = Modifier.fillMaxSize()
) {
if (!oneHandUI.state.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.new_chat),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
val closeAll = { ModalManager.start.closeModals() }
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Column(modifier = modifier) {
NewChatSheetLayout(
addContact = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) }
},
scanPaste = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
},
createGroup = {
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
},
rh = rh,
close = close,
oneHandUI = oneHandUI
)
}
}
}
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
enum class ContactType {
CARD, REQUEST, RECENT, CHAT_DELETED, UNLISTED
}
fun chatContactType(chat: Chat): ContactType {
return when (val cInfo = chat.chatInfo) {
is ChatInfo.ContactRequest -> ContactType.REQUEST
is ChatInfo.Direct -> {
val contact = cInfo.contact;
when {
contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD
contact.chatDeleted -> ContactType.CHAT_DELETED
contact.contactStatus == ContactStatus.Active -> ContactType.RECENT
else -> ContactType.UNLISTED
}
}
else -> ContactType.UNLISTED
}
}
private fun filterContactTypes(c: List<Chat>, contactTypes: List<ContactType>): List<Chat> {
return c.filter { chat -> contactTypes.contains(chatContactType(chat)) }
}
private var lazyListState = 0 to 0
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
rh: RemoteHostInfo?,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
close: () -> Unit,
oneHandUI: SharedPreference<Boolean>
) {
var newChat by remember { mutableStateOf(newChatSheetState.value) }
val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor
val animatedColor = remember {
Animatable(
if (newChat.isVisible()) Color.Transparent else resultingColor,
Color.VectorConverter(resultingColor.colorSpace)
)
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val baseContactTypes = listOf(ContactType.CARD, ContactType.RECENT, ContactType.REQUEST)
val contactTypes by remember(baseContactTypes, searchText.value.text.isEmpty()) {
derivedStateOf { contactTypesSearchTargets(baseContactTypes, searchText.value.text.isEmpty()) }
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
LaunchedEffect(Unit) {
launch {
newChatSheetState.collect {
newChat = it
launch {
animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec())
}
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) closeNewChatSheet(false)
val allChats by remember(chatModel.chats.value, contactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) }
}
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentScrollOffset = listState.firstVisibleItemScrollOffset
val threshold = 25
scrollDirection = when {
currentIndex > previousIndex -> ScrollDirection.Down
currentIndex < previousIndex -> ScrollDirection.Up
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
else -> scrollDirection
}
previousIndex = currentIndex
previousScrollOffset = currentScrollOffset
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
var sectionModifier = Modifier.fillMaxWidth()
if (oneHandUI.state.value) {
sectionModifier = sectionModifier.scale(scaleX = 1f, scaleY = -1f)
}
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
stickyHeader {
Column(
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) {
0
} else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background)
) {
if (!oneHandUI.state.value) {
Divider()
}
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
oneHandUI = oneHandUI
)
Divider()
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Column(
Modifier
.fillMaxSize()
.padding(end = endPadding)
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else 0, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) }
.drawBehind { drawRect(animatedColor.value) },
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
MaterialTheme.colors.background
LazyColumn(Modifier
.graphicsLayer {
alpha = animatedFloat.value
translationY = (1 - animatedFloat.value) * 20.dp.toPx()
}) {
items(actions.size) { index ->
item {
Spacer(Modifier.padding(bottom = DEFAULT_PADDING))
if (searchText.value.text.isEmpty()) {
Row {
Spacer(Modifier.weight(1f))
Box(contentAlignment = Alignment.CenterEnd) {
Button(
actions[index],
shape = RoundedCornerShape(21.dp * fontSizeSqrtMultiplier),
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp * fontSizeSqrtMultiplier)
) {
Text(
stringResource(titles[index]),
Modifier.padding(start = DEFAULT_PADDING_HALF),
color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary,
fontWeight = FontWeight.Medium,
)
Icon(
painterResource(icons[index]),
stringResource(titles[index]),
Modifier.size(42.dp * fontSizeSqrtMultiplier),
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
)
SectionView {
NewChatButton(
icon = painterResource(MR.images.ic_add_link),
text = stringResource(MR.strings.add_contact_tab),
click = addContact,
extraPadding = true,
oneHandUI = oneHandUI.state
)
NewChatButton(
icon = painterResource(MR.images.ic_qr_code),
text = if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link),
click = scanPaste,
extraPadding = true,
oneHandUI = oneHandUI.state
)
NewChatButton(
icon = painterResource(MR.images.ic_group),
text = stringResource(MR.strings.create_group_button),
click = createGroup,
extraPadding = true,
oneHandUI = oneHandUI.state
)
}
}
SectionDividerSpaced(maxBottomPadding = false)
val deletedContactTypes = listOf(ContactType.CHAT_DELETED)
val deletedChats by remember(chatModel.chats.value, deletedContactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) }
}
if (deletedChats.isNotEmpty()) {
Row(modifier = sectionModifier) {
SectionView {
SectionItemView(
click = {
ModalManager.start.showCustomModal { closeDeletedChats ->
ModalView(
close = closeDeletedChats,
closeOnTop = !oneHandUI.state.value,
closeBarTitle = if (oneHandUI.state.value) generalGetString(MR.strings.deleted_chats) else null,
endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) }
) {
DeletedContactsView(rh = rh, close = {
ModalManager.start.closeModals()
})
}
}
}
) {
Icon(
painterResource(MR.images.ic_folder_open),
contentDescription = stringResource(MR.strings.deleted_chats),
tint = MaterialTheme.colors.secondary,
)
TextIconSpaced(extraPadding = true)
Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground)
}
}
}
Spacer(Modifier.width(DEFAULT_PADDING))
SectionDividerSpaced()
}
Spacer(Modifier.height(DEFAULT_PADDING))
}
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING).size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
item {
if (filteredContactChats.isNotEmpty() && !oneHandUI.state.value) {
Text(
stringResource(MR.strings.contact_list_header_title).uppercase(), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2,
modifier = sectionModifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp
)
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary
)
}
}
}
}
@Composable
private fun NewChatButton(
icon: Painter,
text: String,
click: () -> Unit,
textColor: Color = Color.Unspecified,
iconColor: Color = MaterialTheme.colors.secondary,
disabled: Boolean = false,
extraPadding: Boolean = false,
oneHandUI: State<Boolean>
) {
SectionItemView(click, disabled = disabled) {
Row(modifier = if (oneHandUI.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier) {
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor)
TextIconSpaced(extraPadding)
Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor)
}
}
}
@Composable
private fun ContactsSearchBar(
listState: LazyListState,
searchText: MutableState<TextFieldValue>,
searchShowingSimplexLink: MutableState<Boolean>,
searchChatFilteredBySimplexLink: MutableState<String?>,
close: () -> Unit,
oneHandUI: SharedPreference<Boolean>
) {
var modifier = Modifier.fillMaxWidth();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
var focused by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val focusRequester = remember { FocusRequester() }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
Icon(
painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
)
Icon(
painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText =
link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value =
searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = link.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
}
}
@Composable
private fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(sp16)
)
}
}
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, close: () -> Unit, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
URI.create(link),
incognito = null,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
close = close,
cleanup = cleanup,
)
}
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
return if (linkChatId != null) {
contactChats.filter { it.id == linkChatId }
} else {
contactChats.filter { chat ->
filterChat(
chat = chat,
searchText = s,
showUnreadAndFavorites = showUnreadAndFavorites
)
}
}
.sortedWith(chatsByTypeComparator)
}
private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Boolean): Boolean {
var meetsPredicate = true;
val s = searchText.trim().lowercase()
val cInfo = chat.chatInfo
if (searchText.isNotEmpty()) {
meetsPredicate = viewNameContains(cInfo, s) ||
if (cInfo is ChatInfo.Direct) (cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s)) else false
}
if (showUnreadAndFavorites) {
meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?: false)
}
return meetsPredicate;
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
private val chatsByTypeComparator = Comparator<Chat> { chat1, chat2 ->
val chat1Type = chatContactType(chat1)
val chat2Type = chatContactType(chat2)
when {
chat1Type.ordinal < chat2Type.ordinal -> -1
chat1Type.ordinal > chat2Type.ordinal -> 1
else -> chat2.chatInfo.chatTs.compareTo(chat1.chatInfo.chatTs)
}
}
private fun contactTypesSearchTargets(baseContactTypes: List<ContactType>, searchEmpty: Boolean): List<ContactType> {
return if (baseContactTypes.contains(ContactType.CHAT_DELETED) || searchEmpty) {
baseContactTypes
} else {
baseContactTypes + ContactType.CHAT_DELETED
}
}
@Composable
private fun DeletedContactsView(rh: RemoteHostInfo?, close: () -> Unit) {
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
var modifier = Modifier.fillMaxSize()
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Column(
modifier,
) {
if (!oneHandUI.state.value) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.deleted_chats),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val contactTypes = listOf(ContactType.CHAT_DELETED)
val allChats by remember(chatModel.chats.value, contactTypes) {
derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) }
}
val filteredContactChats = filteredContactChats(
showUnreadAndFavorites = showUnreadAndFavorites,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
searchShowingSimplexLink = searchShowingSimplexLink,
searchText = searchText.value.text,
contactChats = allChats
)
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
item {
Divider()
ContactsSearchBar(
listState = listState,
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
oneHandUI = oneHandUI
)
Divider()
Spacer(Modifier.padding(bottom = DEFAULT_PADDING))
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) {
derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
}
}
ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
generalGetString(MR.strings.no_filtered_contacts),
color = MaterialTheme.colors.secondary,
modifier = if (oneHandUI.state.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier
)
}
}
}
}
}
@Composable
@@ -267,13 +687,6 @@ fun ActionButton(
@Composable
private fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
NewChatSheet(rh = null, close = {})
}
}
@@ -25,6 +25,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -62,7 +63,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
* It will be dropped automatically when connection established or when user goes away from this screen.
* It applies only to Android because on Desktop center space will not be overlapped by [AddContactLearnMore]
**/
if (chatModel.showingInvitation.value != null && (!ModalManager.center.hasModalsOpen() || appPlatform.isDesktop)) {
if (chatModel.showingInvitation.value != null && (ModalManager.start.openModalCount() == 1 || appPlatform.isDesktop)) {
val conn = contactConnection.value
if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) {
AlertManager.shared.showAlertDialog(
@@ -218,8 +219,10 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection
withBGApi {
val contactConn = contactConnection.value ?: return@withBGApi
val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi
contactConnection.value = conn
chatModel.updateContactConnection(rhId, conn)
withChats {
contactConnection.value = conn
updateContactConnection(rhId, conn)
}
}
chatModel.markShowingInvitationUsed()
}
@@ -234,7 +237,8 @@ private fun AddContactLearnMoreButton() {
ModalManager.end.showModalCloseable { close ->
AddContactLearnMore(close)
}
}
},
Modifier.size(18.dp * fontSizeSqrtMultiplier)
) {
Icon(
painterResource(MR.images.ic_info),
@@ -367,9 +371,11 @@ private fun createInvitation(
withBGApi {
val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get())
if (r != null) {
chatModel.updateContactConnection(rhId, r.second)
chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false)
contactConnection.value = r.second
withChats {
updateContactConnection(rhId, r.second)
chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false)
contactConnection.value = r.second
}
} else {
creatingConnReq.value = false
if (alert != null) {
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.res.MR
@@ -33,7 +34,9 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) {
if (updated != null) {
val (updatedProfile, updatedContacts) = updated
m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences)
updatedContacts.forEach { m.updateContact(user.remoteHostId, it) }
withChats {
updatedContacts.forEach { updateContact(user.remoteHostId, it) }
}
currentPreferences = preferences
}
afterSave()
@@ -32,6 +32,7 @@ import chat.simplex.common.views.isValidDisplayName
import chat.simplex.common.views.localauth.SetAppPasscodeView
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import kotlin.math.min
import kotlin.math.roundToInt
@@ -121,14 +122,16 @@ fun PrivacySettingsView(
chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable)
if (clearOverrides) {
// For loop here is to prevent ConcurrentModificationException that happens with forEach
for (i in 0 until chatModel.chats.size) {
val chat = chatModel.chats[i]
if (chat.chatInfo is ChatInfo.Direct) {
var contact = chat.chatInfo.contact
val sendRcpts = contact.chatSettings.sendRcpts
if (sendRcpts != null && sendRcpts != enable) {
contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null))
chatModel.updateContact(currentUser.remoteHostId, contact)
withChats {
for (i in 0 until chats.size) {
val chat = chats[i]
if (chat.chatInfo is ChatInfo.Direct) {
var contact = chat.chatInfo.contact
val sendRcpts = contact.chatSettings.sendRcpts
if (sendRcpts != null && sendRcpts != enable) {
contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null))
updateContact(currentUser.remoteHostId, contact)
}
}
}
}
@@ -143,15 +146,17 @@ fun PrivacySettingsView(
chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true)
chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable)
if (clearOverrides) {
// For loop here is to prevent ConcurrentModificationException that happens with forEach
for (i in 0 until chatModel.chats.size) {
val chat = chatModel.chats[i]
if (chat.chatInfo is ChatInfo.Group) {
var groupInfo = chat.chatInfo.groupInfo
val sendRcpts = groupInfo.chatSettings.sendRcpts
if (sendRcpts != null && sendRcpts != enable) {
groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null))
chatModel.updateGroup(currentUser.remoteHostId, groupInfo)
withChats {
// For loop here is to prevent ConcurrentModificationException that happens with forEach
for (i in 0 until chats.size) {
val chat = chats[i]
if (chat.chatInfo is ChatInfo.Group) {
var groupInfo = chat.chatInfo.groupInfo
val sendRcpts = groupInfo.chatSettings.sendRcpts
if (sendRcpts != null && sendRcpts != enable) {
groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null))
updateGroup(currentUser.remoteHostId, groupInfo)
}
}
}
}
@@ -164,7 +169,7 @@ fun PrivacySettingsView(
DeliveryReceiptsSection(
currentUser = currentUser,
setOrAskSendReceiptsContacts = { enable ->
val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Direct) {
val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
@@ -179,7 +184,7 @@ fun PrivacySettingsView(
}
},
setOrAskSendReceiptsGroups = { enable ->
val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat ->
val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat ->
if (chat.chatInfo is ChatInfo.Group) {
val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts
count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1)
@@ -351,6 +351,7 @@
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Chats</string>
<string name="toolbar_settings">Settings</string>
<string name="contact_connection_pending">connecting…</string>
<string name="member_contact_send_direct_message">send direct message</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
@@ -442,10 +443,25 @@
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="info_view_connect_button">connect</string>
<string name="info_view_open_button">open</string>
<string name="info_view_message_button">message</string>
<string name="info_view_call_button">call</string>
<string name="info_view_search_button">search</string>
<string name="info_view_video_button">video</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_cannot_undo_warning">Contact will be deleted - this cannot be undone!</string>
<string name="keep_conversation">Keep conversation</string>
<string name="only_delete_conversation">Only delete conversation</string>
<string name="confirm_delete_contact_question">Confirm contact deletion?</string>
<string name="delete_and_notify_contact">Delete and notify contact</string>
<string name="delete_without_notification">Delete without notification</string>
<string name="button_delete_contact">Delete contact</string>
<string name="conversation_deleted">Conversation deleted!</string>
<string name="you_can_still_send_messages_to_contact">You can still send messages to %1$s from the Deleted chats.</string>
<string name="contact_deleted">Contact deleted!</string>
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the list of chats.</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -633,6 +649,7 @@
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -651,6 +668,10 @@
<string name="invalid_qr_code">Invalid QR code</string>
<string name="code_you_scanned_is_not_simplex_link_qr_code">The code you scanned is not a SimpleX link QR code.</string>
<string name="deleted_chats">Deleted chats</string>
<string name="no_filtered_contacts">No filtered contacts</string>
<string name="contact_list_header_title">Your contacts</string>
<!-- ScanCodeView.kt -->
<string name="scan_code">Scan code</string>
<string name="incorrect_code">Incorrect security code!</string>
@@ -1123,6 +1144,7 @@
<string name="settings_developer_tools">Developer tools</string>
<string name="settings_experimental_features">Experimental features</string>
<string name="settings_section_title_socks">SOCKS PROXY</string>
<string name="settings_section_title_interface" translatable="false">INTERFACE</string>
<string name="settings_section_title_language" translatable="false">LANGUAGE</string>
<string name="settings_section_title_icon">APP ICON</string>
<string name="settings_section_title_themes">THEMES</string>
@@ -1258,6 +1280,7 @@
<string name="database_downgrade">Database downgrade</string>
<string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="one_hand_ui">One-hand UI</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="chat_list_always_visible">Show chat list in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
@@ -1450,6 +1473,7 @@
<string name="send_receipts_disabled">disabled</string>
<string name="send_receipts_disabled_alert_title">Receipts are disabled</string>
<string name="send_receipts_disabled_alert_msg">This group has over %1$d members, delivery receipts are not sent.</string>
<string name="action_button_add_members">Invite</string>
<!-- Chat / Chat item info -->
<string name="section_title_for_console">FOR CONSOLE</string>
@@ -1527,6 +1551,17 @@
<string name="message_queue_info_none">none</string>
<string name="message_queue_info_server_info">server queue info: %1$s\n\nlast received msg: %2$s</string>
<string name="cant_call_contact_alert_title">Can\'t call contact</string>
<string name="cant_call_contact_connecting_wait_alert_text">Connecting to contact, please wait or check later!</string>
<string name="cant_call_contact_deleted_alert_text">Contact is deleted.</string>
<string name="allow_calls_question">Allow calls?</string>
<string name="you_need_to_allow_calls">You need to allow your contact to call to be able to call them.</string>
<string name="calls_prohibited_alert_title">Calls prohibited!</string>
<string name="calls_prohibited_ask_to_enable_calls_alert_text">Please ask your contact to enable calls.</string>
<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>
<!-- GroupWelcomeView.kt -->
<string name="group_welcome_title">Welcome message</string>
<string name="save_welcome_message_question">Save welcome message?</string>
@@ -52,9 +52,11 @@ actual fun LazyColumnWithScrollBar(
}
}
}
LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout)
Box {
LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout)
}
}
}
}
@@ -35,6 +35,7 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>,
) {
var modifier = Modifier.fillMaxWidth()
if (!disabled) modifier = modifier
@@ -22,53 +22,67 @@ import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
actual fun ActiveCallInteractiveArea(call: Call) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(
Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp)
.align(Alignment.TopEnd)
) {
if (media == CallMediaType.Video) {
Icon(
painterResource(MR.images.ic_videocam_filled),
stringResource(MR.strings.icon_descr_video_call),
Modifier.size(18.dp),
tint = Color.White
)
} else {
Icon(
painterResource(MR.images.ic_call_filled),
stringResource(MR.strings.icon_descr_audio_call),
Modifier.size(18.dp),
tint = Color.White
)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(
stringResource(MR.strings.icon_descr_hang_up),
painterResource(MR.images.ic_call_end_filled),
color = MaterialTheme.colors.error,
onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.size
import chat.simplex.common.platform.*
import chat.simplex.common.platform.DesktopPlatform
import chat.simplex.common.showApp
+5 -1
View File
@@ -40,6 +40,10 @@ if [ ! -f ../appimagetool-x86_64.AppImage ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ../appimagetool-x86_64.AppImage
chmod +x ../appimagetool-x86_64.AppImage
fi
../appimagetool-x86_64.AppImage .
if [ ! -f ../runtime-fuse3-x86_64 ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/old/runtime-fuse3-x86_64 -O ../runtime-fuse3-x86_64
chmod +x ../runtime-fuse3-x86_64
fi
../appimagetool-x86_64.AppImage --runtime-file ../runtime-fuse3-x86_64 .
mv *imple*.AppImage ../../
+4 -4
View File
@@ -1745,7 +1745,7 @@ markDirectChatItemDeleted db User {userId} Contact {contactId} ci@ChatItem {meta
WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
|]
(DBCIDeleted, deletedTs, currentTs, userId, contactId, itemId)
pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs}}
pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs, editable = False, deletable = False}}
getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect)
getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do
@@ -1945,7 +1945,7 @@ markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta}
WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
|]
(DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId)
pure ci {meta = meta {itemDeleted}}
pure ci {meta = meta {itemDeleted, editable = False, deletable = False}}
markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv)
markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do
@@ -1958,7 +1958,7 @@ markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta}
WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
|]
(DBCIBlocked, deletedTs, deletedTs, userId, groupId, chatItemId' ci)
pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs}}
pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs, editable = False, deletable = False}}
markGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv)
markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do
@@ -1971,7 +1971,7 @@ markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta
WHERE user_id = ? AND group_id = ? AND chat_item_id = ?
|]
(DBCIBlockedByAdmin, deletedTs, deletedTs, userId, groupId, chatItemId' ci)
pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs}}
pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs, editable = False, deletable = False}}
getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup)
getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do
+1 -1
View File
@@ -248,7 +248,7 @@
"signing-key-fingerprint": "Otisk podpisového klíče (SHA-256)",
"f-droid-org-repo": "F-Droid.org repozitář",
"stable-versions-built-by-f-droid-org": "Stabilní verze vytvořené F-Droid.org",
"releases-to-this-repo-are-done-1-2-days-later": "Vydání v tomto repozitáři se provádí o 1-2 dny později",
"releases-to-this-repo-are-done-1-2-days-later": "Vydání v tomto repozitáři se provádí o několik dní později",
"jobs": "Připojit k týmu",
"hero-overlay-card-3-p-2": "Trail of Bits přezkoumala kryptografii a síťové komponenty SimpleX platformy v listopadu 2022.",
"hero-overlay-card-3-p-3": "Přečtěte si více v <a href=\"/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html\">ohlášení</a>.",