Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-09-20 09:45:53 +01:00
34 changed files with 963 additions and 372 deletions
+103 -52
View File
@@ -357,6 +357,12 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r throw r
} }
func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
@@ -1039,77 +1045,122 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
} }
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
if let chatItem = await apiReceiveFile( await receiveFiles(
fileId: fileId, user: user,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(), fileIds: [fileId],
encrypted: privacyEncryptLocalFilesGroupDefault.get(), userApprovedRelays: userApprovedRelays,
auto: auto auto: auto
) { )
await chatItemSimpleUpdate(user, chatItem)
}
} }
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? { func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline)) var fileIdsToApprove = [Int64]()
let am = AlertManager.shared var srvsToApprove = Set<String>()
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } var otherFileErrs = [ChatResponse]()
if case .rcvFileAcceptedSndCancelled = r {
logger.debug("apiReceiveFile error: sender cancelled file transfer") for fileId in fileIds {
if !auto { let r = await chatSendCmd(
am.showAlertMsg( .receiveFile(
title: "Cannot receive file", fileId: fileId,
message: "Sender cancelled file transfer." userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
inline: nil
) )
)
switch r {
case let .rcvFileAccepted(_, chatItem):
await chatItemSimpleUpdate(user, chatItem)
default:
if let chatError = chatError(r) {
switch chatError {
case let .fileNotApproved(fileId, unknownServers):
fileIdsToApprove.append(fileId)
srvsToApprove.formUnion(unknownServers)
default:
otherFileErrs.append(r)
}
}
} }
} else if let networkErrorAlert = networkErrorAlert(r) { }
logger.error("apiReceiveFile network error: \(String(describing: r))")
if !auto { if !auto {
am.showAlert(networkErrorAlert) let otherErrsStr = if otherFileErrs.isEmpty {
""
} else if otherFileErrs.count == 1 {
"\(otherFileErrs[0])"
} else if otherFileErrs.count == 2 {
"\(otherFileErrs[0])\n\(otherFileErrs[1])"
} else {
"\(otherFileErrs[0])\n\(otherFileErrs[1])\nand \(otherFileErrs.count - 2) other error(s)"
} }
} else {
switch chatError(r) { // If there are not approved files, alert is shown the same way both in case of singular and plural files reception
case .fileCancelled: if !fileIdsToApprove.isEmpty {
logger.debug("apiReceiveFile ignoring fileCancelled error") let srvs = srvsToApprove
case .fileAlreadyReceiving: .map { s in
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
case let .fileNotApproved(fileId, unknownServers):
logger.debug("apiReceiveFile fileNotApproved error")
if !auto {
let srvs = unknownServers.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty { if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0] srv.hostnames[0]
} else { } else {
serverHost(s) serverHost(s)
} }
} }
am.showAlert(Alert( .sorted()
title: Text("Unknown servers!"), .joined(separator: ", ")
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."), let fIds = fileIdsToApprove
primaryButton: .default( await MainActor.run {
Text("Download"), showAlert(
action: { title: NSLocalizedString("Unknown servers!", comment: "alert title"),
Task { message: (
logger.debug("apiReceiveFile fileNotApproved alert - in Task") NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs).", comment: "alert message") +
if let user = ChatModel.shared.currentUser { (otherErrsStr != "" ? "\n\n" + NSLocalizedString("Other file errors:\n\(otherErrsStr)", comment: "alert message") : "")
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true) ),
} buttonTitle: NSLocalizedString("Download", comment: "alert button"),
buttonAction: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true)
} }
} }
), },
secondaryButton: .cancel() cancelButton: true
)) )
} }
default: } else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
logger.error("apiReceiveFile error: \(String(describing: r))") let errorResponse = otherFileErrs.first!
if !auto { switch errorResponse {
am.showAlertMsg( case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
title: "Error receiving file", logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
message: "Error: \(responseError(r))" await MainActor.run {
showAlert(
NSLocalizedString("Cannot receive file", comment: "alert title"),
message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
)
}
default:
if let chatError = chatError(errorResponse) {
switch chatError {
case .fileCancelled, .fileAlreadyReceiving:
logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
default:
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: responseError(errorResponse)
)
}
}
}
}
} else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: NSLocalizedString("File errors:\n\(otherErrsStr)", comment: "alert message")
) )
} }
} }
} }
return nil
} }
func cancelFile(user: User, fileId: Int64) async { func cancelFile(user: User, fileId: Int64) async {
@@ -14,7 +14,7 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
var ci: ChatItem var chatItems: [ChatItem]
var fromChatInfo: ChatInfo var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@@ -73,11 +73,14 @@ struct ChatItemForwardingView: View {
} }
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref( let prohibited = chatItems.map { ci in
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), chat.prohibitedByPref(
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isVoice: ci.content.msgContent?.isVoice ?? false isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
) isVoice: ci.content.msgContent?.isVoice ?? false
)
}.contains(true)
Button { Button {
if prohibited { if prohibited {
alert = SomeAlert( alert = SomeAlert(
@@ -93,10 +96,10 @@ struct ChatItemForwardingView: View {
composeState = ComposeState( composeState = ComposeState(
message: composeState.message, message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo) contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
) )
} else { } else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo) composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id) ItemsModel.shared.loadOpenChat(chat.id)
} }
} }
@@ -123,7 +126,7 @@ struct ChatItemForwardingView: View {
#Preview { #Preview {
ChatItemForwardingView( ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"), chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData), fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello")) composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme()) ).environmentObject(CurrentColors.toAppTheme())
+133 -12
View File
@@ -42,6 +42,7 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false @State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String? @State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set<Int64>? = nil @State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false @State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false @State private var allowToDeleteSelectedMessagesForAll: Bool = false
@@ -98,7 +99,8 @@ struct ChatView: View {
if case let .group(groupInfo) = chat.chatInfo { if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo) showModerateSelectedMessagesAlert(groupInfo)
} }
} },
forwardItems: forwardSelectedMessages
) )
} }
} }
@@ -135,6 +137,22 @@ struct ChatView: View {
} }
} }
} }
.sheet(isPresented: Binding(
get: { !forwardedChatItems.isEmpty },
set: { isPresented in
if !isPresented {
forwardedChatItems = []
selectedChatItems = nil
}
}
)) {
if #available(iOS 16.0, *) {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
.presentationDetents([.fraction(0.8)])
} else {
ChatItemForwardingView(chatItems: forwardedChatItems, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
.onAppear { .onAppear {
selectedChatItems = nil selectedChatItems = nil
initChatView() initChatView()
@@ -411,7 +429,8 @@ struct ChatView: View {
composeState: $composeState, composeState: $composeState,
selectedMember: $selectedMember, selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem, revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems
) )
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: { } loadPage: {
@@ -701,6 +720,116 @@ struct ChatView: View {
} }
} }
private func forwardSelectedMessages() {
Task {
do {
if let selectedChatItems {
let (validItems, confirmation) = try await apiPlanForwardChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemIds: Array(selectedChatItems)
)
if let confirmation {
if validItems.count > 0 {
showAlert(
String.localizedStringWithFormat(
NSLocalizedString("Forward %d message(s)?", comment: "alert title"),
validItems.count
),
message: forwardConfirmationText(confirmation) + "\n" +
NSLocalizedString("Forward messages without files?", comment: "alert message")
) {
switch confirmation {
case let .filesNotAccepted(fileIds):
[forwardAction(validItems), downloadAction(fileIds), cancelAlertAction]
default:
[forwardAction(validItems), cancelAlertAction]
}
}
} else {
showAlert(
NSLocalizedString("Nothing to forward!", comment: "alert title"),
message: forwardConfirmationText(confirmation)
) {
switch confirmation {
case let .filesNotAccepted(fileIds):
[downloadAction(fileIds), cancelAlertAction]
default:
[okAlertAction]
}
}
}
} else {
await openForwardingSheet(validItems)
}
}
} catch {
logger.error("Plan forward chat items failed: \(error.localizedDescription)")
}
}
func forwardConfirmationText(_ fc: ForwardConfirmation) -> String {
switch fc {
case let .filesNotAccepted(fileIds):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) were not downloaded.", comment: "forward confirmation reason"),
fileIds.count
)
case let .filesInProgress(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) are still being downloaded.", comment: "forward confirmation reason"),
filesCount
)
case let .filesMissing(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) were deleted.", comment: "forward confirmation reason"),
filesCount
)
case let .filesFailed(filesCount):
String.localizedStringWithFormat(
NSLocalizedString("%d file(s) failed to download.", comment: "forward confirmation reason"),
filesCount
)
}
}
func forwardAction(_ items: [Int64]) -> UIAlertAction {
UIAlertAction(
title: NSLocalizedString("Forward messages", comment: "alert action"),
style: .default,
handler: { _ in Task { await openForwardingSheet(items) } }
)
}
func downloadAction(_ fileIds: [Int64]) -> UIAlertAction {
UIAlertAction(
title: NSLocalizedString("Download files", comment: "alert action"),
style: .default,
handler: { _ in
Task {
if let user = ChatModel.shared.currentUser {
await receiveFiles(user: user, fileIds: fileIds)
}
}
}
)
}
func openForwardingSheet(_ items: [Int64]) async {
let im = ItemsModel.shared
var items = Set(items)
var fci = [ChatItem]()
for reversedChatItem in im.reversedChatItems {
if items.contains(reversedChatItem.id) {
items.remove(reversedChatItem.id)
fci.insert(reversedChatItem, at: 0)
}
if items.isEmpty { break }
}
await MainActor.run { forwardedChatItems = fci }
}
}
private func loadChatItems(_ cInfo: ChatInfo) { private func loadChatItems(_ cInfo: ChatInfo) {
Task { Task {
if loadingItems || firstPage { return } if loadingItems || firstPage { return }
@@ -762,10 +891,10 @@ struct ChatView: View {
@State private var showDeleteMessages = false @State private var showDeleteMessages = false
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false
@State private var msgWidth: CGFloat = 0 @State private var msgWidth: CGFloat = 0
@Binding var selectedChatItems: Set<Int64>? @Binding var selectedChatItems: Set<Int64>?
@Binding var forwardedChatItems: [ChatItem]
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false @State private var markedRead = false
@@ -1079,14 +1208,6 @@ struct ChatView: View {
}) { }) {
ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo)
} }
.sheet(isPresented: $showForwardingSheet) {
if #available(iOS 16.0, *) {
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
.presentationDetents([.fraction(0.8)])
} else {
ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState)
}
}
} }
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
@@ -1227,7 +1348,7 @@ struct ChatView: View {
var forwardButton: Button<some View> { var forwardButton: Button<some View> {
Button { Button {
showForwardingSheet = true forwardedChatItems = [chatItem]
} label: { } label: {
Label( Label(
NSLocalizedString("Forward", comment: "chat item action"), NSLocalizedString("Forward", comment: "chat item action"),
@@ -23,7 +23,7 @@ enum ComposeContextItem {
case noContextItem case noContextItem
case quotedItem(chatItem: ChatItem) case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem)
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo) case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
} }
enum VoiceMessageRecordingState { enum VoiceMessageRecordingState {
@@ -73,10 +73,10 @@ struct ComposeState {
} }
} }
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) { init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = "" self.message = ""
self.preview = .noPreview self.preview = .noPreview
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo) self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording self.voiceMessageRecordingState = .noRecording
} }
@@ -112,7 +112,7 @@ struct ComposeState {
var forwarding: Bool { var forwarding: Bool {
switch contextItem { switch contextItem {
case .forwardingItem: return true case .forwardingItems: return true
default: return false default: return false
} }
} }
@@ -167,6 +167,13 @@ struct ComposeState {
} }
} }
var manyMediaPreviews: Bool {
switch preview {
case let .mediaPreviews(mediaPreviews): return mediaPreviews.count > 1
default: return false
}
}
var attachmentDisabled: Bool { var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress { return true } if editing || forwarding || liveMessage != nil || inProgress { return true }
switch preview { switch preview {
@@ -687,7 +694,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItem: quotedItem, contextItems: [quotedItem],
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
@@ -695,18 +702,17 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItem: editingItem, contextItems: [editingItem],
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
) )
Divider() Divider()
case let .forwardingItem(chatItem: forwardedItem, _): case let .forwardingItems(chatItems, _):
ContextItemView( ContextItemView(
chat: chat, chat: chat,
contextItem: forwardedItem, contextItems: chatItems,
contextIcon: "arrowshape.turn.up.forward", contextIcon: "arrowshape.turn.up.forward",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
showSender: false
) )
Divider() Divider()
} }
@@ -730,10 +736,11 @@ struct ComposeView: View {
} }
if chat.chatInfo.contact?.nextSendGrpInv ?? false { if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation() await sendMemberContactInvitation()
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem { } else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
sent = await forwardItem(ci, fromChatInfo, ttl) // Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty { if !composeState.message.isEmpty {
sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) _ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl)
} }
} else if case let .editingItem(ci) = composeState.contextItem { } else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live) sent = await updateMessage(ci, live: live)
@@ -750,27 +757,28 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview: case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(media):
// TODO batch send: batch media previews
let last = media.count - 1 let last = media.count - 1
var msgs: [ComposedMessage] = []
if last >= 0 { if last >= 0 {
for i in 0..<last { for i in 0..<last {
if case (_, .video(_, _, _)) = media[i] { if i > 0 {
sent = await sendVideo(media[i], ttl: ttl) // Sleep to allow `progressByTimeout` update be rendered
} else { try? await Task.sleep(nanoseconds: 100_000000)
sent = await sendImage(media[i], ttl: ttl) }
if let (fileSource, msgContent) = mediaContent(media[i], text: "") {
msgs.append(ComposedMessage(fileSource: fileSource, msgContent: msgContent))
} }
_ = try? await Task.sleep(nanoseconds: 100_000000)
} }
if case (_, .video(_, _, _)) = media[last] { if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl) msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} }
} }
if sent == nil { if msgs.isEmpty {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
} }
sent = await send(msgs, live: live, ttl: ttl).last
case let .voicePreview(recordingFileName, duration): case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle() stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName) let file = voiceCryptoFile(recordingFileName)
@@ -792,6 +800,20 @@ struct ComposeView: View {
} }
return sent return sent
func mediaContent(_ media: (String, UploadContent?), text: String) -> (CryptoFile?, MsgContent)? {
let (previewImage, uploadContent) = media
return switch uploadContent {
case let .simpleImage(image):
(saveImage(image), .image(text: text, image: previewImage))
case let .animatedImage(image):
(saveAnimImage(image), .image(text: text, image: previewImage))
case let .video(_, url, duration):
(moveTempFileFromURL(url), .video(text: text, image: previewImage, duration: duration))
case .none:
nil
}
}
func sending() async { func sending() async {
await MainActor.run { composeState.inProgress = true } await MainActor.run { composeState.inProgress = true }
} }
@@ -855,23 +877,6 @@ struct ComposeView: View {
} }
} }
func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if let data = data, let savedFile = saveAnyImage(data) {
return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
let (image, data) = imageData
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
ChatModel.shared.filesToDelete.remove(url)
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
}
return nil
}
func voiceCryptoFile(_ fileName: String) -> CryptoFile? { func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
if !privacyEncryptLocalFilesGroupDefault.get() { if !privacyEncryptLocalFilesGroupDefault.get() {
return CryptoFile.plain(fileName) return CryptoFile.plain(fileName)
@@ -888,17 +893,22 @@ struct ComposeView: View {
} }
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
live: live,
ttl: ttl
).first
}
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
if let chatItems = chat.chatInfo.chatType == .local if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItems( ? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
noteFolderId: chat.chatInfo.apiId,
composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)]
)
: await apiSendMessages( : await apiSendMessages(
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
live: live, live: live,
ttl: ttl, ttl: ttl,
composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)] composedMessages: msgs
) { ) {
await MainActor.run { await MainActor.run {
chatModel.removeLiveDummy(animated: false) chatModel.removeLiveDummy(animated: false)
@@ -906,33 +916,43 @@ struct ComposeView: View {
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
} }
} }
// UI only supports sending one item at a time return chatItems
return chatItems.first
} }
if let file = file { for msg in msgs {
removeFile(file.filePath) if let file = msg.fileSource {
removeFile(file.filePath)
}
} }
return nil return []
} }
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? { func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
if let chatItems = await apiForwardChatItems( if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType, toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId, toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType, fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId, fromChatId: fromChatInfo.apiId,
itemIds: [forwardedItem.id], itemIds: forwardedItems.map { $0.id },
ttl: ttl ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
for chatItem in chatItems { for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem) chatModel.addChatItem(chat.chatInfo, chatItem)
} }
if forwardedItems.count != chatItems.count {
showAlert(
String.localizedStringWithFormat(
NSLocalizedString("%d messages not forwarded", comment: "alert title"),
forwardedItems.count - chatItems.count
),
message: NSLocalizedString("Messages were deleted after you selected them.", comment: "alert message")
)
}
} }
// TODO batch send: forward multiple messages return chatItems
return chatItems.first } else {
return []
} }
return nil
} }
func checkLinkPreview() -> MsgContent { func checkLinkPreview() -> MsgContent {
@@ -949,14 +969,6 @@ struct ComposeView: View {
return .text(msgText) return .text(msgText)
} }
} }
func saveAnyImage(_ img: UploadContent) -> CryptoFile? {
switch img {
case let .simpleImage(image): return saveImage(image)
case let .animatedImage(image): return saveAnimImage(image)
default: return nil
}
}
} }
private func startVoiceMessageRecording() async { private func startVoiceMessageRecording() async {
@@ -12,7 +12,7 @@ import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
let contextItem: ChatItem let contextItems: [ChatItem]
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
var showSender: Bool = true var showSender: Bool = true
@@ -24,13 +24,22 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
if showSender, let sender = contextItem.memberDisplayName { if let singleItem = contextItems.first, contextItems.count == 1 {
VStack(alignment: .leading, spacing: 4) { if showSender, let sender = singleItem.memberDisplayName {
Text(sender).font(.caption).foregroundColor(theme.colors.secondary) VStack(alignment: .leading, spacing: 4) {
msgContentView(lines: 2) Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
} msgContentView(lines: 2, contextItem: singleItem)
}
} else {
msgContentView(lines: 3, contextItem: singleItem)
}
} else { } else {
msgContentView(lines: 3) Text(
chat.chatInfo.chatType == .local
? "Saving \(contextItems.count) messages"
: "Forwarding \(contextItems.count) messages"
)
.italic()
} }
Spacer() Spacer()
Button { Button {
@@ -45,23 +54,32 @@ struct ContextItemView: View {
.padding(12) .padding(12)
.frame(minHeight: 54) .frame(minHeight: 54)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme)) .background(background)
} }
private func msgContentView(lines: Int) -> some View { private var background: Color {
contextMsgPreview() contextItems.first
.map { chatItemFrameColor($0, theme) }
?? Color(uiColor: .tertiarySystemBackground)
}
private func msgContentView(lines: Int, contextItem: ChatItem) -> some View {
contextMsgPreview(contextItem)
.multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading)
.lineLimit(lines) .lineLimit(lines)
} }
private func contextMsgPreview() -> Text { private func contextMsgPreview(_ contextItem: ChatItem) -> Text {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
func attachment() -> Text { func attachment() -> Text {
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {
FileManager.default.fileExists(atPath: getAppFilePath(fileSource.filePath).path)
} else { false }
switch contextItem.content.msgContent { switch contextItem.content.msgContent {
case .file: return image("doc.fill") case .file: return isFileLoaded ? image("doc.fill") : Text("")
case .image: return image("photo") case .image: return image("photo")
case .voice: return image("play.fill") case .voice: return isFileLoaded ? image("play.fill") : Text("")
default: return Text("") default: return Text("")
} }
} }
@@ -75,6 +93,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider { struct ContextItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
} }
} }
@@ -227,6 +227,7 @@ struct SendMessageView: View {
!composeState.editing { !composeState.editing {
if case .noContextItem = composeState.contextItem, if case .noContextItem = composeState.contextItem,
!composeState.voicePreview, !composeState.voicePreview,
!composeState.manyMediaPreviews,
let send = sendLiveMessage, let send = sendLiveMessage,
let update = updateLiveMessage { let update = updateLiveMessage {
Button { Button {
@@ -32,12 +32,15 @@ struct SelectedItemsBottomToolbar: View {
var deleteItems: (Bool) -> Void var deleteItems: (Bool) -> Void
var moderateItems: () -> Void var moderateItems: () -> Void
//var shareItems: () -> Void //var shareItems: () -> Void
var forwardItems: () -> Void
@State var deleteEnabled: Bool = false @State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false @State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false @State var canModerate: Bool = false
@State var moderateEnabled: Bool = false @State var moderateEnabled: Bool = false
@State var forwardEnabled: Bool = false
@State var allButtonsDisabled = false @State var allButtonsDisabled = false
var body: some View { var body: some View {
@@ -50,6 +53,7 @@ struct SelectedItemsBottomToolbar: View {
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red) .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
} }
@@ -61,24 +65,24 @@ struct SelectedItemsBottomToolbar: View {
} label: { } label: {
Image(systemName: "flag") Image(systemName: "flag")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red) .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
} }
.disabled(!moderateEnabled || allButtonsDisabled) .disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0) .opacity(canModerate ? 1 : 0)
Spacer() Spacer()
Button { Button {
//shareItems() forwardItems()
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "arrowshape.turn.up.forward")
.resizable() .resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center) .frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary) .foregroundColor(!forwardEnabled || allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
} }
.disabled(allButtonsDisabled) .disabled(!forwardEnabled || allButtonsDisabled)
.opacity(0)
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12) .padding([.leading, .trailing], 12)
@@ -106,15 +110,16 @@ struct SelectedItemsBottomToolbar: View {
if let selected = selectedItems { if let selected = selectedItems {
let me: Bool let me: Bool
let onlyOwnGroupItems: Bool let onlyOwnGroupItems: Bool
(deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in (deleteEnabled, deleteForEveryoneEnabled, me, onlyOwnGroupItems, forwardEnabled, selectedChatItems) = chatItems.reduce((true, true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) { if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list 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) return (de, dee, me, onlyOwnGroupItems, fe, sel)
} else { } else {
return r return r
} }
+17 -1
View File
@@ -47,8 +47,24 @@ func showAlert(
buttonAction() buttonAction()
}) })
if cancelButton { if cancelButton {
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)) alert.addAction(cancelAlertAction)
} }
topController.present(alert, animated: true) topController.present(alert, animated: true)
} }
} }
func showAlert(
_ title: String,
message: String? = nil,
actions: () -> [UIAlertAction] = { [okAlertAction] }
) {
if let topController = getTopViewController() {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
for action in actions() { alert.addAction(action) }
topController.present(alert, animated: true)
}
}
let okAlertAction = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default)
let cancelAlertAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)
+40 -40
View File
@@ -222,11 +222,11 @@
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
E55128DD2C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128D82C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a */; }; E55128E72C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128E22C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a */; };
E55128DE2C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128D92C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a */; }; E55128E82C9AD063001D165C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128E32C9AD063001D165C /* libgmp.a */; };
E55128DF2C9AA96B001D165C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128DA2C9AA96B001D165C /* libffi.a */; }; E55128E92C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128E42C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a */; };
E55128E02C9AA96B001D165C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128DB2C9AA96B001D165C /* libgmpxx.a */; }; E55128EA2C9AD063001D165C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128E52C9AD063001D165C /* libgmpxx.a */; };
E55128E12C9AA96B001D165C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128DC2C9AA96B001D165C /* libgmp.a */; }; E55128EB2C9AD063001D165C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128E62C9AD063001D165C /* libffi.a */; };
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
@@ -566,11 +566,11 @@
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
E55128D82C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a"; sourceTree = "<group>"; }; E55128E22C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a"; sourceTree = "<group>"; };
E55128D92C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a"; sourceTree = "<group>"; }; E55128E32C9AD063001D165C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E55128DA2C9AA96B001D165C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; }; E55128E42C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a"; sourceTree = "<group>"; };
E55128DB2C9AA96B001D165C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; }; E55128E52C9AD063001D165C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E55128DC2C9AA96B001D165C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; }; E55128E62C9AD063001D165C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -661,14 +661,14 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E55128E72C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a in Frameworks */,
E55128E82C9AD063001D165C /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E55128DF2C9AA96B001D165C /* libffi.a in Frameworks */, E55128EB2C9AD063001D165C /* libffi.a in Frameworks */,
E55128E12C9AA96B001D165C /* libgmp.a in Frameworks */,
E55128DE2C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a in Frameworks */,
E55128DD2C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
E55128E92C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
E55128E02C9AA96B001D165C /* libgmpxx.a in Frameworks */, E55128EA2C9AD063001D165C /* libgmpxx.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -745,11 +745,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E55128DA2C9AA96B001D165C /* libffi.a */, E55128E62C9AD063001D165C /* libffi.a */,
E55128DC2C9AA96B001D165C /* libgmp.a */, E55128E32C9AD063001D165C /* libgmp.a */,
E55128DB2C9AA96B001D165C /* libgmpxx.a */, E55128E52C9AD063001D165C /* libgmpxx.a */,
E55128D82C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63-ghc9.6.3.a */, E55128E42C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt-ghc9.6.3.a */,
E55128D92C9AA96B001D165C /* libHSsimplex-chat-6.1.0.1-2faBJOPOSgP5OStZMK9U63.a */, E55128E22C9AD063001D165C /* libHSsimplex-chat-6.1.0.2-ItgztLmvKyzFsOmChHMkFt.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1901,7 +1901,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -1926,7 +1926,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LLVM_LTO = YES_THIN; LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1950,7 +1950,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -1975,7 +1975,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1991,11 +1991,11 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2011,11 +2011,11 @@
buildSettings = { buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2036,7 +2036,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s; GCC_OPTIMIZATION_LEVEL = s;
@@ -2051,7 +2051,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2073,7 +2073,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO; ENABLE_CODE_COVERAGE = NO;
@@ -2088,7 +2088,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2110,7 +2110,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2136,7 +2136,7 @@
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2161,7 +2161,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2187,7 +2187,7 @@
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
LLVM_LTO = YES; LLVM_LTO = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2212,7 +2212,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2227,7 +2227,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -2246,7 +2246,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 236; CURRENT_PROJECT_VERSION = 237;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2261,7 +2261,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.0.4; MARKETING_VERSION = 6.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
+14 -1
View File
@@ -49,6 +49,7 @@ public enum ChatCommand {
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
@@ -204,6 +205,7 @@ public enum ChatCommand {
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl): case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default" let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
@@ -359,6 +361,7 @@ public enum ChatCommand {
case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
case .apiChatItemReaction: return "apiChatItemReaction" case .apiChatItemReaction: return "apiChatItemReaction"
case .apiPlanForwardChatItems: return "apiPlanForwardChatItems"
case .apiForwardChatItems: return "apiForwardChatItems" case .apiForwardChatItems: return "apiForwardChatItems"
case .apiGetNtfToken: return "apiGetNtfToken" case .apiGetNtfToken: return "apiGetNtfToken"
case .apiRegisterToken: return "apiRegisterToken" case .apiRegisterToken: return "apiRegisterToken"
@@ -601,6 +604,7 @@ public enum ChatResponse: Decodable, Error {
case groupEmpty(user: UserRef, groupInfo: GroupInfo) case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed case userContactLinkSubscribed
case newChatItems(user: UserRef, chatItems: [AChatItem]) case newChatItems(user: UserRef, chatItems: [AChatItem])
case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemUpdated(user: UserRef, chatItem: AChatItem)
case chatItemNotChanged(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem)
@@ -772,6 +776,7 @@ public enum ChatResponse: Decodable, Error {
case .groupEmpty: return "groupEmpty" case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItems: return "newChatItems" case .newChatItems: return "newChatItems"
case .forwardPlan: return "forwardPlan"
case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated"
case .chatItemUpdated: return "chatItemUpdated" case .chatItemUpdated: return "chatItemUpdated"
case .chatItemNotChanged: return "chatItemNotChanged" case .chatItemNotChanged: return "chatItemNotChanged"
@@ -943,6 +948,7 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItems(u, chatItems): case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString) return withUser(u, itemsString)
case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
case let .chatItemsStatusesUpdated(u, chatItems): case let .chatItemsStatusesUpdated(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString) return withUser(u, itemsString)
@@ -1134,7 +1140,7 @@ public enum ChatPagination {
public struct ComposedMessage: Encodable { public struct ComposedMessage: Encodable {
public var fileSource: CryptoFile? public var fileSource: CryptoFile?
var quotedItemId: Int64? var quotedItemId: Int64?
var msgContent: MsgContent public var msgContent: MsgContent
public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) { public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) {
self.fileSource = fileSource self.fileSource = fileSource
@@ -1595,6 +1601,13 @@ public enum NetworkStatus: Decodable, Equatable {
} }
} }
public enum ForwardConfirmation: Decodable, Hashable {
case filesNotAccepted(fileIds: [Int64])
case filesInProgress(filesCount: Int)
case filesMissing(filesCount: Int)
case filesFailed(filesCount: Int)
}
public struct ConnNetworkStatus: Decodable { public struct ConnNetworkStatus: Decodable {
public var agentConnId: String public var agentConnId: String
public var networkStatus: NetworkStatus public var networkStatus: NetworkStatus
@@ -150,12 +150,9 @@ fun processIntent(intent: Intent?) {
"android.intent.action.VIEW" -> { "android.intent.action.VIEW" -> {
val uri = intent.data val uri = intent.data
if (uri != null) { if (uri != null) {
val transformedUri = uri.toURIOrNull() chatModel.appOpenUrl.value = null to uri.toString()
if (transformedUri != null) { } else {
chatModel.appOpenUrl.value = null to transformedUri AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), generalGetString(MR.strings.error_parsing_uri_desc))
} else {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), generalGetString(MR.strings.error_parsing_uri_desc))
}
} }
} }
} }
@@ -82,7 +82,7 @@ object ChatModel {
val desktopOnboardingRandomPassword = mutableStateOf(false) val desktopOnboardingRandomPassword = mutableStateOf(false)
// set when app is opened via contact or invitation URI (rhId, uri) // set when app is opened via contact or invitation URI (rhId, uri)
val appOpenUrl = mutableStateOf<Pair<Long?, URI>?>(null) val appOpenUrl = mutableStateOf<Pair<Long?, String>?>(null)
// Needed to check for bottom nav bar and to apply or not navigation bar color on Android // Needed to check for bottom nav bar and to apply or not navigation bar color on Android
val newChatSheetVisible = mutableStateOf(false) val newChatSheetVisible = mutableStateOf(false)
@@ -1404,6 +1404,14 @@ class Group (
var members: List<GroupMember> var members: List<GroupMember>
) )
@Serializable
sealed class ForwardConfirmation {
@Serializable @SerialName("filesNotAccepted") data class FilesNotAccepted(val fileIds: List<Long>) : ForwardConfirmation()
@Serializable @SerialName("filesInProgress") data class FilesInProgress(val filesCount: Int) : ForwardConfirmation()
@Serializable @SerialName("filesMissing") data class FilesMissing(val filesCount: Int) : ForwardConfirmation()
@Serializable @SerialName("filesFailed") data class FilesFailed(val filesCount: Int) : ForwardConfirmation()
}
@Serializable @Serializable
data class GroupInfo ( data class GroupInfo (
val groupId: Long, val groupId: Long,
@@ -1,18 +1,19 @@
package chat.simplex.common.model package chat.simplex.common.model
import SectionItemView import SectionItemView
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.changingActiveUserMutex
@@ -905,7 +906,15 @@ object ChatController {
return processSendMessageCmd(rh, cmd)?.map { it.chatItem } return processSendMessageCmd(rh, cmd)?.map { it.chatItem }
} }
suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List<Long>): CR.ForwardPlan? {
return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) {
is CR.ForwardPlan -> r
else -> {
apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r)
null
}
}
}
suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? {
val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live))
@@ -1541,50 +1550,132 @@ object ChatController {
} }
} }
suspend fun apiReceiveFile(rh: Long?, fileId: Long, userApprovedRelays: Boolean, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List<Long>, userApprovedRelays: Boolean = false, auto: Boolean = false) {
// -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected val fileIdsToApprove = mutableListOf<Long>()
val r = sendCmd(rh, CC.ReceiveFile(fileId, userApprovedRelays = userApprovedRelays, encrypt = encrypted, inline = inline)) val srvsToApprove = mutableSetOf<String>()
return when (r) { val otherFileErrs = mutableListOf<CR>()
is CR.RcvFileAccepted -> r.chatItem
is CR.RcvFileAcceptedSndCancelled -> {
Log.d(TAG, "apiReceiveFile error: sender cancelled file transfer")
if (!auto) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cannot_receive_file),
generalGetString(MR.strings.sender_cancelled_file_transfer)
)
}
null
}
else -> { for (fileId in fileIds) {
if (!(networkErrorAlert(r))) { val r = sendCmd(
val maybeChatError = chatError(r) rhId, CC.ReceiveFile(
if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { fileId,
Log.d(TAG, "apiReceiveFile ignoring FileCancelled or FileAlreadyReceiving error") userApprovedRelays = userApprovedRelays || !appPrefs.privacyAskToApproveRelays.get(),
} else if (maybeChatError is ChatErrorType.FileNotApproved) { encrypt = appPrefs.privacyEncryptLocalFiles.get(),
Log.d(TAG, "apiReceiveFile FileNotApproved error") inline = null
if (!auto) { )
val srvs = maybeChatError.unknownServers.map{ serverHostname(it) } )
AlertManager.shared.showAlertDialog( if (r is CR.RcvFileAccepted) {
title = generalGetString(MR.strings.file_not_approved_title), chatItemSimpleUpdate(rhId, user, r.chatItem)
text = generalGetString(MR.strings.file_not_approved_descr).format(srvs.sorted().joinToString(separator = ", ")), } else {
confirmText = generalGetString(MR.strings.download_file), val maybeChatError = chatError(r)
onConfirm = { if (maybeChatError is ChatErrorType.FileNotApproved) {
val user = chatModel.currentUser.value fileIdsToApprove.add(maybeChatError.fileId)
if (user != null) { srvsToApprove.addAll(maybeChatError.unknownServers.map { serverHostname(it) })
withBGApi { chatModel.controller.receiveFile(rh, user, fileId, userApprovedRelays = true) } } else {
} otherFileErrs.add(r)
},
)
}
} else if (!auto) {
apiErrorAlert("apiReceiveFile", generalGetString(MR.strings.error_receiving_file), r)
}
} }
null
} }
} }
if (!auto) {
// If there are not approved files, alert is shown the same way both in case of singular and plural files reception
if (fileIdsToApprove.isNotEmpty()) {
showFilesToApproveAlert(
srvsToApprove = srvsToApprove,
otherFileErrs = otherFileErrs,
approveFiles = {
withBGApi {
receiveFiles(
rhId = rhId,
user = user,
fileIds = fileIdsToApprove,
userApprovedRelays = true
)
}
}
)
} else if (otherFileErrs.size == 1) { // If there is a single other error, we differentiate on it
when (val errCR = otherFileErrs.first()) {
is CR.RcvFileAcceptedSndCancelled -> {
Log.d(TAG, "receiveFiles error: sender cancelled file transfer")
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cannot_receive_file),
generalGetString(MR.strings.sender_cancelled_file_transfer)
)
}
else -> {
val maybeChatError = chatError(errCR)
if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) {
Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
} else {
apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR)
}
}
}
} else if (otherFileErrs.size > 1) { // If there are multiple other errors, we show general alert
val errsStr = otherFileErrs.map { json.encodeToString(it) }.joinToString(separator = "\n")
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.error_receiving_file),
text = String.format(generalGetString(MR.strings.n_file_errors), otherFileErrs.size, errsStr),
shareText = true
)
}
}
}
private fun showFilesToApproveAlert(
srvsToApprove: Set<String>,
otherFileErrs: List<CR>,
approveFiles: (() -> Unit)
) {
val srvsToApproveStr = srvsToApprove.sorted().joinToString(separator = ", ")
val alertText =
generalGetString(MR.strings.file_not_approved_descr).format(srvsToApproveStr) +
(if (otherFileErrs.isNotEmpty()) "\n" + generalGetString(MR.strings.n_other_file_errors).format(otherFileErrs.size) else "")
AlertManager.shared.showAlertDialogButtonsColumn(generalGetString(MR.strings.file_not_approved_title), alertText, belowTextContent = {
if (otherFileErrs.isNotEmpty()) {
val clipboard = LocalClipboardManager.current
SimpleButtonFrame(click = {
clipboard.setText(AnnotatedString(otherFileErrs.map { json.encodeToString(it) }.joinToString(separator = "\n")))
}) {
Icon(
painterResource(MR.images.ic_content_copy),
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text(generalGetString(MR.strings.copy_error), color = MaterialTheme.colors.primary)
}
}
}) {
Row(
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard
delay(200)
focusRequester.requestFocus()
}
TextButton(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(MR.strings.cancel_verb)) }
TextButton(onClick = {
approveFiles.invoke()
AlertManager.shared.hideAlert()
}, Modifier.focusRequester(focusRequester)) { Text(generalGetString(MR.strings.download_file)) }
}
}
}
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) {
receiveFiles(
rhId = rhId,
user = user,
fileIds = listOf(fileId),
userApprovedRelays = userApprovedRelays,
auto = auto
)
} }
suspend fun cancelFile(rh: Long?, user: User, fileId: Long) { suspend fun cancelFile(rh: Long?, user: User, fileId: Long) {
@@ -2689,19 +2780,6 @@ object ChatController {
} }
} }
suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) {
val chatItem = apiReceiveFile(
rhId,
fileId,
userApprovedRelays = userApprovedRelays || !appPrefs.privacyAskToApproveRelays.get(),
encrypted = appPrefs.privacyEncryptLocalFiles.get(),
auto = auto
)
if (chatItem != null) {
chatItemSimpleUpdate(rhId, user, chatItem)
}
}
suspend fun leaveGroup(rh: Long?, groupId: Long) { suspend fun leaveGroup(rh: Long?, groupId: Long) {
val groupInfo = apiLeaveGroup(rh, groupId) val groupInfo = apiLeaveGroup(rh, groupId)
if (groupInfo != null) { if (groupInfo != null) {
@@ -2914,6 +2992,7 @@ sealed class CC {
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List<Long>): CC()
class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List<Long>, val ttl: Int?): CC() class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List<Long>, val ttl: Int?): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
@@ -3072,6 +3151,9 @@ sealed class CC {
val ttlStr = if (ttl != null) "$ttl" else "default" val ttlStr = if (ttl != null) "$ttl" else "default"
"/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}" "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}"
} }
is ApiPlanForwardChatItems -> {
"/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}"
}
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId" is ApiJoinGroup -> "/_join #$groupId"
@@ -3216,6 +3298,7 @@ sealed class CC {
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction" is ApiChatItemReaction -> "apiChatItemReaction"
is ApiForwardChatItems -> "apiForwardChatItems" is ApiForwardChatItems -> "apiForwardChatItems"
is ApiPlanForwardChatItems -> "apiPlanForwardChatItems"
is ApiNewGroup -> "apiNewGroup" is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember" is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup" is ApiJoinGroup -> "apiJoinGroup"
@@ -4878,6 +4961,7 @@ sealed class CR {
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List<ChatItemDeletion>, val byUser: Boolean): CR() @Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List<ChatItemDeletion>, val byUser: Boolean): CR()
@Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List<Long>, val forwardConfirmation: ForwardConfirmation? = null): CR()
// group events // group events
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@@ -5055,6 +5139,7 @@ sealed class CR {
is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemNotChanged -> "chatItemNotChanged"
is ChatItemReaction -> "chatItemReaction" is ChatItemReaction -> "chatItemReaction"
is ChatItemsDeleted -> "chatItemsDeleted" is ChatItemsDeleted -> "chatItemsDeleted"
is ForwardPlan -> "forwardPlan"
is GroupCreated -> "groupCreated" is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation" is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent" is UserAcceptedGroupSent -> "userAcceptedGroupSent"
@@ -5224,6 +5309,7 @@ sealed class CR {
is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem))
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser") is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser")
is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}")
is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
@@ -172,7 +173,30 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}) })
} }
} }
} },
forwardItems = {
val itemIds = selectedChatItems.value
if (itemIds != null) {
withBGApi {
val chatItemIds = itemIds.toList()
val forwardPlan = controller.apiPlanForwardChatItems(
rh = chatRh,
fromChatType = chatInfo.chatType,
fromChatId = chatInfo.apiId,
chatItemIds = chatItemIds
)
if (forwardPlan != null) {
if (forwardPlan.chatItemIds.count() < chatItemIds.count() || forwardPlan.forwardConfirmation != null) {
handleForwardConfirmation(chatRh, forwardPlan, chatInfo)
} else {
forwardContent(forwardPlan.chatItemIds, chatInfo)
}
}
}
}
},
) )
} }
}, },
@@ -347,9 +371,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
openDirectChat(chatRh, contactId, chatModel) openDirectChat(chatRh, contactId, chatModel)
} }
}, },
forwardItem = { cItem, cInfo -> forwardItem = { cInfo, cItem ->
chatModel.chatId.value = null chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(cInfo, cItem) chatModel.sharedContent.value = SharedContent.Forward(listOf(cItem), cInfo)
}, },
updateContactStats = { contact -> updateContactStats = { contact ->
withBGApi { withBGApi {
@@ -1416,6 +1440,65 @@ private fun TopEndFloatingButton(
} }
} }
@Composable
private fun DownloadFilesButton(
forwardConfirmation: ForwardConfirmation.FilesNotAccepted,
rhId: Long?,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding
) {
val user = chatModel.currentUser.value
if (user != null) {
TextButton(
contentPadding = contentPadding,
modifier = modifier,
onClick = {
AlertManager.shared.hideAlert()
withBGApi {
controller.receiveFiles(
rhId = rhId,
fileIds = forwardConfirmation.fileIds,
user = user
)
}
}
) {
Text(stringResource(MR.strings.forward_files_not_accepted_receive_files), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
@Composable
private fun ForwardButton(
forwardPlan: CR.ForwardPlan,
chatInfo: ChatInfo,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding
) {
TextButton(
onClick = {
forwardContent(forwardPlan.chatItemIds, chatInfo)
AlertManager.shared.hideAlert()
},
modifier = modifier,
contentPadding = contentPadding
) {
Text(stringResource(MR.strings.forward_chat_item), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
@Composable
private fun ButtonRow(horizontalArrangement: Arrangement.Horizontal, content: @Composable() (RowScope.() -> Unit)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = horizontalArrangement
) {
content()
}
}
val chatViewScrollState = MutableStateFlow(false) val chatViewScrollState = MutableStateFlow(false)
fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) { fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
@@ -1712,6 +1795,83 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf
override val touchSlop: Float get() = slop override val touchSlop: Float get() = slop
} }
private fun forwardContent(chatItemsIds: List<Long>, chatInfo: ChatInfo) {
chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(
chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) },
chatInfo
)
}
private fun forwardConfirmationAlertDescription(forwardConfirmation: ForwardConfirmation): String {
return when (forwardConfirmation) {
is ForwardConfirmation.FilesNotAccepted -> String.format(generalGetString(MR.strings.forward_files_not_accepted_desc), forwardConfirmation.fileIds.count())
is ForwardConfirmation.FilesInProgress -> String.format(generalGetString(MR.strings.forward_files_in_progress_desc), forwardConfirmation.filesCount)
is ForwardConfirmation.FilesFailed -> String.format(generalGetString(MR.strings.forward_files_failed_to_receive_desc), forwardConfirmation.filesCount)
is ForwardConfirmation.FilesMissing -> String.format(generalGetString(MR.strings.forward_files_missing_desc), forwardConfirmation.filesCount)
}
}
private fun handleForwardConfirmation(
rhId: Long?,
forwardPlan: CR.ForwardPlan,
chatInfo: ChatInfo
) {
var alertDescription = if (forwardPlan.forwardConfirmation != null) forwardConfirmationAlertDescription(forwardPlan.forwardConfirmation) else ""
if (forwardPlan.chatItemIds.isNotEmpty()) {
alertDescription += "\n${generalGetString(MR.strings.forward_alert_forward_messages_without_files)}"
}
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (forwardPlan.chatItemIds.isNotEmpty())
String.format(generalGetString(MR.strings.forward_alert_title_messages_to_forward), forwardPlan.chatItemIds.count()) else
generalGetString(MR.strings.forward_alert_title_nothing_to_forward),
text = alertDescription,
buttons = {
if (forwardPlan.chatItemIds.isNotEmpty()) {
when (val confirmation = forwardPlan.forwardConfirmation) {
is ForwardConfirmation.FilesNotAccepted -> {
val fillMaxWidthModifier = Modifier.fillMaxWidth()
val contentPadding = PaddingValues(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)
Column {
ForwardButton(forwardPlan, chatInfo, fillMaxWidthModifier, contentPadding)
DownloadFilesButton(confirmation, rhId, fillMaxWidthModifier, contentPadding)
TextButton(onClick = { AlertManager.shared.hideAlert() }, modifier = fillMaxWidthModifier, contentPadding = contentPadding) {
Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
else -> {
ButtonRow(Arrangement.SpaceBetween) {
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
ForwardButton(forwardPlan, chatInfo)
}
}
}
} else {
when (val confirmation = forwardPlan.forwardConfirmation) {
is ForwardConfirmation.FilesNotAccepted -> {
ButtonRow(Arrangement.SpaceBetween) {
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
DownloadFilesButton(confirmation, rhId)
}
}
else -> ButtonRow(Arrangement.Center) {
TextButton(onClick = { AlertManager.shared.hideAlert() }) {
Text(stringResource(MR.strings.ok), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
}
}
)
}
@Preview/*( @Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES, uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true, showBackground = true,
@@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
@@ -49,7 +48,7 @@ sealed class ComposeContextItem {
@Serializable object NoContextItem: ComposeContextItem() @Serializable object NoContextItem: ComposeContextItem()
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class ForwardingItem(val chatItem: ChatItem, val fromChatInfo: ChatInfo): ComposeContextItem() @Serializable class ForwardingItems(val chatItems: List<ChatItem>, val fromChatInfo: ChatInfo): ComposeContextItem()
} }
@Serializable @Serializable
@@ -85,7 +84,7 @@ data class ComposeState(
} }
val forwarding: Boolean val forwarding: Boolean
get() = when (contextItem) { get() = when (contextItem) {
is ComposeContextItem.ForwardingItem -> true is ComposeContextItem.ForwardingItems -> true
else -> false else -> false
} }
val sendEnabled: () -> Boolean val sendEnabled: () -> Boolean
@@ -407,33 +406,41 @@ fun ComposeView(
return null return null
} }
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List<ChatItem>? {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
val cs = composeState.value val cs = composeState.value
var sent: ChatItem? var sent: List<ChatItem>?
val msgText = text ?: cs.message val msgText = text ?: cs.message
fun sending() { fun sending() {
composeState.value = composeState.value.copy(inProgress = true) composeState.value = composeState.value.copy(inProgress = true)
} }
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? { suspend fun forwardItem(rhId: Long?, forwardedItem: List<ChatItem>, fromChatInfo: ChatInfo, ttl: Int?): List<ChatItem>? {
val chatItems = controller.apiForwardChatItems( val chatItems = controller.apiForwardChatItems(
rh = rhId, rh = rhId,
toChatType = chat.chatInfo.chatType, toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId, toChatId = chat.chatInfo.apiId,
fromChatType = fromChatInfo.chatType, fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId, fromChatId = fromChatInfo.apiId,
itemIds = listOf(forwardedItem.id), itemIds = forwardedItem.map { it.id },
ttl = ttl ttl = ttl
) )
chatItems?.forEach { chatItem -> chatItems?.forEach { chatItem ->
withChats { withChats {
addChatItem(rhId, chat.chatInfo, chatItem) addChatItem(rhId, chat.chatInfo, chatItem)
} }
} }
// TODO batch send: forward multiple messages
return chatItems?.firstOrNull() if (chatItems != null && chatItems.count() < forwardedItem.count()) {
AlertManager.shared.showAlertMsg(
title = String.format(generalGetString(MR.strings.forward_files_messages_deleted_after_selection_title), forwardedItem.count() - chatItems.count()),
text = generalGetString(MR.strings.forward_files_messages_deleted_after_selection_desc)
)
}
return chatItems
} }
fun checkLinkPreview(): MsgContent { fun checkLinkPreview(): MsgContent {
@@ -506,16 +513,25 @@ fun ComposeView(
if (chat.nextSendGrpInv) { if (chat.nextSendGrpInv) {
sendMemberContactInvitation() sendMemberContactInvitation()
sent = null sent = null
} else if (cs.contextItem is ComposeContextItem.ForwardingItem) { } else if (cs.contextItem is ComposeContextItem.ForwardingItems) {
sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo, ttl = ttl) sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItems, cs.contextItem.fromChatInfo, ttl = ttl)
if (cs.message.isNotEmpty()) { if (cs.message.isNotEmpty()) {
sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = ttl) sent?.mapIndexed { index, message ->
if (index == sent!!.lastIndex) {
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl)
} else {
message
}
}
} }
} else if (cs.contextItem is ComposeContextItem.EditingItem) { }
else if (cs.contextItem is ComposeContextItem.EditingItem) {
val ei = cs.contextItem.chatItem val ei = cs.contextItem.chatItem
sent = updateMessage(ei, chat, live) val updatedMessage = updateMessage(ei, chat, live)
sent = if (updatedMessage != null) listOf(updatedMessage) else null
} else if (liveMessage != null && liveMessage.sent) { } else if (liveMessage != null && liveMessage.sent) {
sent = updateMessage(liveMessage.chatItem, chat, live) val updatedMessage = updateMessage(liveMessage.chatItem, chat, live)
sent = if (updatedMessage != null) listOf(updatedMessage) else null
} else { } else {
val msgs: ArrayList<MsgContent> = ArrayList() val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList() val files: ArrayList<CryptoFile> = ArrayList()
@@ -608,21 +624,23 @@ fun ComposeView(
localPath = file.filePath localPath = file.filePath
) )
} }
sent = send(chat, content, if (index == 0) quotedItemId else null, file, val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file,
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl ttl = ttl
) )
sent = if (sendResult != null) listOf(sendResult) else null
} }
if (sent == null && if (sent == null &&
(cs.preview is ComposePreview.MediaPreview || (cs.preview is ComposePreview.MediaPreview ||
cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.FilePreview ||
cs.preview is ComposePreview.VoicePreview) cs.preview is ComposePreview.VoicePreview)
) { ) {
sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) val sendResult = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl)
sent = if (sendResult != null) listOf(sendResult) else null
} }
} }
val wasForwarding = cs.forwarding val wasForwarding = cs.forwarding
val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItem)?.fromChatInfo?.id val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItems)?.fromChatInfo?.id
clearState(live) clearState(live)
val draft = chatModel.draft.value val draft = chatModel.draft.value
if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) { if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) {
@@ -724,8 +742,8 @@ fun ComposeView(
val typedMsg = cs.message val typedMsg = cs.message
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) {
val ci = sendMessageAsync(typedMsg, live = true, ttl = null) val ci = sendMessageAsync(typedMsg, live = true, ttl = null)
if (ci != null) { if (!ci.isNullOrEmpty()) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
} }
} else if (cs.liveMessage == null) { } else if (cs.liveMessage == null) {
val cItem = chatModel.addLiveDummy(chat.chatInfo) val cItem = chatModel.addLiveDummy(chat.chatInfo)
@@ -745,8 +763,8 @@ fun ComposeView(
val sentMsg = liveMessageToSend(liveMessage, typedMsg) val sentMsg = liveMessageToSend(liveMessage, typedMsg)
if (sentMsg != null) { if (sentMsg != null) {
val ci = sendMessageAsync(sentMsg, live = true, ttl = null) val ci = sendMessageAsync(sentMsg, live = true, ttl = null)
if (ci != null) { if (!ci.isNullOrEmpty()) {
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
} }
} else if (liveMessage.typedMsg != typedMsg) { } else if (liveMessage.typedMsg != typedMsg) {
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
@@ -805,13 +823,13 @@ fun ComposeView(
fun contextItemView() { fun contextItemView() {
when (val contextItem = composeState.value.contextItem) { when (val contextItem = composeState.value.contextItem) {
ComposeContextItem.NoContextItem -> {} ComposeContextItem.NoContextItem -> {}
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) { is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
} }
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) {
clearState() clearState()
} }
is ComposeContextItem.ForwardingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_forward), showSender = false) { is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
} }
} }
@@ -834,7 +852,7 @@ fun ComposeView(
is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text)
is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text)
is SharedContent.Forward -> composeState.value = composeState.value.copy( is SharedContent.Forward -> composeState.value = composeState.value.copy(
contextItem = ComposeContextItem.ForwardingItem(shared.chatItem, shared.fromChatInfo), contextItem = ComposeContextItem.ForwardingItems(shared.chatItems, shared.fromChatInfo),
preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview
) )
null -> {} null -> {}
@@ -13,28 +13,31 @@ import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.item.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.getLoadedFilePath
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.ImageResource
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
@Composable @Composable
fun ContextItemView( fun ContextItemView(
contextItem: ChatItem, contextItems: List<ChatItem>,
contextIcon: Painter, contextIcon: Painter,
showSender: Boolean = true, showSender: Boolean = true,
cancelContextItem: () -> Unit chatType: ChatType,
cancelContextItem: () -> Unit,
) { ) {
val sent = contextItem.chatDir.sent
val sentColor = MaterialTheme.appColors.sentMessage val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage val receivedColor = MaterialTheme.appColors.receivedMessage
@Composable @Composable
fun MessageText(attachment: ImageResource?, lines: Int) { fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int) {
val inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = if (attachment != null) { val inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = if (attachment != null) {
remember(contextItem.id) { remember(contextItem.id) {
val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = {
@@ -62,19 +65,24 @@ fun ContextItemView(
) )
} }
fun attachment(): ImageResource? = fun attachment(contextItem: ChatItem): ImageResource? {
when (contextItem.content.msgContent) { val fileIsLoaded = getLoadedFilePath(contextItem.file) != null
is MsgContent.MCFile -> MR.images.ic_draft_filled
return when (contextItem.content.msgContent) {
is MsgContent.MCFile -> if (fileIsLoaded) MR.images.ic_draft_filled else null
is MsgContent.MCImage -> MR.images.ic_image is MsgContent.MCImage -> MR.images.ic_image
is MsgContent.MCVoice -> MR.images.ic_play_arrow_filled is MsgContent.MCVoice -> if (fileIsLoaded) MR.images.ic_play_arrow_filled else null
else -> null else -> null
} }
}
@Composable @Composable
fun ContextMsgPreview(lines: Int) { fun ContextMsgPreview(contextItem: ChatItem, lines: Int) {
MessageText(remember(contextItem.id) { attachment() }, lines) MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines)
} }
val sent = contextItems[0].chatDir.sent
Row( Row(
Modifier Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
@@ -97,20 +105,27 @@ fun ContextItemView(
contentDescription = stringResource(MR.strings.icon_descr_context), contentDescription = stringResource(MR.strings.icon_descr_context),
tint = MaterialTheme.colors.secondary, tint = MaterialTheme.colors.secondary,
) )
val sender = contextItem.memberDisplayName
if (showSender && sender != null) { if (contextItems.count() == 1) {
Column( val contextItem = contextItems[0]
horizontalAlignment = Alignment.Start, val sender = contextItem.memberDisplayName
verticalArrangement = Arrangement.spacedBy(4.dp),
) { if (showSender && sender != null) {
Text( Column(
sender, horizontalAlignment = Alignment.Start,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) verticalArrangement = Arrangement.spacedBy(4.dp),
) ) {
ContextMsgPreview(lines = 2) Text(
sender,
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
ContextMsgPreview(contextItem, lines = 2)
}
} else {
ContextMsgPreview(contextItem, lines = 3)
} }
} else { } else if (contextItems.isNotEmpty()) {
ContextMsgPreview(lines = 3) Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic)
} }
} }
IconButton(onClick = cancelContextItem) { IconButton(onClick = cancelContextItem) {
@@ -129,8 +144,9 @@ fun ContextItemView(
fun PreviewContextItemView() { fun PreviewContextItemView() {
SimpleXTheme { SimpleXTheme {
ContextItemView( ContextItemView(
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")),
contextIcon = painterResource(MR.images.ic_edit_filled) contextIcon = painterResource(MR.images.ic_edit_filled),
chatType = ChatType.Direct
) {} ) {}
} }
} }
@@ -7,6 +7,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,17 +52,29 @@ fun SelectedItemsBottomToolbar(
selectedChatItems: MutableState<Set<Long>?>, selectedChatItems: MutableState<Set<Long>?>,
deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible
moderateItems: () -> Unit, moderateItems: () -> Unit,
// shareItems: () -> Unit, forwardItems: () -> Unit,
) { ) {
val deleteEnabled = remember { mutableStateOf(false) } val deleteEnabled = remember { mutableStateOf(false) }
val deleteForEveryoneEnabled = remember { mutableStateOf(false) } val deleteForEveryoneEnabled = remember { mutableStateOf(false) }
val canModerate = remember { mutableStateOf(false) } val canModerate = remember { mutableStateOf(false) }
val moderateEnabled = remember { mutableStateOf(false) } val moderateEnabled = remember { mutableStateOf(false) }
val forwardEnabled = remember { mutableStateOf(false) }
val allButtonsDisabled = remember { mutableStateOf(false) } val allButtonsDisabled = remember { mutableStateOf(false) }
Box { Box {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {})
Row(Modifier.matchParentSize().background(MaterialTheme.colors.background), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(
Modifier
.matchParentSize()
.background(MaterialTheme.colors.background)
.pointerInput(Unit) {
detectGesture {
true
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !allButtonsDisabled.value) { IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !allButtonsDisabled.value) {
Icon( Icon(
painterResource(MR.images.ic_delete), painterResource(MR.images.ic_delete),
@@ -80,18 +93,18 @@ fun SelectedItemsBottomToolbar(
) )
} }
IconButton({ /*shareItems()*/ }, Modifier.alpha(0f), enabled = false/*!allButtonsDisabled.value*/) { IconButton({ forwardItems() }, enabled = forwardEnabled.value && !allButtonsDisabled.value) {
Icon( Icon(
painterResource(MR.images.ic_share), painterResource(MR.images.ic_forward),
null, null,
Modifier.size(22.dp), Modifier.size(22.dp),
tint = if (allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary tint = if (!forwardEnabled.value || allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
) )
} }
} }
} }
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, allButtonsDisabled) recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, allButtonsDisabled)
} }
} }
@@ -102,6 +115,7 @@ private fun recheckItems(chatInfo: ChatInfo,
deleteForEveryoneEnabled: MutableState<Boolean>, deleteForEveryoneEnabled: MutableState<Boolean>,
canModerate: MutableState<Boolean>, canModerate: MutableState<Boolean>,
moderateEnabled: MutableState<Boolean>, moderateEnabled: MutableState<Boolean>,
forwardEnabled: MutableState<Boolean>,
allButtonsDisabled: MutableState<Boolean> allButtonsDisabled: MutableState<Boolean>
) { ) {
val count = selectedChatItems.value?.size ?: 0 val count = selectedChatItems.value?.size ?: 0
@@ -112,6 +126,7 @@ private fun recheckItems(chatInfo: ChatInfo,
var rDeleteForEveryoneEnabled = true var rDeleteForEveryoneEnabled = true
var rModerateEnabled = true var rModerateEnabled = true
var rOnlyOwnGroupItems = true var rOnlyOwnGroupItems = true
var rForwardEnabled = true
val rSelectedChatItems = mutableSetOf<Long>() val rSelectedChatItems = mutableSetOf<Long>()
for (ci in chatItems) { for (ci in chatItems) {
if (selected.contains(ci.id)) { if (selected.contains(ci.id)) {
@@ -119,6 +134,7 @@ private fun recheckItems(chatInfo: ChatInfo,
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy
rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list
} }
} }
@@ -126,6 +142,7 @@ private fun recheckItems(chatInfo: ChatInfo,
deleteEnabled.value = rDeleteEnabled deleteEnabled.value = rDeleteEnabled
deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled
moderateEnabled.value = rModerateEnabled moderateEnabled.value = rModerateEnabled
forwardEnabled.value = rForwardEnabled
selectedChatItems.value = rSelectedChatItems selectedChatItems.value = rSelectedChatItems
} }
@@ -665,9 +665,8 @@ private fun updateMemberRoleDialog(
fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) {
try { try {
val uri = URI(connReqUri)
withBGApi { withBGApi {
planAndConnect(rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) planAndConnect(rhId, connReqUri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() })
} }
} catch (e: RuntimeException) { } catch (e: RuntimeException) {
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -478,7 +478,7 @@ private fun ToggleFilterEnabledButton() {
@Composable @Composable
expect fun ActiveCallInteractiveArea(call: Call) expect fun ActiveCallInteractiveArea(call: Call)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link") Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) { if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri chatModel.appOpenUrl.value = rhId to uri
@@ -566,7 +566,7 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
withBGApi { withBGApi {
planAndConnect( planAndConnect(
chatModel.remoteHostId(), chatModel.remoteHostId(),
URI.create(link), link,
incognito = null, incognito = null,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id }, filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id }, filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id },
@@ -58,11 +58,13 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
hasSimplexLink = hasSimplexLink(sharedContent.text) hasSimplexLink = hasSimplexLink(sharedContent.text)
} }
is SharedContent.Forward -> { is SharedContent.Forward -> {
val mc = sharedContent.chatItem.content.msgContent sharedContent.chatItems.forEach { ci ->
if (mc != null) { val mc = ci.content.msgContent
isMediaOrFileAttachment = mc.isMediaOrFileAttachment if (mc != null) {
isVoice = mc.isVoice isMediaOrFileAttachment = isMediaOrFileAttachment || mc.isMediaOrFileAttachment
hasSimplexLink = hasSimplexLink(mc.text) isVoice = isVoice || mc.isVoice
hasSimplexLink = hasSimplexLink || hasSimplexLink(mc.text)
}
} }
} }
null -> {} null -> {}
@@ -175,11 +177,11 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
when (chatModel.sharedContent.value) { when (val v = chatModel.sharedContent.value) {
is SharedContent.Text -> stringResource(MR.strings.share_message) is SharedContent.Text -> stringResource(MR.strings.share_message)
is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.Media -> stringResource(MR.strings.share_image)
is SharedContent.File -> stringResource(MR.strings.share_file) is SharedContent.File -> stringResource(MR.strings.share_file)
is SharedContent.Forward -> stringResource(MR.strings.forward_message) is SharedContent.Forward -> if (v.chatItems.size > 1) stringResource(MR.strings.forward_multiple) else stringResource(MR.strings.forward_message)
null -> stringResource(MR.strings.share_message) null -> stringResource(MR.strings.share_message)
}, },
color = MaterialTheme.colors.onBackground, color = MaterialTheme.colors.onBackground,
@@ -2,8 +2,7 @@
package chat.simplex.common.views.helpers package chat.simplex.common.views.helpers
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import chat.simplex.common.model.ChatInfo import chat.simplex.common.model.*
import chat.simplex.common.model.ChatItem
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.descriptors.* import kotlinx.serialization.descriptors.*
@@ -15,7 +14,7 @@ sealed class SharedContent {
data class Text(val text: String): SharedContent() data class Text(val text: String): SharedContent()
data class Media(val text: String, val uris: List<URI>): SharedContent() data class Media(val text: String, val uris: List<URI>): SharedContent()
data class File(val text: String, val uri: URI): SharedContent() data class File(val text: String, val uri: URI): SharedContent()
data class Forward(val chatItem: ChatItem, val fromChatInfo: ChatInfo): SharedContent() data class Forward(val chatItems: List<ChatItem>, val fromChatInfo: ChatInfo): SharedContent()
} }
enum class AnimatedViewState { enum class AnimatedViewState {
@@ -480,12 +480,11 @@ inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
) )
fun UriHandler.openVerifiedSimplexUri(uri: String) { fun UriHandler.openVerifiedSimplexUri(uri: String) {
val URI = try { URI.create(uri) } catch (e: Exception) { null } connectIfOpenedViaUri(chatModel.remoteHostId(), uri, ChatModel)
if (URI != null) {
connectIfOpenedViaUri(chatModel.remoteHostId(), URI, ChatModel)
}
} }
fun uriCreateOrNull(uri: String) = try { URI.create(uri) } catch (e: Exception) { null }
fun UriHandler.openUriCatching(uri: String) { fun UriHandler.openUriCatching(uri: String) {
try { try {
openUri(uri) openUri(uri)
@@ -20,7 +20,7 @@ enum class ConnectionLinkType {
suspend fun planAndConnect( suspend fun planAndConnect(
rhId: Long?, rhId: Long?,
uri: URI, uri: String,
incognito: Boolean?, incognito: Boolean?,
close: (() -> Unit)?, close: (() -> Unit)?,
cleanup: (() -> Unit)? = null, cleanup: (() -> Unit)? = null,
@@ -29,7 +29,7 @@ suspend fun planAndConnect(
) { ) {
val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString()) val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString())
if (connectionPlan != null) { if (connectionPlan != null) {
val link = strHasSingleSimplexLink(uri.toString().trim()) val link = strHasSingleSimplexLink(uri.trim())
val linkText = if (link?.format is Format.SimplexLink) val linkText = if (link?.format is Format.SimplexLink)
"<br><br><u>${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}</u>" "<br><br><u>${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}</u>"
else else
@@ -323,13 +323,13 @@ suspend fun planAndConnect(
suspend fun connectViaUri( suspend fun connectViaUri(
chatModel: ChatModel, chatModel: ChatModel,
rhId: Long?, rhId: Long?,
uri: URI, uri: String,
incognito: Boolean, incognito: Boolean,
connectionPlan: ConnectionPlan?, connectionPlan: ConnectionPlan?,
close: (() -> Unit)?, close: (() -> Unit)?,
cleanup: (() -> Unit)?, cleanup: (() -> Unit)?,
) { ) {
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val pcc = chatModel.controller.apiConnect(rhId, incognito, uri)
val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
if (pcc != null) { if (pcc != null) {
withChats { withChats {
@@ -361,7 +361,7 @@ fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType
fun askCurrentOrIncognitoProfileAlert( fun askCurrentOrIncognitoProfileAlert(
chatModel: ChatModel, chatModel: ChatModel,
rhId: Long?, rhId: Long?,
uri: URI, uri: String,
connectionPlan: ConnectionPlan?, connectionPlan: ConnectionPlan?,
close: (() -> Unit)?, close: (() -> Unit)?,
title: String, title: String,
@@ -417,7 +417,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co
fun ownGroupLinkConfirmConnect( fun ownGroupLinkConfirmConnect(
chatModel: ChatModel, chatModel: ChatModel,
rhId: Long?, rhId: Long?,
uri: URI, uri: String,
linkText: String, linkText: String,
incognito: Boolean?, incognito: Boolean?,
connectionPlan: ConnectionPlan?, connectionPlan: ConnectionPlan?,
@@ -482,7 +482,7 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
withBGApi { withBGApi {
planAndConnect( planAndConnect(
chatModel.remoteHostId(), chatModel.remoteHostId(),
URI.create(link), link,
incognito = null, incognito = null,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id }, filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
close = close, close = close,
@@ -68,7 +68,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
* Otherwise, it will be called here AFTER [AddContactLearnMore] is launched and will clear the value too soon. * Otherwise, it will be called here AFTER [AddContactLearnMore] is launched and will clear the value too soon.
* It will be dropped automatically when connection established or when user goes away from this screen. * It will be dropped automatically when connection established or when user goes away from this screen.
**/ **/
if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() == 1) { if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() <= 1) {
val conn = contactConnection.value val conn = contactConnection.value
if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) { if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
@@ -308,6 +308,7 @@ fun ActiveProfilePicker(
switchingProfile.value = true switchingProfile.value = true
withApi { withApi {
try { try {
appPreferences.incognito.set(false)
var updatedConn: PendingContactConnection? = null; var updatedConn: PendingContactConnection? = null;
if (contactConnection != null) { if (contactConnection != null) {
@@ -361,6 +362,7 @@ fun ActiveProfilePicker(
switchingProfile.value = true switchingProfile.value = true
withApi { withApi {
try { try {
appPreferences.incognito.set(true)
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (conn != null) { if (conn != null) {
withChats { withChats {
@@ -653,7 +655,7 @@ private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boole
private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null) { private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null) {
planAndConnect( planAndConnect(
rhId, rhId,
URI.create(link), link,
close = close, close = close,
cleanup = cleanup, cleanup = cleanup,
incognito = null incognito = null
@@ -125,6 +125,7 @@
<string name="proxy_destination_error_broker_version">Destination server version of %1$s is incompatible with forwarding server %2$s.</string> <string name="proxy_destination_error_broker_version">Destination server version of %1$s is incompatible with forwarding server %2$s.</string>
<string name="please_try_later">Please try later.</string> <string name="please_try_later">Please try later.</string>
<string name="error_sending_message">Error sending message</string> <string name="error_sending_message">Error sending message</string>
<string name="error_forwarding_messages">Error forwarding messages</string>
<string name="error_creating_message">Error creating message</string> <string name="error_creating_message">Error creating message</string>
<string name="error_loading_details">Error loading details</string> <string name="error_loading_details">Error loading details</string>
<string name="error_adding_members">Error adding member(s)</string> <string name="error_adding_members">Error adding member(s)</string>
@@ -133,7 +134,9 @@
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string> <string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
<string name="file_not_approved_title">Unknown servers!</string> <string name="file_not_approved_title">Unknown servers!</string>
<string name="file_not_approved_descr">Without Tor or VPN, your IP address will be visible to these XFTP relays:\n%1$s.</string> <string name="file_not_approved_descr">Without Tor or VPN, your IP address will be visible to these XFTP relays:\n%1$s.</string>
<string name="n_other_file_errors">%1$d other file error(s).</string>
<string name="error_receiving_file">Error receiving file</string> <string name="error_receiving_file">Error receiving file</string>
<string name="n_file_errors">%1$d file error(s):\n%2$s</string>
<string name="error_creating_address">Error creating address</string> <string name="error_creating_address">Error creating address</string>
<string name="contact_already_exists">Contact already exists</string> <string name="contact_already_exists">Contact already exists</string>
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to %1$s.</string> <string name="you_are_already_connected_to_vName_via_this_link">You are already connected to %1$s.</string>
@@ -378,12 +381,23 @@
<string name="no_selected_chat">No selected chat</string> <string name="no_selected_chat">No selected chat</string>
<string name="selected_chat_items_nothing_selected">Nothing selected</string> <string name="selected_chat_items_nothing_selected">Nothing selected</string>
<string name="selected_chat_items_selected_n">Selected %d</string> <string name="selected_chat_items_selected_n">Selected %d</string>
<string name="forward_alert_title_messages_to_forward">Forward %1$s message(s)?</string>
<string name="forward_alert_title_nothing_to_forward">Nothing to forward!</string>
<string name="forward_alert_forward_messages_without_files">Forward messages without files?</string>
<string name="forward_files_messages_deleted_after_selection_desc">Messages were deleted after you selected them.</string>
<string name="forward_files_not_accepted_desc">%1$d file(s) were not downloaded.</string>
<string name="forward_files_in_progress_desc">%1$d file(s) are still being downloaded.</string>
<string name="forward_files_failed_to_receive_desc">%1$d file(s) failed to download.</string>
<string name="forward_files_missing_desc">%1$d file(s) were deleted.</string>
<string name="forward_files_not_accepted_receive_files">Download</string>
<string name="forward_files_messages_deleted_after_selection_title">%1$s messages not forwarded</string>
<!-- ShareListView.kt --> <!-- ShareListView.kt -->
<string name="share_message">Share message…</string> <string name="share_message">Share message…</string>
<string name="share_image">Share media…</string> <string name="share_image">Share media…</string>
<string name="share_file">Share file…</string> <string name="share_file">Share file…</string>
<string name="forward_message">Forward message…</string> <string name="forward_message">Forward message…</string>
<string name="forward_multiple">Forward messages…</string>
<string name="cannot_share_message_alert_title">Cannot send message</string> <string name="cannot_share_message_alert_title">Cannot send message</string>
<string name="cannot_share_message_alert_text">Selected chat preferences prohibit this message.</string> <string name="cannot_share_message_alert_text">Selected chat preferences prohibit this message.</string>
@@ -405,6 +419,8 @@
<string name="files_and_media_prohibited">Files and media prohibited!</string> <string name="files_and_media_prohibited">Files and media prohibited!</string>
<string name="only_owners_can_enable_files_and_media">Only group owners can enable files and media.</string> <string name="only_owners_can_enable_files_and_media">Only group owners can enable files and media.</string>
<string name="compose_send_direct_message_to_connect">Send direct message to connect</string> <string name="compose_send_direct_message_to_connect">Send direct message to connect</string>
<string name="compose_forward_messages_n">Forwarding %1$s messages</string>
<string name="compose_save_messages_n">Saving %1$s messages</string>
<string name="simplex_links_not_allowed">SimpleX links not allowed</string> <string name="simplex_links_not_allowed">SimpleX links not allowed</string>
<string name="files_and_media_not_allowed">Files and media not allowed</string> <string name="files_and_media_not_allowed">Files and media not allowed</string>
<string name="voice_messages_not_allowed">Voice messages not allowed</string> <string name="voice_messages_not_allowed">Voice messages not allowed</string>
@@ -34,6 +34,9 @@ var useWorker = false;
var isDesktop = false; var isDesktop = false;
var localizedState = ""; var localizedState = "";
var localizedDescription = ""; var localizedDescription = "";
// When one side of a call sends candidates tot fast (until local & remote descriptions are set), that candidates
// will be stored here and then set when the call will be ready to process them
let afterCallInitializedCandidates = [];
const processCommand = (function () { const processCommand = (function () {
const defaultIceServers = [ const defaultIceServers = [
{ urls: ["stuns:stun.simplex.im:443"] }, { urls: ["stuns:stun.simplex.im:443"] },
@@ -234,6 +237,8 @@ const processCommand = (function () {
const pc = activeCall.connection; const pc = activeCall.connection;
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
addIceCandidates(pc, afterCallInitializedCandidates);
afterCallInitializedCandidates = [];
// for debugging, returning the command for callee to use // for debugging, returning the command for callee to use
// resp = { // resp = {
// type: "offer", // type: "offer",
@@ -272,6 +277,8 @@ const processCommand = (function () {
const answer = await pc.createAnswer(); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer); await pc.setLocalDescription(answer);
addIceCandidates(pc, remoteIceCandidates); addIceCandidates(pc, remoteIceCandidates);
addIceCandidates(pc, afterCallInitializedCandidates);
afterCallInitializedCandidates = [];
// same as command for caller to use // same as command for caller to use
resp = { resp = {
type: "answer", type: "answer",
@@ -297,17 +304,20 @@ const processCommand = (function () {
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
await pc.setRemoteDescription(new RTCSessionDescription(answer)); await pc.setRemoteDescription(new RTCSessionDescription(answer));
addIceCandidates(pc, remoteIceCandidates); addIceCandidates(pc, remoteIceCandidates);
addIceCandidates(pc, afterCallInitializedCandidates);
afterCallInitializedCandidates = [];
resp = { type: "ok" }; resp = { type: "ok" };
} }
break; break;
case "ice": case "ice":
const remoteIceCandidates = parse(command.iceCandidates);
if (pc) { if (pc) {
const remoteIceCandidates = parse(command.iceCandidates);
addIceCandidates(pc, remoteIceCandidates); addIceCandidates(pc, remoteIceCandidates);
resp = { type: "ok" }; resp = { type: "ok" };
} }
else { else {
resp = { type: "error", message: "ice: call not started" }; afterCallInitializedCandidates.push(...remoteIceCandidates);
resp = { type: "error", message: "ice: call not started yet, will add candidates later" };
} }
break; break;
case "media": case "media":
@@ -204,7 +204,7 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
return when { return when {
session.headers["upgrade"] == "websocket" -> super.handle(session) session.headers["upgrade"] == "websocket" -> super.handle(session)
session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html")
else -> resourcesToResponse(URI.create(session.uri).path) else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL"))
} }
} }
} }
+4 -4
View File
@@ -26,11 +26,11 @@ android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11 kotlin.jvm.target=11
android.version_name=6.0.4 android.version_name=6.1-beta.0
android.version_code=237 android.version_code=239
desktop.version_name=6.0.4 desktop.version_name=6.1-beta.0
desktop.version_code=65 desktop.version_code=66
kotlin.version=1.9.23 kotlin.version=1.9.23
gradle.plugin.version=8.2.0 gradle.plugin.version=8.2.0
+1 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat name: simplex-chat
version: 6.1.0.2 version: 6.1.0.3
#synopsis: #synopsis:
#description: #description:
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme
+12 -2
View File
@@ -219,6 +219,9 @@ var useWorker = false
var isDesktop = false var isDesktop = false
var localizedState = "" var localizedState = ""
var localizedDescription = "" var localizedDescription = ""
// When one side of a call sends candidates tot fast (until local & remote descriptions are set), that candidates
// will be stored here and then set when the call will be ready to process them
let afterCallInitializedCandidates: RTCIceCandidateInit[] = []
const processCommand = (function () { const processCommand = (function () {
type RTCRtpSenderWithEncryption = RTCRtpSender & { type RTCRtpSenderWithEncryption = RTCRtpSender & {
@@ -445,6 +448,8 @@ const processCommand = (function () {
const pc = activeCall.connection const pc = activeCall.connection
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
addIceCandidates(pc, afterCallInitializedCandidates)
afterCallInitializedCandidates = []
// for debugging, returning the command for callee to use // for debugging, returning the command for callee to use
// resp = { // resp = {
// type: "offer", // type: "offer",
@@ -481,6 +486,8 @@ const processCommand = (function () {
const answer = await pc.createAnswer() const answer = await pc.createAnswer()
await pc.setLocalDescription(answer) await pc.setLocalDescription(answer)
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
addIceCandidates(pc, afterCallInitializedCandidates)
afterCallInitializedCandidates = []
// same as command for caller to use // same as command for caller to use
resp = { resp = {
type: "answer", type: "answer",
@@ -503,16 +510,19 @@ const processCommand = (function () {
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
await pc.setRemoteDescription(new RTCSessionDescription(answer)) await pc.setRemoteDescription(new RTCSessionDescription(answer))
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
addIceCandidates(pc, afterCallInitializedCandidates)
afterCallInitializedCandidates = []
resp = {type: "ok"} resp = {type: "ok"}
} }
break break
case "ice": case "ice":
const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates)
if (pc) { if (pc) {
const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates)
addIceCandidates(pc, remoteIceCandidates) addIceCandidates(pc, remoteIceCandidates)
resp = {type: "ok"} resp = {type: "ok"}
} else { } else {
resp = {type: "error", message: "ice: call not started"} afterCallInitializedCandidates.push(...remoteIceCandidates)
resp = {type: "error", message: "ice: call not started yet, will add candidates later"}
} }
break break
case "media": case "media":
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack -- see: https://github.com/sol/hpack
name: simplex-chat name: simplex-chat
version: 6.1.0.2 version: 6.1.0.3
category: Web, System, Services, Cryptography category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat author: simplex.chat
+16 -4
View File
@@ -2945,8 +2945,8 @@ processChatCommand' vr = \case
msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers
let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_ let itemsData = prepareSndItemsData msgs_ cmrs ciFiles_ quotedItems_
when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch"
(errs, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live
unless (null errs) $ toView $ CRChatErrors (Just user) errs processSendErrs user r
forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ (timed_ >>= timedDeleteAt') $ \deleteAt ->
forM_ cis $ \ci -> forM_ cis $ \ci ->
startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) deleteAt
@@ -3010,8 +3010,8 @@ processChatCommand' vr = \case
cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live
when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
createMemberSndStatuses cis_ msgs_ gsr createMemberSndStatuses cis_ msgs_ gsr
let (errs, cis) = partitionEithers cis_ let r@(_, cis) = partitionEithers cis_
unless (null errs) $ toView $ CRChatErrors (Just user) errs processSendErrs user r
forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ (timed_ >>= timedDeleteAt') $ \deleteAt ->
forM_ cis $ \ci -> forM_ cis $ \ci ->
startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) deleteAt
@@ -3103,6 +3103,18 @@ processChatCommand' vr = \case
| (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <- | (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <-
zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_) zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_)
] ]
processSendErrs :: User -> ([ChatError], [ChatItem c d]) -> CM ()
processSendErrs user = \case
-- no errors
([], _) -> pure ()
-- at least one item is successfully created
(errs, _ci : _) -> toView $ CRChatErrors (Just user) errs
-- single error
([err], []) -> throwError err
-- multiple errors
(errs@(err : _), []) -> do
toView $ CRChatErrors (Just user) errs
throwError err
getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect])
getCommandDirectChatItems user ctId itemIds = do getCommandDirectChatItems user ctId itemIds = do
ct <- withFastStore $ \db -> getContact db vr user ctId ct <- withFastStore $ \db -> getContact db vr user ctId
+1 -1
View File
@@ -2547,7 +2547,7 @@ setupDesynchronizedRatchet tmp alice = do
(bob </) (bob </)
bob ##> "/tail @alice 1" bob ##> "/tail @alice 1"
bob <# "alice> decryption error, possibly due to the device change (header, 3 messages)" bob <# "alice> decryption error, possibly due to the device change (header, 3 messages)"
bob `send` "@alice 1" bob ##> "@alice 1"
bob <## "error: command is prohibited, sendMessagesB: send prohibited" bob <## "error: command is prohibited, sendMessagesB: send prohibited"
(alice </) (alice </)
where where