ios: bulk forward (#4857)

* ios: forward multiple messages

* ios: batch previews, when sending media messsages (#4861)

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: Arturs Krumins <auth@levitatingpineapple.com>
Co-authored-by: Diogo <diogofncunha@gmail.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Arturs Krumins
2024-09-19 10:04:19 +03:00
committed by GitHub
parent acc9be1a5b
commit 255538e5d7
9 changed files with 404 additions and 164 deletions
+103 -52
View File
@@ -357,6 +357,12 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
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]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
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 {
if let chatItem = await apiReceiveFile(
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
await receiveFiles(
user: user,
fileIds: [fileId],
userApprovedRelays: userApprovedRelays,
auto: auto
) {
await chatItemSimpleUpdate(user, chatItem)
}
)
}
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r {
logger.debug("apiReceiveFile error: sender cancelled file transfer")
if !auto {
am.showAlertMsg(
title: "Cannot receive file",
message: "Sender cancelled file transfer."
func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
var fileIdsToApprove = [Int64]()
var srvsToApprove = Set<String>()
var otherFileErrs = [ChatResponse]()
for fileId in fileIds {
let r = await chatSendCmd(
.receiveFile(
fileId: fileId,
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 {
am.showAlert(networkErrorAlert)
}
if !auto {
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) {
case .fileCancelled:
logger.debug("apiReceiveFile ignoring fileCancelled error")
case .fileAlreadyReceiving:
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 there are not approved files, alert is shown the same way both in case of singular and plural files reception
if !fileIdsToApprove.isEmpty {
let srvs = srvsToApprove
.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
am.showAlert(Alert(
title: Text("Unknown servers!"),
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
primaryButton: .default(
Text("Download"),
action: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
}
.sorted()
.joined(separator: ", ")
let fIds = fileIdsToApprove
await MainActor.run {
showAlert(
title: NSLocalizedString("Unknown servers!", comment: "alert title"),
message: (
NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs).", comment: "alert message") +
(otherErrsStr != "" ? "\n\n" + NSLocalizedString("Other file errors:\n\(otherErrsStr)", comment: "alert message") : "")
),
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:
logger.error("apiReceiveFile error: \(String(describing: r))")
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(responseError(r))"
} else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
let errorResponse = otherFileErrs.first!
switch errorResponse {
case let .rcvFileAcceptedSndCancelled(_, rcvFileTransfer):
logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
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 {
@@ -14,7 +14,7 @@ struct ChatItemForwardingView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
var ci: ChatItem
var chatItems: [ChatItem]
var fromChatInfo: ChatInfo
@Binding var composeState: ComposeState
@@ -73,11 +73,14 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
let prohibited = chatItems.map { ci in
chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
}.contains(true)
Button {
if prohibited {
alert = SomeAlert(
@@ -93,10 +96,10 @@ struct ChatItemForwardingView: View {
composeState = ComposeState(
message: composeState.message,
preview: composeState.linkPreview != nil ? composeState.preview : .noPreview,
contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo)
contextItem: .forwardingItems(chatItems: chatItems, fromChatInfo: fromChatInfo)
)
} else {
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
composeState = ComposeState.init(forwardingItems: chatItems, fromChatInfo: fromChatInfo)
ItemsModel.shared.loadOpenChat(chat.id)
}
}
@@ -123,7 +126,7 @@ struct ChatItemForwardingView: View {
#Preview {
ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
fromChatInfo: .direct(contact: Contact.sampleData),
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
+133 -12
View File
@@ -42,6 +42,7 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
@@ -98,7 +99,8 @@ struct ChatView: View {
if case let .group(groupInfo) = chat.chatInfo {
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 {
selectedChatItems = nil
initChatView()
@@ -411,7 +429,8 @@ struct ChatView: View {
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems
selectedChatItems: $selectedChatItems,
forwardedChatItems: $forwardedChatItems
)
.id(ci.id) // Required to trigger `onAppear` on iOS15
} 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) {
Task {
if loadingItems || firstPage { return }
@@ -762,10 +891,10 @@ struct ChatView: View {
@State private var showDeleteMessages = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false
@State private var msgWidth: CGFloat = 0
@Binding var selectedChatItems: Set<Int64>?
@Binding var forwardedChatItems: [ChatItem]
@State private var allowMenu: Bool = true
@State private var markedRead = false
@@ -1079,14 +1208,6 @@ struct ChatView: View {
}) {
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 {
@@ -1227,7 +1348,7 @@ struct ChatView: View {
var forwardButton: Button<some View> {
Button {
showForwardingSheet = true
forwardedChatItems = [chatItem]
} label: {
Label(
NSLocalizedString("Forward", comment: "chat item action"),
@@ -23,7 +23,7 @@ enum ComposeContextItem {
case noContextItem
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo)
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
}
enum VoiceMessageRecordingState {
@@ -73,10 +73,10 @@ struct ComposeState {
}
}
init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) {
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
self.message = ""
self.preview = .noPreview
self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo)
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
self.voiceMessageRecordingState = .noRecording
}
@@ -112,7 +112,7 @@ struct ComposeState {
var forwarding: Bool {
switch contextItem {
case .forwardingItem: return true
case .forwardingItems: return true
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 {
if editing || forwarding || liveMessage != nil || inProgress { return true }
switch preview {
@@ -687,7 +694,7 @@ struct ComposeView: View {
case let .quotedItem(chatItem: quotedItem):
ContextItemView(
chat: chat,
contextItem: quotedItem,
contextItems: [quotedItem],
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
@@ -695,18 +702,17 @@ struct ComposeView: View {
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
contextItem: editingItem,
contextItems: [editingItem],
contextIcon: "pencil",
cancelContextItem: { clearState() }
)
Divider()
case let .forwardingItem(chatItem: forwardedItem, _):
case let .forwardingItems(chatItems, _):
ContextItemView(
chat: chat,
contextItem: forwardedItem,
contextItems: chatItems,
contextIcon: "arrowshape.turn.up.forward",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
showSender: false
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
}
@@ -730,10 +736,11 @@ struct ComposeView: View {
}
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
await sendMemberContactInvitation()
} else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem {
sent = await forwardItem(ci, fromChatInfo, ttl)
} else if case let .forwardingItems(chatItems, fromChatInfo) = composeState.contextItem {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
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 {
sent = await updateMessage(ci, live: live)
@@ -750,27 +757,28 @@ struct ComposeView: View {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media):
// TODO batch send: batch media previews
case let .mediaPreviews(media):
let last = media.count - 1
var msgs: [ComposedMessage] = []
if last >= 0 {
for i in 0..<last {
if case (_, .video(_, _, _)) = media[i] {
sent = await sendVideo(media[i], ttl: ttl)
} else {
sent = await sendImage(media[i], ttl: ttl)
if i > 0 {
// Sleep to allow `progressByTimeout` update be rendered
try? await Task.sleep(nanoseconds: 100_000000)
}
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] {
sent = await sendVideo(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
} else {
sent = await sendImage(media[last], text: msgText, quoted: quoted, live: live, ttl: ttl)
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
}
}
if sent == nil {
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl)
if msgs.isEmpty {
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
}
sent = await send(msgs, live: live, ttl: ttl).last
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
@@ -792,6 +800,20 @@ struct ComposeView: View {
}
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 {
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? {
if !privacyEncryptLocalFilesGroupDefault.get() {
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? {
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
? await apiCreateChatItems(
noteFolderId: chat.chatInfo.apiId,
composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)]
)
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
: await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
live: live,
ttl: ttl,
composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)]
composedMessages: msgs
) {
await MainActor.run {
chatModel.removeLiveDummy(animated: false)
@@ -906,33 +916,43 @@ struct ComposeView: View {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
// UI only supports sending one item at a time
return chatItems.first
return chatItems
}
if let file = file {
removeFile(file.filePath)
for msg in msgs {
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(
toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId,
itemIds: [forwardedItem.id],
itemIds: forwardedItems.map { $0.id },
ttl: ttl
) {
await MainActor.run {
for chatItem in chatItems {
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.first
return chatItems
} else {
return []
}
return nil
}
func checkLinkPreview() -> MsgContent {
@@ -949,14 +969,6 @@ struct ComposeView: View {
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 {
@@ -12,7 +12,7 @@ import SimpleXChat
struct ContextItemView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
let contextItem: ChatItem
let contextItems: [ChatItem]
let contextIcon: String
let cancelContextItem: () -> Void
var showSender: Bool = true
@@ -24,13 +24,22 @@ struct ContextItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary)
if showSender, let sender = contextItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
msgContentView(lines: 2)
}
if let singleItem = contextItems.first, contextItems.count == 1 {
if showSender, let sender = singleItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) {
Text(sender).font(.caption).foregroundColor(theme.colors.secondary)
msgContentView(lines: 2, contextItem: singleItem)
}
} else {
msgContentView(lines: 3, contextItem: singleItem)
}
} else {
msgContentView(lines: 3)
Text(
chat.chatInfo.chatType == .local
? "Saving \(contextItems.count) messages"
: "Forwarding \(contextItems.count) messages"
)
.italic()
}
Spacer()
Button {
@@ -45,23 +54,32 @@ struct ContextItemView: View {
.padding(12)
.frame(minHeight: 54)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(contextItem, theme))
.background(background)
}
private func msgContentView(lines: Int) -> some View {
contextMsgPreview()
private var background: Color {
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)
.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)
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 {
case .file: return image("doc.fill")
case .file: return isFileLoaded ? image("doc.fill") : Text("")
case .image: return image("photo")
case .voice: return image("play.fill")
case .voice: return isFileLoaded ? image("play.fill") : Text("")
default: return Text("")
}
}
@@ -75,6 +93,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
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 {
if case .noContextItem = composeState.contextItem,
!composeState.voicePreview,
!composeState.manyMediaPreviews,
let send = sendLiveMessage,
let update = updateLiveMessage {
Button {
@@ -32,12 +32,15 @@ struct SelectedItemsBottomToolbar: View {
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
var forwardItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var forwardEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
@@ -50,6 +53,7 @@ struct SelectedItemsBottomToolbar: View {
} label: {
Image(systemName: "trash")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
@@ -61,24 +65,24 @@ struct SelectedItemsBottomToolbar: View {
} label: {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
forwardItems()
} label: {
Image(systemName: "square.and.arrow.up")
Image(systemName: "arrowshape.turn.up.forward")
.resizable()
.scaledToFit()
.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)
.opacity(0)
.disabled(!forwardEnabled || allButtonsDisabled)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
@@ -106,15 +110,16 @@ struct SelectedItemsBottomToolbar: View {
if let selected = selectedItems {
let me: 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) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
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
return (de, dee, me, onlyOwnGroupItems, sel)
return (de, dee, me, onlyOwnGroupItems, fe, sel)
} else {
return r
}
+17 -1
View File
@@ -47,8 +47,24 @@ func showAlert(
buttonAction()
})
if cancelButton {
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel))
alert.addAction(cancelAlertAction)
}
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)
+14 -1
View File
@@ -49,6 +49,7 @@ public enum ChatCommand {
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
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 apiGetNtfToken
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 .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 .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
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 .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
case .apiChatItemReaction: return "apiChatItemReaction"
case .apiPlanForwardChatItems: return "apiPlanForwardChatItems"
case .apiForwardChatItems: return "apiForwardChatItems"
case .apiGetNtfToken: return "apiGetNtfToken"
case .apiRegisterToken: return "apiRegisterToken"
@@ -601,6 +604,7 @@ public enum ChatResponse: Decodable, Error {
case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItems(user: UserRef, chatItems: [AChatItem])
case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
case chatItemNotChanged(user: UserRef, chatItem: AChatItem)
@@ -772,6 +776,7 @@ public enum ChatResponse: Decodable, Error {
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItems: return "newChatItems"
case .forwardPlan: return "forwardPlan"
case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated"
case .chatItemUpdated: return "chatItemUpdated"
case .chatItemNotChanged: return "chatItemNotChanged"
@@ -943,6 +948,7 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
case let .chatItemsStatusesUpdated(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
@@ -1134,7 +1140,7 @@ public enum ChatPagination {
public struct ComposedMessage: Encodable {
public var fileSource: CryptoFile?
var quotedItemId: Int64?
var msgContent: MsgContent
public var msgContent: MsgContent
public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) {
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 var agentConnId: String
public var networkStatus: NetworkStatus