diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 764db8335e..a828e1348d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1826,11 +1826,15 @@ func processReceivedMsg(_ res: ChatResponse) async { if let aChatItem = aChatItem { await chatItemSimpleUpdate(user, aChatItem) } - case let .rcvFileError(user, aChatItem, _): + case let .rcvFileError(user, aChatItem, _, _): if let aChatItem = aChatItem { await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } } + case let .rcvFileWarning(user, aChatItem, _, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } case let .sndFileStart(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) case let .sndFileComplete(user, aChatItem, _): @@ -1852,6 +1856,10 @@ func processReceivedMsg(_ res: ChatResponse) async { await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } } + case let .sndFileWarning(user, aChatItem, _, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } case let .callInvitation(invitation): await MainActor.run { m.callInvitations[invitation.contact.id] = invitation diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 53d840306e..6def90ebe9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -56,14 +56,16 @@ struct CIFileView: View { case .sndTransfer: return false case .sndComplete: return true case .sndCancelled: return false - case .sndError: return false + case .sndError: return true + case .sndWarning: return true case .rcvInvitation: return true case .rcvAccepted: return true case .rcvTransfer: return false case .rcvAborted: return true case .rcvComplete: return true case .rcvCancelled: return false - case .rcvError: return false + case .rcvError: return true + case .rcvWarning: return true case .invalid: return false } } @@ -108,6 +110,18 @@ struct CIFileView: View { if let fileSource = getLoadedFileSource(file) { saveCryptoFile(fileSource) } + case let .rcvError(rcvFileError): + logger.debug("CIFileView fileAction - in .rcvError") + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(rcvFileError.errorInfo) + )) + case let .rcvWarning(rcvFileError): + logger.debug("CIFileView fileAction - in .rcvWarning") + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(rcvFileError.errorInfo) + )) case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { @@ -118,6 +132,18 @@ struct CIFileView: View { if let fileSource = getLoadedFileSource(file) { saveCryptoFile(fileSource) } + case let .sndError(sndFileError): + logger.debug("CIFileView fileAction - in .sndError") + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(sndFileError.errorInfo) + )) + case let .sndWarning(sndFileError): + logger.debug("CIFileView fileAction - in .sndWarning") + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(sndFileError.errorInfo) + )) default: break } } @@ -141,6 +167,7 @@ struct CIFileView: View { case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10) case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .rcvInvitation: if fileSizeValid(file) { fileIcon("arrow.down.doc.fill", color: .accentColor) @@ -159,6 +186,7 @@ struct CIFileView: View { case .rcvComplete: fileIcon("doc.fill") case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) + case .rcvWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10) case .invalid: fileIcon("doc.fill", innerIcon: "questionmark", innerIconSize: 10) } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 7d33df6c60..cfead635fe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -60,6 +60,26 @@ struct CIImageView: View { case .rcvTransfer: () // ? case .rcvComplete: () // ? case .rcvCancelled: () // TODO + case let .rcvError(rcvFileError): + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(rcvFileError.errorInfo) + )) + case let .rcvWarning(rcvFileError): + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(rcvFileError.errorInfo) + )) + case let .sndError(sndFileError): + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(sndFileError.errorInfo) + )) + case let .sndWarning(sndFileError): + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(sndFileError.errorInfo) + )) default: () } } @@ -99,14 +119,16 @@ struct CIImageView: View { case .sndComplete: fileIcon("checkmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13) case .sndError: fileIcon("xmark", 10, 13) + case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvTransfer: progressView() case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11) + case .rcvComplete: EmptyView() case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvError: fileIcon("xmark", 10, 13) + case .rcvWarning: fileIcon("exclamationmark.triangle.fill", 10, 13) case .invalid: fileIcon("questionmark", 10, 13) - default: EmptyView() } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 3bfe24b79d..d84ad8f5fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -192,7 +192,7 @@ struct CIVideoView: View { .disabled(!canBePlayed) } } - loadingIndicator() + fileStatusIcon() } .onAppear { addObserver(player, url) @@ -258,11 +258,11 @@ struct CIVideoView: View { .resizable() .scaledToFit() .frame(width: w) - loadingIndicator() + fileStatusIcon() } } - @ViewBuilder private func loadingIndicator() -> some View { + @ViewBuilder private func fileStatusIcon() -> some View { if let file = chatItem.file { switch file.fileStatus { case .sndStored: @@ -279,7 +279,22 @@ struct CIVideoView: View { } case .sndComplete: fileIcon("checkmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13) - case .sndError: fileIcon("xmark", 10, 13) + case let .sndError(sndFileError): + fileIcon("xmark", 10, 13) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(sndFileError.errorInfo) + )) + } + case let .sndWarning(sndFileError): + fileIcon("exclamationmark.triangle.fill", 10, 13) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(sndFileError.errorInfo) + )) + } case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) case let .rcvTransfer(rcvProgress, rcvTotal): @@ -289,10 +304,25 @@ struct CIVideoView: View { progressView() } case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11) + case .rcvComplete: EmptyView() case .rcvCancelled: fileIcon("xmark", 10, 13) - case .rcvError: fileIcon("xmark", 10, 13) + case let .rcvError(rcvFileError): + fileIcon("xmark", 10, 13) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(rcvFileError.errorInfo) + )) + } + case let .rcvWarning(rcvFileError): + fileIcon("exclamationmark.triangle.fill", 10, 13) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(rcvFileError.errorInfo) + )) + } case .invalid: fileIcon("questionmark", 10, 13) - default: EmptyView() } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index ba1712f6e3..4d950a0d99 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -134,18 +134,53 @@ struct VoiceMessagePlayer: View { ZStack { if let recordingFile = recordingFile { switch recordingFile.fileStatus { - case .sndStored: playbackButton() - case .sndTransfer: playbackButton() + case .sndStored: + if recordingFile.fileProtocol == .local { + playbackButton() + } else { + loadingIcon() + } + case .sndTransfer: loadingIcon() case .sndComplete: playbackButton() case .sndCancelled: playbackButton() - case .sndError: playbackButton() + case let .sndError(sndFileError): + fileStatusIcon("multiply", 14) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(sndFileError.errorInfo) + )) + } + case let .sndWarning(sndFileError): + fileStatusIcon("exclamationmark.triangle.fill", 16) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(sndFileError.errorInfo) + )) + } case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath") case .rcvComplete: playbackButton() case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) - case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) + case let .rcvError(rcvFileError): + fileStatusIcon("multiply", 14) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("File error"), + message: Text(rcvFileError.errorInfo) + )) + } + case let .rcvWarning(rcvFileError): + fileStatusIcon("exclamationmark.triangle.fill", 16) + .onTapGesture { + AlertManager.shared.showAlert(Alert( + title: Text("Temporary file error"), + message: Text(rcvFileError.errorInfo) + )) + } case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } } else { @@ -246,6 +281,17 @@ struct VoiceMessagePlayer: View { } } + private func fileStatusIcon(_ image: String, _ size: CGFloat) -> some View { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .frame(width: 56, height: 56) + .background(showBackground ? chatItemFrameColor(chatItem, colorScheme) : .clear) + .clipShape(Circle()) + } + private func loadingIcon() -> some View { ProgressView() .frame(width: 30, height: 30) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 29bfdb6288..f5da473fd6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -17,6 +17,8 @@ struct ChatItemInfoView: View { @Binding var chatItemInfo: ChatItemInfo? @State private var selection: CIInfoTab = .history @State private var alert: CIInfoViewAlert? = nil + @State private var messageStatusLimited: Bool = true + @State private var fileStatusLimited: Bool = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum CIInfoTab { @@ -157,6 +159,35 @@ struct ChatItemInfoView: View { if developerTools { infoRow("Database ID", "\(meta.itemId)") infoRow("Record updated at", localTimestamp(meta.updatedAt)) + let msv = infoRow("Message status", ci.meta.itemStatus.id) + Group { + if messageStatusLimited { + msv.lineLimit(1) + } else { + msv + } + } + .onTapGesture { + withAnimation { + messageStatusLimited.toggle() + } + } + + if let file = ci.file { + let fsv = infoRow("File status", file.fileStatus.id) + Group { + if fileStatusLimited { + fsv.lineLimit(1) + } else { + fsv + } + } + .onTapGesture { + withAnimation { + fileStatusLimited.toggle() + } + } + } } } } @@ -473,8 +504,12 @@ struct ChatItemInfoView: View { if developerTools { shareText += [ String.localizedStringWithFormat(NSLocalizedString("Database ID: %d", comment: "copied message info"), meta.itemId), - String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)) + String.localizedStringWithFormat(NSLocalizedString("Record updated at: %@", comment: "copied message info"), localTimestamp(meta.updatedAt)), + String.localizedStringWithFormat(NSLocalizedString("Message status: %@", comment: "copied message info"), meta.itemStatus.id) ] + if let file = ci.file { + shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)] + } } if let qi = ci.quotedItem { shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 87eb21922c..a3545972d4 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -622,7 +622,8 @@ public enum ChatResponse: Decodable, Error { case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) + case rcvFileWarning(user: UserRef, chatItem_: AChatItem?, agentError: AgentErrorType, rcvFileTransfer: RcvFileTransfer) // sending file events case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) @@ -636,6 +637,7 @@ public enum ChatResponse: Decodable, Error { case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) + case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String) // call events case callInvitation(callInvitation: RcvCallInvitation) case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) @@ -784,6 +786,7 @@ public enum ChatResponse: Decodable, Error { case .rcvFileCancelled: return "rcvFileCancelled" case .rcvFileSndCancelled: return "rcvFileSndCancelled" case .rcvFileError: return "rcvFileError" + case .rcvFileWarning: return "rcvFileWarning" case .sndFileStart: return "sndFileStart" case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" @@ -796,6 +799,7 @@ public enum ChatResponse: Decodable, Error { case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" case .sndFileError: return "sndFileError" + case .sndFileWarning: return "sndFileWarning" case .callInvitation: return "callInvitation" case .callOffer: return "callOffer" case .callAnswer: return "callAnswer" @@ -944,7 +948,8 @@ public enum ChatResponse: Decodable, Error { case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") + case let .rcvFileWarning(u, chatItem, agentError, _): return withUser(u, "agentError: \(String(describing: agentError))\nchatItem: \(String(describing: chatItem))") case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) @@ -957,6 +962,7 @@ public enum ChatResponse: Decodable, Error { case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") + case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))") case let .callInvitation(inv): return String(describing: inv) case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 27e5a9818c..4c62fe4c8b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2728,7 +2728,7 @@ public enum CIStatus: Decodable { case rcvRead case invalid(text: String) - var id: String { + public var id: String { switch self { case .sndNew: return "sndNew" case .sndSent: return "sndSent" @@ -2809,11 +2809,19 @@ public enum SndError: Decodable { } } -public enum SrvError: Decodable { +public enum SrvError: Decodable, Equatable { case host case version case other(srvError: String) + var id: String { + switch self { + case .host: return "host" + case .version: return "version" + case let .other(srvError): return "other \(srvError)" + } + } + public var errorInfo: String { switch self { case .host: NSLocalizedString("Server address is incompatible with network settings.", comment: "srv error text.") @@ -3171,6 +3179,7 @@ public struct CIFile: Decodable { case .sndComplete: return true case .sndCancelled: return true case .sndError: return true + case .sndWarning: return true case .rcvInvitation: return false case .rcvAccepted: return false case .rcvTransfer: return false @@ -3178,6 +3187,7 @@ public struct CIFile: Decodable { case .rcvCancelled: return false case .rcvComplete: return true case .rcvError: return false + case .rcvWarning: return false case .invalid: return false } } @@ -3196,12 +3206,14 @@ public struct CIFile: Decodable { } case .sndCancelled: return nil case .sndError: return nil + case .sndWarning: return sndCancelAction case .rcvInvitation: return nil case .rcvAccepted: return rcvCancelAction case .rcvTransfer: return rcvCancelAction case .rcvAborted: return nil case .rcvCancelled: return nil case .rcvComplete: return nil + case .rcvWarning: return rcvCancelAction case .rcvError: return nil case .invalid: return nil } @@ -3310,35 +3322,64 @@ public enum CIFileStatus: Decodable, Equatable { case sndTransfer(sndProgress: Int64, sndTotal: Int64) case sndComplete case sndCancelled - case sndError + case sndError(sndFileError: FileError) + case sndWarning(sndFileError: FileError) case rcvInvitation case rcvAccepted case rcvTransfer(rcvProgress: Int64, rcvTotal: Int64) case rcvAborted case rcvComplete case rcvCancelled - case rcvError + case rcvError(rcvFileError: FileError) + case rcvWarning(rcvFileError: FileError) case invalid(text: String) - var id: String { + public var id: String { switch self { case .sndStored: return "sndStored" case let .sndTransfer(sndProgress, sndTotal): return "sndTransfer \(sndProgress) \(sndTotal)" case .sndComplete: return "sndComplete" case .sndCancelled: return "sndCancelled" - case .sndError: return "sndError" + case let .sndError(sndFileError): return "sndError \(sndFileError)" + case let .sndWarning(sndFileError): return "sndWarning \(sndFileError)" case .rcvInvitation: return "rcvInvitation" case .rcvAccepted: return "rcvAccepted" case let .rcvTransfer(rcvProgress, rcvTotal): return "rcvTransfer \(rcvProgress) \(rcvTotal)" case .rcvAborted: return "rcvAborted" case .rcvComplete: return "rcvComplete" case .rcvCancelled: return "rcvCancelled" - case .rcvError: return "rcvError" + case let .rcvError(rcvFileError): return "rcvError \(rcvFileError)" + case let .rcvWarning(rcvFileError): return "rcvWarning \(rcvFileError)" case .invalid: return "invalid" } } } +public enum FileError: Decodable, Equatable { + case auth + case noFile + case relay(srvError: SrvError) + case other(fileError: String) + + var id: String { + switch self { + case .auth: return "auth" + case .noFile: return "noFile" + case let .relay(srvError): return "relay \(srvError)" + case let .other(fileError): return "other \(fileError)" + } + } + + public var errorInfo: String { + switch self { + case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") + case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") + case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) + case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) + } + } +} + public enum MsgContent: Equatable { case text(String) case link(text: String, preview: LinkPreview)