mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-10 07:51:56 +00:00
63eaf260a2
* android, desktop, ios: drop stale mention map entry when @name is edited in place The compose-state mentions map was only pruned when fewer @name tokens were parsed from the text than were in the map. Editing an inserted @Name token in place (without re-picking from the picker) keeps the count at 1 while the parsed name no longer matches the stored key, so the stale entry was sent and the core rejected it with SEInvalidMention. Pruning now triggers whenever any map key is absent from the parsed mention names; the modified token is then sent as an unresolved formatted @ token. * android, desktop, ios: filter mentions at send time so in-place edits round-trip Treat the compose-state mentions map as a sticky cache of name -> memberId bindings recorded by the picker. The previous removeUnusedMentions pruned it on every text change, which dropped the binding the instant a letter was removed from an inserted @name token. Now the map is no longer mutated by text edits: memberMentions, the picker max-reached check, and the mentionMemberName disambiguator all filter against the names currently parsed from the message, so deleting and retyping the original characters re-resolves the original member, and the SEInvalidMention error from an in-place edit no longer occurs. * android, desktop, ios: cap memberMentions at MAX so stale cache entries cannot overflow the server limit With the sticky cache, manually typing an @name that happens to match a stale cache entry could push memberMentions above maxSndMentions and have the core reject the send with SEInvalidMention. The getter now walks parsedMessage in text order and stops at MAX_NUMBER_OF_MENTIONS; later @-tokens past the cap become visual-only formatting and the message still goes through. Also hoists the iOS activeMentions computation out of the per-row ForEach so it runs once per picker open rather than once per row. * android, desktop, ios: simplify mention restoration to minimal surgical diff Reverts the unnecessary mentionMemberName changes (the original mentions.containsKey behaviour preserves bindings better than the parsed-only variant), switches the picker checks to memberMentions.size (no helper variable needed since the getter already caps at MAX), and collapses the Kotlin memberMentions getter into a flat chain. * android, desktop, ios: gate picker mention-id and max-reached banner by memberMentions With the sticky cache, the picker's "currently bound member" highlight and the showMaxReachedBox banner-suppression clause read mentions[name] directly, so a stale cache entry made one row clickable (as a no-op) at the MAX limit and could suppress the banner when the user is actually adding a new mention. Both now gate the lookup by membership in the capped memberMentions, restoring the pre-fix UX where at MAX all rows are disabled and the banner shows when adding past the limit.
1993 lines
84 KiB
Swift
1993 lines
84 KiB
Swift
// Spec: spec/client/compose.md
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
import SwiftyGif
|
|
import PhotosUI
|
|
|
|
let MAX_NUMBER_OF_MENTIONS = 3
|
|
|
|
// Spec: spec/client/compose.md#ComposePreview
|
|
enum ComposePreview {
|
|
case noPreview
|
|
case linkPreview(linkPreview: LinkPreview?)
|
|
case chatLinkPreview(chatLink: MsgChatLink, ownerSig: LinkOwnerSig?)
|
|
case mediaPreviews(mediaPreviews: [(String, UploadContent?)])
|
|
case voicePreview(recordingFileName: String, duration: Int)
|
|
case filePreview(fileName: String, file: URL)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#ComposeContextItem
|
|
enum ComposeContextItem: Equatable {
|
|
case noContextItem
|
|
case quotedItem(chatItem: ChatItem)
|
|
case editingItem(chatItem: ChatItem)
|
|
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
|
|
case reportedItem(chatItem: ChatItem, reason: ReportReason)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#VoiceMessageRecordingState
|
|
enum VoiceMessageRecordingState {
|
|
case noRecording
|
|
case recording
|
|
case finished
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#LiveMessage
|
|
struct LiveMessage {
|
|
var chatItem: ChatItem
|
|
var typedMsg: String
|
|
var sentMsg: String?
|
|
}
|
|
|
|
typealias MentionedMembers = [String: CIMention]
|
|
|
|
// Spec: spec/client/compose.md#ComposeState
|
|
struct ComposeState {
|
|
var message: String
|
|
var parsedMessage: [FormattedText]
|
|
var liveMessage: LiveMessage? = nil
|
|
var preview: ComposePreview
|
|
var contextItem: ComposeContextItem
|
|
var voiceMessageRecordingState: VoiceMessageRecordingState
|
|
var inProgress = false
|
|
var progressByTimeout = false
|
|
var useLinkPreviews = true
|
|
var mentions: MentionedMembers = [:]
|
|
|
|
init(
|
|
message: String = "",
|
|
parsedMessage: [FormattedText] = [],
|
|
liveMessage: LiveMessage? = nil,
|
|
preview: ComposePreview = .noPreview,
|
|
contextItem: ComposeContextItem = .noContextItem,
|
|
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
|
|
mentions: MentionedMembers = [:]
|
|
) {
|
|
self.message = message
|
|
self.parsedMessage = parsedMessage
|
|
self.liveMessage = liveMessage
|
|
self.preview = preview
|
|
self.contextItem = contextItem
|
|
self.voiceMessageRecordingState = voiceMessageRecordingState
|
|
self.mentions = mentions
|
|
}
|
|
|
|
init(editingItem: ChatItem) {
|
|
let text = if case let .chat(t, chatLink, _) = editingItem.content.msgContent {
|
|
stripTextLink(t, chatLink.connLinkStr)
|
|
} else {
|
|
editingItem.content.text
|
|
}
|
|
self.message = text
|
|
self.parsedMessage = editingItem.formattedText ?? FormattedText.plain(text)
|
|
self.preview = chatItemPreview(chatItem: editingItem)
|
|
self.contextItem = .editingItem(chatItem: editingItem)
|
|
if let emc = editingItem.content.msgContent,
|
|
case .voice = emc {
|
|
self.voiceMessageRecordingState = .finished
|
|
} else {
|
|
self.voiceMessageRecordingState = .noRecording
|
|
}
|
|
self.mentions = editingItem.mentions ?? [:]
|
|
}
|
|
|
|
init(forwardingItems: [ChatItem], fromChatInfo: ChatInfo) {
|
|
self.message = ""
|
|
self.parsedMessage = []
|
|
self.preview = .noPreview
|
|
self.contextItem = .forwardingItems(chatItems: forwardingItems, fromChatInfo: fromChatInfo)
|
|
self.voiceMessageRecordingState = .noRecording
|
|
}
|
|
|
|
func copy(
|
|
message: String? = nil,
|
|
parsedMessage: [FormattedText]? = nil,
|
|
liveMessage: LiveMessage? = nil,
|
|
preview: ComposePreview? = nil,
|
|
contextItem: ComposeContextItem? = nil,
|
|
voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
|
|
mentions: MentionedMembers? = nil
|
|
) -> ComposeState {
|
|
ComposeState(
|
|
message: message ?? self.message,
|
|
parsedMessage: parsedMessage ?? self.parsedMessage,
|
|
liveMessage: liveMessage ?? self.liveMessage,
|
|
preview: preview ?? self.preview,
|
|
contextItem: contextItem ?? self.contextItem,
|
|
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState,
|
|
mentions: mentions ?? self.mentions
|
|
)
|
|
}
|
|
|
|
func mentionMemberName(_ name: String) -> String {
|
|
var n = 0
|
|
var tryName = name
|
|
while mentions[tryName] != nil {
|
|
n += 1
|
|
tryName = "\(name)_\(n)"
|
|
}
|
|
return tryName
|
|
}
|
|
|
|
var memberMentions: [String: Int64] {
|
|
var result: [String: Int64] = [:]
|
|
for ft in parsedMessage {
|
|
if result.count >= MAX_NUMBER_OF_MENTIONS { break }
|
|
if case let .mention(name) = ft.format, let id = mentions[name]?.memberRef?.groupMemberId { result[name] = id }
|
|
}
|
|
return result
|
|
}
|
|
|
|
var editing: Bool {
|
|
switch contextItem {
|
|
case .editingItem: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var quoting: Bool {
|
|
switch contextItem {
|
|
case .quotedItem: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var forwarding: Bool {
|
|
switch contextItem {
|
|
case .forwardingItems: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var reporting: Bool {
|
|
switch contextItem {
|
|
case .reportedItem: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var submittingValidReport: Bool {
|
|
switch contextItem {
|
|
case let .reportedItem(_, reason):
|
|
switch reason {
|
|
case .other: return !message.isEmpty
|
|
default: return true
|
|
}
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var sendEnabled: Bool {
|
|
switch preview {
|
|
case let .mediaPreviews(media): return !media.isEmpty
|
|
case .voicePreview: return voiceMessageRecordingState == .finished
|
|
case .chatLinkPreview: return true
|
|
case .filePreview: return true
|
|
default: return !whitespaceOnly || forwarding || liveMessage != nil || submittingValidReport
|
|
}
|
|
}
|
|
|
|
var endLiveDisabled: Bool {
|
|
liveMessage != nil && message.isEmpty && noPreview && !quoting
|
|
}
|
|
|
|
var linkPreviewAllowed: Bool {
|
|
switch preview {
|
|
case .chatLinkPreview: return false
|
|
case .mediaPreviews: return false
|
|
case .voicePreview: return false
|
|
case .filePreview: return false
|
|
default: return useLinkPreviews
|
|
}
|
|
}
|
|
|
|
var linkPreview: LinkPreview? {
|
|
switch preview {
|
|
case let .linkPreview(linkPreview): return linkPreview
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
var voiceMessageRecordingFileName: String? {
|
|
switch preview {
|
|
case let .voicePreview(recordingFileName: recordingFileName, _): return recordingFileName
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
var noPreview: Bool {
|
|
switch preview {
|
|
case .noPreview: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var voicePreview: Bool {
|
|
switch preview {
|
|
case .voicePreview: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
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 || reporting { return true }
|
|
switch preview {
|
|
case .noPreview: return false
|
|
case .linkPreview: return false
|
|
default: return true
|
|
}
|
|
}
|
|
|
|
var attachmentPreview: Bool {
|
|
switch preview {
|
|
case .noPreview: false
|
|
case .linkPreview: false
|
|
case .chatLinkPreview: false
|
|
case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty
|
|
case .voicePreview: false
|
|
case .filePreview: true
|
|
}
|
|
}
|
|
|
|
var placeholder: String? {
|
|
switch contextItem {
|
|
case let .reportedItem(_, reason):
|
|
return reason.text
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var empty: Bool {
|
|
whitespaceOnly && noPreview
|
|
}
|
|
|
|
var whitespaceOnly: Bool {
|
|
message.allSatisfy { $0.isWhitespace }
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#chatItemPreview
|
|
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
|
switch chatItem.content.msgContent {
|
|
case .text:
|
|
return .noPreview
|
|
case let .link(_, preview: preview):
|
|
return .linkPreview(linkPreview: preview)
|
|
case let .image(_, image):
|
|
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
|
case let .video(_, image, _):
|
|
return .mediaPreviews(mediaPreviews: [(image, nil)])
|
|
case let .voice(_, duration):
|
|
return .voicePreview(recordingFileName: chatItem.file?.fileName ?? "", duration: duration)
|
|
case .file:
|
|
let fileName = chatItem.file?.fileName ?? ""
|
|
return .filePreview(fileName: fileName, file: getAppFilePath(fileName))
|
|
default:
|
|
return .noPreview
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#UploadContent
|
|
enum UploadContent: Equatable {
|
|
case simpleImage(image: UIImage)
|
|
case animatedImage(image: UIImage)
|
|
case video(image: UIImage, url: URL, duration: Int)
|
|
|
|
var uiImage: UIImage {
|
|
switch self {
|
|
case let .simpleImage(image): return image
|
|
case let .animatedImage(image): return image
|
|
case let .video(image, _, _): return image
|
|
}
|
|
}
|
|
|
|
static func loadFromURL(url: URL) -> UploadContent? {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
if let image = UIImage(data: data) {
|
|
try image.setGifFromData(data, levelOfIntegrity: 1.0)
|
|
logger.log("UploadContent: added animated image")
|
|
return .animatedImage(image: image)
|
|
} else { return nil }
|
|
} catch {
|
|
do {
|
|
if let image = try UIImage(data: Data(contentsOf: url)) {
|
|
logger.log("UploadContent: added simple image")
|
|
return .simpleImage(image: image)
|
|
}
|
|
} catch {}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func loadVideoFromURL(url: URL) -> UploadContent? {
|
|
let asset = AVAsset(url: url)
|
|
if let (image, duration) = asset.generatePreview() {
|
|
return .video(image: image, url: url, duration: duration)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#ComposeView
|
|
struct ComposeView: View {
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@ObservedObject var chat: Chat
|
|
@ObservedObject var im: ItemsModel
|
|
@Binding var composeState: ComposeState
|
|
@Binding var showCommandsMenu: Bool
|
|
@Binding var keyboardVisible: Bool
|
|
@Binding var keyboardHiddenDate: Date
|
|
@Binding var selectedRange: NSRange
|
|
|
|
@State var linkUrl: String? = nil
|
|
@State var hasSimplexLink: Bool = false
|
|
@State var prevLinkUrl: String? = nil
|
|
@State var pendingLinkUrl: String? = nil
|
|
@State var cancelledLinks: Set<String> = []
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var showChooseSource = false
|
|
@State private var showMediaPicker = false
|
|
@State private var showTakePhoto = false
|
|
@State var chosenMedia: [UploadContent] = []
|
|
@State private var showFileImporter = false
|
|
|
|
@State private var audioRecorder: AudioRecorder?
|
|
@State private var voiceMessageRecordingTime: TimeInterval?
|
|
@State private var startingRecording: Bool = false
|
|
// for some reason voice message preview playback occasionally
|
|
// fails to stop on ComposeVoiceView.playbackMode().onDisappear,
|
|
// this is a workaround to fire an explicit event in certain cases
|
|
@State private var stopPlayback: Bool = false
|
|
|
|
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
|
|
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
|
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
|
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
|
|
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
|
|
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, store: groupDefaults) private var linkPreviewsShowAlert = true
|
|
@State private var updatingCompose = false
|
|
@State private var relayListExpanded = false
|
|
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
|
|
|
|
// Spec: spec/client/compose.md#body
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
|
|
if chat.chatInfo.nextConnectPrepared,
|
|
!composeState.inProgress,
|
|
let user = chatModel.currentUser {
|
|
ContextProfilePickerView(
|
|
chat: chat,
|
|
selectedUser: user
|
|
)
|
|
Divider()
|
|
}
|
|
|
|
let ownerState = ownerRelayState
|
|
let subscriberState = subscriberRelayState
|
|
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
|
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
|
|
if gInfo.membership.memberRole == .owner {
|
|
if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
|
|
ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
|
|
}
|
|
} else if let s = subscriberState {
|
|
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
|
|
let resolvedCount = s.connectedCount + s.removedCount + s.failedCount
|
|
if s.total == 0 || s.removedCount + s.failedCount > 0 || resolvedCount < s.total {
|
|
subscriberChannelRelayBar(
|
|
hostnames: s.hostnames,
|
|
relayMembers: s.relayMembers,
|
|
connectedCount: s.connectedCount,
|
|
removedCount: s.removedCount,
|
|
failedCount: s.failedCount,
|
|
total: s.total,
|
|
showProgress: showProgress
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
let userCantSendReason = chat.chatInfo.userCantSendReason(allRelaysBroken: (ownerState?.noActiveRelays ?? subscriberState?.noActiveRelays) ?? false)
|
|
let composeEnabled = (
|
|
userCantSendReason == nil ||
|
|
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) ||
|
|
(chat.chatInfo.contact?.nextAcceptContactRequest ?? false)
|
|
)
|
|
Group {
|
|
|
|
if let groupInfo = chat.chatInfo.groupInfo,
|
|
case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter,
|
|
case let .memberSupport(member) = groupScopeInfo,
|
|
let member = member,
|
|
member.memberPending,
|
|
composeState.contextItem == .noContextItem,
|
|
composeState.noPreview {
|
|
ContextPendingMemberActionsView(
|
|
groupInfo: groupInfo,
|
|
member: member
|
|
)
|
|
Divider()
|
|
}
|
|
|
|
if case let .reportedItem(_, reason) = composeState.contextItem {
|
|
reportReasonView(reason)
|
|
Divider()
|
|
}
|
|
// preference checks should match checks in forwarding list
|
|
let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
|
let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
|
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
|
let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited
|
|
if simplexLinkProhibited {
|
|
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
|
Divider()
|
|
} else if fileProhibited {
|
|
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
|
Divider()
|
|
} else if voiceProhibited {
|
|
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
|
Divider()
|
|
}
|
|
contextItemView()
|
|
switch (composeState.editing, composeState.preview) {
|
|
case (true, .filePreview): EmptyView()
|
|
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
|
default: previewView()
|
|
}
|
|
|
|
let contact = chat.chatInfo.contact
|
|
|
|
if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
|
|
if chat.chatInfo.groupInfo?.businessChat == nil {
|
|
let isChannel = chat.chatInfo.groupInfo?.useRelays == true
|
|
connectButtonView(
|
|
isChannel ? "Join channel" : "Join group",
|
|
icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.fill",
|
|
connect: connectPreparedGroup
|
|
)
|
|
} else {
|
|
sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup)
|
|
}
|
|
} else if contact?.nextSendGrpInv == true {
|
|
contextSendMessageToConnect("Send direct message to connect")
|
|
Divider()
|
|
HStack (alignment: .center) {
|
|
attachmentAndCommandsButtons().disabled(true)
|
|
sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
} else if let contact,
|
|
contact.nextConnectPrepared == true,
|
|
let linkType = contact.preparedContact?.uiConnLinkType {
|
|
switch linkType {
|
|
case .inv:
|
|
connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact)
|
|
case .con:
|
|
if contact.isBot {
|
|
connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact)
|
|
} else {
|
|
sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest)
|
|
}
|
|
}
|
|
} else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId {
|
|
ContextContactRequestActionsView(contactRequestId: crId)
|
|
} else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv {
|
|
ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv)
|
|
} else {
|
|
HStack (alignment: .center) {
|
|
attachmentAndCommandsButtons()
|
|
sendMessageView(
|
|
disableSendButton,
|
|
placeholder: chat.chatInfo.groupInfo.map { gi in
|
|
gi.useRelays && gi.membership.memberRole >= .owner && chat.chatInfo.groupChatScope() == nil
|
|
? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner")
|
|
: nil
|
|
} ?? nil
|
|
)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
} // Group
|
|
.disabled(!composeEnabled)
|
|
.if(!composeEnabled) { v in
|
|
v.onTapGesture {
|
|
if let reason = userCantSendReason {
|
|
AlertManager.shared.showAlertMsg(
|
|
title: "You can't send messages!",
|
|
message: reason.alertMessage
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background {
|
|
Color.clear
|
|
.overlay(ToolbarMaterial.material(toolbarMaterial))
|
|
.ignoresSafeArea(.all, edges: .bottom)
|
|
}
|
|
.onChange(of: composeState.message) { msg in
|
|
if updatingCompose {
|
|
updatingCompose = false
|
|
return
|
|
}
|
|
var parsedMsg = parseSimpleXMarkdown(msg)
|
|
if privacySanitizeLinks, let parsed = parsedMsg {
|
|
let r = sanitizeMessage(parsed)
|
|
if let sanitizedPos = r.sanitizedPos {
|
|
updatingCompose = true
|
|
composeState = composeState.copy(message: r.message, parsedMessage: r.parsedMsg)
|
|
if sanitizedPos < selectedRange.location {
|
|
selectedRange = NSRange(location: sanitizedPos, length: 0)
|
|
}
|
|
parsedMsg = r.parsedMsg
|
|
} else {
|
|
composeState = composeState.copy(parsedMessage: parsedMsg)
|
|
}
|
|
} else {
|
|
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
|
|
}
|
|
if composeState.linkPreviewAllowed && useLinkPreviews {
|
|
if !msg.isEmpty {
|
|
showLinkPreview(parsedMsg)
|
|
} else {
|
|
resetLinkPreview()
|
|
hasSimplexLink = false
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
} else {
|
|
resetLinkPreview()
|
|
hasSimplexLink = !msg.isEmpty && !chat.groupFeatureEnabled(.simplexLinks) && getMessageLinks(parsedMsg).hasSimplexLink
|
|
if composeState.linkPreviewAllowed {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
|
|
if !sendEnabled {
|
|
cancelCurrentVoiceRecording()
|
|
clearCurrentDraft()
|
|
clearState()
|
|
}
|
|
}
|
|
.onChange(of: composeState.inProgress) { inProgress in
|
|
if inProgress {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
composeState.progressByTimeout = composeState.inProgress
|
|
}
|
|
} else {
|
|
composeState.progressByTimeout = false
|
|
}
|
|
}
|
|
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
|
|
Button("Take picture") {
|
|
showTakePhoto = true
|
|
}
|
|
Button("Choose from library") {
|
|
showMediaPicker = true
|
|
}
|
|
if UIPasteboard.general.hasImages {
|
|
Button("Paste image") {
|
|
UIPasteboard.general.itemProviders.forEach { p in
|
|
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
|
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
|
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
|
chosenMedia.append(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Button("Choose file") {
|
|
showFileImporter = true
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $showTakePhoto) {
|
|
ZStack {
|
|
Color.black.edgesIgnoringSafeArea(.all)
|
|
CameraImageListPicker(images: $chosenMedia)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showMediaPicker) {
|
|
LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in
|
|
await MainActor.run {
|
|
showMediaPicker = false
|
|
if itemsSelected {
|
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: []))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: chosenMedia) { selected in
|
|
Task {
|
|
var media: [(String, UploadContent)] = []
|
|
for content in selected {
|
|
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
|
media.append((img, content))
|
|
await MainActor.run {
|
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: media))
|
|
}
|
|
}
|
|
}
|
|
if media.count == 0 {
|
|
await MainActor.run {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.fileImporter(
|
|
isPresented: $showFileImporter,
|
|
allowedContentTypes: [.data],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
if case let .success(files) = result, let fileURL = files.first {
|
|
do {
|
|
var fileSize: Int? = nil
|
|
if fileURL.startAccessingSecurityScopedResource() {
|
|
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
|
fileSize = resourceValues.fileSize
|
|
}
|
|
fileURL.stopAccessingSecurityScopedResource()
|
|
if let fileSize = fileSize,
|
|
fileSize <= maxFileSize {
|
|
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent, file: fileURL))
|
|
} else {
|
|
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: maxFileSize, countStyle: .binary)
|
|
AlertManager.shared.showAlertMsg(
|
|
title: "Large file!",
|
|
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
|
)
|
|
}
|
|
} catch {
|
|
logger.error("ComposeView fileImporter error \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if composeState.liveMessage != nil
|
|
&& (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) {
|
|
cancelCurrentVoiceRecording()
|
|
clearCurrentDraft()
|
|
sendMessage(ttl: nil)
|
|
resetLinkPreview()
|
|
} else if (composeState.inProgress) {
|
|
clearCurrentDraft()
|
|
} else if !composeState.empty {
|
|
if case .recording = composeState.voiceMessageRecordingState {
|
|
finishVoiceMessageRecording()
|
|
if let fileName = composeState.voiceMessageRecordingFileName {
|
|
chatModel.filesToDelete.insert(getAppFilePath(fileName))
|
|
}
|
|
}
|
|
if saveLastDraft {
|
|
saveCurrentDraft()
|
|
}
|
|
} else {
|
|
cancelCurrentVoiceRecording()
|
|
clearCurrentDraft()
|
|
clearState()
|
|
}
|
|
chatModel.removeLiveDummy(animated: false)
|
|
}
|
|
.onChange(of: chatModel.stopPreviousRecPlay) { _ in
|
|
if !startingRecording {
|
|
if composeState.voiceMessageRecordingState == .recording {
|
|
finishVoiceMessageRecording()
|
|
}
|
|
} else {
|
|
startingRecording = false
|
|
}
|
|
}
|
|
.onChange(of: chat.chatInfo.featureEnabled(.voice)) { vmAllowed in
|
|
if !vmAllowed && composeState.voicePreview,
|
|
let fileName = composeState.voiceMessageRecordingFileName {
|
|
cancelVoiceMessageRecording(fileName)
|
|
clearState()
|
|
}
|
|
}
|
|
.onAppear {
|
|
if case let .voicePreview(_, duration) = composeState.preview {
|
|
voiceMessageRecordingTime = TimeInterval(duration)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var ownerRelayState: (relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int, noActiveRelays: Bool)? {
|
|
guard let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
|
gInfo.membership.memberRole == .owner,
|
|
![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
|
|
else { return nil }
|
|
guard channelRelaysModel.groupId == gInfo.groupId else { return nil }
|
|
let relays = channelRelaysModel.groupRelays
|
|
guard !relays.isEmpty else { return ([], 0, 0, 0, true) }
|
|
let relayMembers = relays.map { relay in
|
|
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
|
|
}
|
|
let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count
|
|
let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .active && m?.activeConn?.connFailedErr == nil }.count
|
|
let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count
|
|
let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count
|
|
return (relays, activeCount, failedCount, removedCount, noActiveRelays)
|
|
}
|
|
|
|
private var subscriberRelayState: (hostnames: [String], relayMembers: [GMember], connectedCount: Int, removedCount: Int, failedCount: Int, total: Int, noActiveRelays: Bool)? {
|
|
guard let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
|
gInfo.membership.memberRole != .owner,
|
|
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
|
|
else { return nil }
|
|
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
|
|
let relayMembers = chatModel.groupMembers
|
|
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
|
|
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
|
|
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
|
|
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
|
|
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
|
|
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
|
let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
|
|
return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
|
|
}
|
|
|
|
private var disabledText: LocalizedStringKey? {
|
|
chat.chatInfo.userCantSendReason(allRelaysBroken: (ownerRelayState?.noActiveRelays ?? subscriberRelayState?.noActiveRelays) ?? false)?.composeLabel
|
|
}
|
|
|
|
@ViewBuilder private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int, removedCount: Int) -> some View {
|
|
let total = relays.count
|
|
let allBroken = activeCount == 0 && (failedCount + removedCount) == total
|
|
let sorted = relays.map { relay in
|
|
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
|
|
}.sorted { relayDisplayName($0.0) < relayDisplayName($1.0) }
|
|
VStack(spacing: 0) {
|
|
relayBarHeader {
|
|
if !allBroken && activeCount + failedCount + removedCount < total {
|
|
RelayProgressIndicator(active: activeCount, total: total)
|
|
}
|
|
if total == 0 {
|
|
Text("No relays")
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.orange)
|
|
} else if allBroken {
|
|
if removedCount == total {
|
|
Text("All relays removed")
|
|
} else if failedCount == total {
|
|
Text("All relays failed")
|
|
} else {
|
|
Text("No active relays")
|
|
}
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.orange)
|
|
} else if activeCount + failedCount + removedCount >= total {
|
|
if failedCount > 0 && removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel relay bar"), failedCount + removedCount))
|
|
} else if failedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel relay bar"), failedCount))
|
|
} else {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel relay bar"), removedCount))
|
|
}
|
|
} else if failedCount > 0 && removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d errors", comment: "channel relay bar"), activeCount, total, failedCount + removedCount))
|
|
} else if failedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar"), activeCount, total, failedCount))
|
|
} else if removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d removed", comment: "channel relay bar"), activeCount, total, removedCount))
|
|
} else {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total))
|
|
}
|
|
}
|
|
if relayListExpanded {
|
|
if allBroken {
|
|
Text("Add relays to restore message delivery.")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.font(.caption)
|
|
.foregroundColor(theme.colors.secondary)
|
|
.padding(.leading, 12)
|
|
.padding(.trailing)
|
|
.padding(.bottom, 4)
|
|
}
|
|
ForEach(sorted, id: \.0.id) { (relay, m) in
|
|
if let err = m?.activeConn?.connFailedErr {
|
|
Button {
|
|
showAlert(
|
|
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
|
message: err
|
|
)
|
|
} label: {
|
|
ownerRelayDetailRow(relay, connFailed: true, memberStatus: m?.memberStatus)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
ownerRelayDetailRow(relay, connFailed: false, memberStatus: m?.memberStatus)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, relayListExpanded ? 4 : 0)
|
|
.animation(nil, value: relayListExpanded)
|
|
}
|
|
|
|
private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool, memberStatus: GroupMemberStatus?) -> some View {
|
|
relayBarDetailRow {
|
|
Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary)
|
|
Spacer()
|
|
relayStatusIndicator(relay.relayStatus, connFailed: connFailed, memberStatus: memberStatus)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func subscriberChannelRelayBar(
|
|
hostnames: [String],
|
|
relayMembers: [GMember],
|
|
connectedCount: Int,
|
|
removedCount: Int,
|
|
failedCount: Int,
|
|
total: Int,
|
|
showProgress: Bool
|
|
) -> some View {
|
|
let errorCount = removedCount + failedCount
|
|
let allBroken = connectedCount == 0 && errorCount == total
|
|
VStack(spacing: 0) {
|
|
relayBarHeader {
|
|
if total == 0 {
|
|
Text("No relays")
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.orange)
|
|
} else if allBroken {
|
|
if removedCount == total {
|
|
Text("All relays removed")
|
|
} else if failedCount == total {
|
|
Text("All relays failed")
|
|
} else {
|
|
Text("No active relays")
|
|
}
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.foregroundColor(.orange)
|
|
} else {
|
|
if showProgress && connectedCount + errorCount < total {
|
|
RelayProgressIndicator(active: connectedCount, total: total)
|
|
}
|
|
if connectedCount + removedCount + failedCount >= total, removedCount + failedCount > 0 {
|
|
if failedCount > 0 && removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays not active", comment: "channel subscriber relay bar"), failedCount + removedCount))
|
|
} else if failedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays failed", comment: "channel subscriber relay bar"), failedCount))
|
|
} else if removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays removed", comment: "channel subscriber relay bar"), removedCount))
|
|
}
|
|
} else if failedCount > 0 && removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar"), connectedCount, total, errorCount))
|
|
} else if failedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d failed", comment: "channel subscriber relay bar"), connectedCount, total, failedCount))
|
|
} else if removedCount > 0 {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d removed", comment: "channel subscriber relay bar"), connectedCount, total, removedCount))
|
|
} else {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total))
|
|
}
|
|
}
|
|
}
|
|
if relayListExpanded {
|
|
if allBroken {
|
|
Text("Waiting for channel owner to add relays.")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.font(.caption)
|
|
.foregroundColor(theme.colors.secondary)
|
|
.padding(.leading, 12)
|
|
.padding(.trailing)
|
|
.padding(.bottom, 4)
|
|
}
|
|
if relayMembers.isEmpty {
|
|
ForEach(hostnames, id: \.self) { relay in
|
|
relayBarDetailRow {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay)))
|
|
.foregroundColor(theme.colors.secondary)
|
|
Spacer()
|
|
}
|
|
}
|
|
} else {
|
|
ForEach(relayMembers) { member in
|
|
let m = member.wrapped
|
|
let host = m.relayLink.map { hostFromRelayLink($0) }
|
|
let failedErr = m.activeConn?.connFailedErr
|
|
if let err = failedErr {
|
|
Button {
|
|
showAlert(
|
|
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
|
message: err
|
|
)
|
|
} label: {
|
|
subscriberRelayDetailRow(m, host: host, connFailed: true)
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
subscriberRelayDetailRow(m, host: host, connFailed: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, relayListExpanded ? 4 : 0)
|
|
.animation(nil, value: relayListExpanded)
|
|
}
|
|
|
|
private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View {
|
|
relayBarDetailRow {
|
|
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName))
|
|
.foregroundColor(theme.colors.secondary)
|
|
Spacer()
|
|
let status = relayConnStatus(m)
|
|
Circle()
|
|
.fill(status.color)
|
|
.frame(width: 8, height: 8)
|
|
Text(status.text)
|
|
.foregroundColor(theme.colors.secondary)
|
|
if connFailed {
|
|
Image(systemName: "exclamationmark.circle")
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relayBarHeader<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
Button {
|
|
withAnimation(nil) { relayListExpanded.toggle() }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
content()
|
|
Spacer()
|
|
Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(theme.colors.secondary)
|
|
.opacity(0.7)
|
|
}
|
|
.font(.callout)
|
|
.foregroundColor(theme.colors.secondary)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, relayListExpanded ? 4 : 8)
|
|
.padding(.leading, 12)
|
|
.padding(.trailing)
|
|
}
|
|
}
|
|
|
|
private func relayBarDetailRow<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
HStack {
|
|
content()
|
|
}
|
|
.font(.caption)
|
|
.padding(.leading, 12)
|
|
.padding(.trailing)
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
|
|
private func relayMemberRemoved(_ status: GroupMemberStatus?) -> Bool {
|
|
status.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
|
|
}
|
|
|
|
private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View {
|
|
Button(action: connect) {
|
|
ZStack(alignment: .trailing) {
|
|
Label(label, systemImage: icon)
|
|
.frame(maxWidth: .infinity)
|
|
if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true {
|
|
ProgressView()
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 57)
|
|
.disabled(composeState.inProgress)
|
|
}
|
|
|
|
private func sendContactRequestView(_ disableSendButton: Bool, icon: String, sendRequest: @escaping () -> Void) -> some View {
|
|
HStack (alignment: .center) {
|
|
sendMessageView(
|
|
disableSendButton,
|
|
placeholder: NSLocalizedString("Add message", comment: "placeholder for sending contact request"),
|
|
sendToConnect: sendRequest
|
|
)
|
|
if composeState.whitespaceOnly {
|
|
Button(action: sendRequest) {
|
|
HStack {
|
|
Text("Connect").fontWeight(.medium)
|
|
Image(systemName: icon)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.disabled(composeState.inProgress)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#sendMessageView
|
|
private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View {
|
|
ZStack(alignment: .leading) {
|
|
SendMessageView(
|
|
placeholder: disabledText != nil ? nil : placeholder,
|
|
composeState: $composeState,
|
|
selectedRange: $selectedRange,
|
|
sendMessage: { ttl in
|
|
sendMessage(ttl: ttl)
|
|
resetLinkPreview()
|
|
},
|
|
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
|
|
updateLiveMessage: updateLiveMessage,
|
|
cancelLiveMessage: {
|
|
composeState.liveMessage = nil
|
|
chatModel.removeLiveDummy()
|
|
},
|
|
sendToConnect: sendToConnect,
|
|
hideSendButton: chat.chatInfo.nextConnect && chat.chatInfo.contact?.nextSendGrpInv != true && composeState.whitespaceOnly,
|
|
voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice),
|
|
disableSendButton: disableSendButton,
|
|
showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert,
|
|
startVoiceMessageRecording: {
|
|
Task {
|
|
await startVoiceMessageRecording()
|
|
}
|
|
},
|
|
finishVoiceMessageRecording: finishVoiceMessageRecording,
|
|
allowVoiceMessagesToContact: allowVoiceMessagesToContact,
|
|
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
|
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
|
keyboardVisible: $keyboardVisible,
|
|
keyboardHiddenDate: $keyboardHiddenDate,
|
|
sendButtonColor: chat.chatInfo.incognito
|
|
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
|
: theme.colors.primary
|
|
)
|
|
.disabled(!chat.chatInfo.sendMsgEnabled)
|
|
|
|
if let disabledText {
|
|
Text(disabledText)
|
|
.italic()
|
|
.foregroundColor(theme.colors.secondary)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func attachmentAndCommandsButtons() -> some View {
|
|
let msg = composeState.message.trimmingCharacters(in: .whitespaces)
|
|
let showAttachment = chat.chatInfo.contact?.profile.peerType != .bot || chat.chatInfo.featureEnabled(.files)
|
|
let showCommands = chat.chatInfo.useCommands && (!showAttachment || msg.isEmpty || msg.starts(with: "/"))
|
|
if showCommands {
|
|
commandsButton()
|
|
}
|
|
if showAttachment {
|
|
attachmentButton()
|
|
.padding(.trailing, 3)
|
|
.if(showCommands) { v in v.padding(.leading, 3) }
|
|
}
|
|
}
|
|
|
|
private func commandsButton() -> some View {
|
|
Button {
|
|
showCommandsMenu.toggle()
|
|
} label: {
|
|
Text(verbatim: "//")
|
|
.font(.title3)
|
|
.italic()
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(!chat.chatInfo.sendMsgEnabled || chat.chatInfo.menuCommands.isEmpty)
|
|
.frame(width: 25, height: 25)
|
|
.tint(theme.colors.primary)
|
|
.padding(.bottom, 2)
|
|
}
|
|
|
|
@ViewBuilder private func attachmentButton() -> some View {
|
|
let b = Button {
|
|
showChooseSource = true
|
|
} label: {
|
|
Image(systemName: "paperclip")
|
|
.resizable()
|
|
}
|
|
.disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled)
|
|
.frame(width: 25, height: 25)
|
|
.tint(theme.colors.primary)
|
|
if im.secondaryIMFilter == nil,
|
|
!chat.chatInfo.featureEnabled(.files) {
|
|
b.disabled(true).onTapGesture {
|
|
AlertManager.shared.showAlertMsg(
|
|
title: "Files and media prohibited!",
|
|
message: chat.chatInfo.groupInfo == nil ? nil : "Only group owners can enable files and media."
|
|
)
|
|
}
|
|
} else {
|
|
b
|
|
}
|
|
}
|
|
|
|
private func sendMemberContactInvitation() {
|
|
Task {
|
|
do {
|
|
await MainActor.run { hideKeyboard() }
|
|
if let mc = connectCheckLinkPreview() {
|
|
await sending()
|
|
let contact = try await apiSendMemberContactInvitation(chat.chatInfo.apiId, mc)
|
|
await MainActor.run {
|
|
self.chatModel.updateContact(contact)
|
|
clearState()
|
|
}
|
|
} else {
|
|
AlertManager.shared.showAlertMsg(title: "Empty message!")
|
|
}
|
|
} catch {
|
|
await MainActor.run { composeState.inProgress = false }
|
|
logger.error("ChatView.sendMemberContactInvitation error: \(error.localizedDescription)")
|
|
AlertManager.shared.showAlertMsg(title: "Error sending member contact invitation", message: "Error: \(responseError(error))")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendConnectPreparedContactRequest() {
|
|
hideKeyboard()
|
|
let empty = composeState.whitespaceOnly
|
|
AlertManager.shared.showAlert(Alert(
|
|
title: Text("Send contact request?"),
|
|
message: Text("You will be able to send messages **only after your request is accepted**."),
|
|
primaryButton: .default(
|
|
Text(empty ? "Send request without message" : "Send request"),
|
|
action: sendConnectPreparedContact
|
|
),
|
|
secondaryButton:
|
|
empty
|
|
? .cancel(Text("Add message"), action: hideKeyboard)
|
|
: .cancel()
|
|
))
|
|
}
|
|
|
|
private func sendConnectPreparedContact() {
|
|
Task {
|
|
await MainActor.run { hideKeyboard() }
|
|
await sending()
|
|
let mc = connectCheckLinkPreview()
|
|
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
|
|
if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
|
|
await MainActor.run {
|
|
self.chatModel.updateContact(contact)
|
|
clearState()
|
|
}
|
|
} else {
|
|
await MainActor.run { composeState.inProgress = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectPreparedGroup() {
|
|
Task {
|
|
await MainActor.run { hideKeyboard() }
|
|
await sending()
|
|
let mc = connectCheckLinkPreview()
|
|
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
|
|
if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
|
|
await MainActor.run {
|
|
self.chatModel.updateGroup(groupInfo)
|
|
self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId)
|
|
self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) }
|
|
self.chatModel.populateGroupMembersIndexes()
|
|
clearState()
|
|
}
|
|
} else {
|
|
await MainActor.run { composeState.inProgress = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
@inline(__always)
|
|
private func connectCheckLinkPreview() -> MsgContent? {
|
|
let msgText = composeState.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return msgText.isEmpty ? nil : checkLinkPreview_(msgText)
|
|
}
|
|
|
|
@inline(__always)
|
|
private func checkLinkPreview() -> MsgContent {
|
|
checkLinkPreview_(composeState.message.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
|
|
private func checkLinkPreview_(_ msgText: String) -> MsgContent {
|
|
switch (composeState.preview) {
|
|
case let .linkPreview(linkPreview: linkPreview):
|
|
if let parsedMsg = parseSimpleXMarkdown(msgText),
|
|
let url = getMessageLinks(parsedMsg).url,
|
|
let linkPreview = linkPreview,
|
|
url == linkPreview.uri {
|
|
return .link(text: msgText, preview: linkPreview)
|
|
} else {
|
|
return .text(msgText)
|
|
}
|
|
default:
|
|
return .text(msgText)
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#addMediaContent
|
|
private func addMediaContent(_ content: UploadContent) async {
|
|
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
|
var newMedia: [(String, UploadContent?)] = []
|
|
if case var .mediaPreviews(media) = composeState.preview {
|
|
media.append((img, content))
|
|
newMedia = media
|
|
} else {
|
|
newMedia = [(img, content)]
|
|
}
|
|
await MainActor.run {
|
|
composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia))
|
|
}
|
|
}
|
|
}
|
|
|
|
// When error occurs while converting video, remove media preview
|
|
private func finishedPreprocessingMediaContent() {
|
|
if case let .mediaPreviews(media) = composeState.preview, media.isEmpty {
|
|
DispatchQueue.main.async {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var maxFileSize: Int64 {
|
|
// the user's active badge raises the limit, but not in incognito chats where no badge is presented
|
|
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
|
|
return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#sendLiveMessage
|
|
private func sendLiveMessage() async {
|
|
let typedMsg = composeState.message
|
|
let lm = composeState.liveMessage
|
|
if (composeState.sendEnabled || composeState.quoting)
|
|
&& (lm == nil || lm?.sentMsg == nil),
|
|
let ci = await sendMessageAsync(typedMsg, live: true, ttl: nil) {
|
|
await MainActor.run {
|
|
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg))
|
|
}
|
|
} else if lm == nil {
|
|
let cItem = chatModel.addLiveDummy(chat.chatInfo)
|
|
await MainActor.run {
|
|
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: cItem, typedMsg: typedMsg, sentMsg: nil))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#updateLiveMessage
|
|
private func updateLiveMessage() async {
|
|
let typedMsg = composeState.message
|
|
if let liveMessage = composeState.liveMessage {
|
|
if let sentMsg = liveMessageToSend(liveMessage, typedMsg),
|
|
let ci = await sendMessageAsync(sentMsg, live: true, ttl: nil) {
|
|
await MainActor.run {
|
|
composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg))
|
|
}
|
|
} else if liveMessage.typedMsg != typedMsg {
|
|
await MainActor.run {
|
|
var lm = liveMessage
|
|
lm.typedMsg = typedMsg
|
|
composeState = composeState.copy(liveMessage: lm)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#liveMessageToSend
|
|
private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? {
|
|
let s = t != lm.typedMsg ? truncateToWords(t) : t
|
|
return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil
|
|
}
|
|
|
|
private func truncateToWords(_ s: String) -> String {
|
|
var acc = ""
|
|
var word = ""
|
|
for c in s {
|
|
if c.isLetter || c.isNumber {
|
|
word = word + String(c)
|
|
} else {
|
|
acc = acc + word + String(c)
|
|
word = ""
|
|
}
|
|
}
|
|
return acc
|
|
}
|
|
|
|
@ViewBuilder func previewView() -> some View {
|
|
switch composeState.preview {
|
|
case .noPreview:
|
|
EmptyView()
|
|
case let .linkPreview(linkPreview: preview):
|
|
ComposeLinkView(
|
|
linkPreview: preview,
|
|
cancelPreview: cancelLinkPreview,
|
|
cancelEnabled: !composeState.inProgress
|
|
)
|
|
Divider()
|
|
case let .chatLinkPreview(chatLink, _):
|
|
ComposeChatLinkView(
|
|
chatLink: chatLink,
|
|
cancelPreview: {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
},
|
|
cancelEnabled: !composeState.inProgress
|
|
)
|
|
Divider()
|
|
case let .mediaPreviews(mediaPreviews: media):
|
|
ComposeImageView(
|
|
images: media.map { (img, _) in img },
|
|
cancelImage: {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
chosenMedia = []
|
|
},
|
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
|
Divider()
|
|
case let .voicePreview(recordingFileName, _):
|
|
ComposeVoiceView(
|
|
recordingFileName: recordingFileName,
|
|
recordingTime: $voiceMessageRecordingTime,
|
|
recordingState: $composeState.voiceMessageRecordingState,
|
|
cancelVoiceMessage: {
|
|
cancelVoiceMessageRecording($0)
|
|
clearState()
|
|
},
|
|
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
|
stopPlayback: $stopPlayback
|
|
)
|
|
Divider()
|
|
case let .filePreview(fileName, _):
|
|
ComposeFileView(
|
|
fileName: fileName,
|
|
cancelFile: {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
},
|
|
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
|
Divider()
|
|
}
|
|
}
|
|
|
|
private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View {
|
|
HStack {
|
|
Image(systemName: icon).foregroundColor(theme.colors.secondary)
|
|
Text(reason).italic()
|
|
}
|
|
.padding(12)
|
|
.frame(minHeight: 54)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial)
|
|
}
|
|
|
|
private func contextSendMessageToConnect(_ s: LocalizedStringKey) -> some View {
|
|
HStack {
|
|
Image(systemName: "message")
|
|
.foregroundColor(theme.colors.secondary)
|
|
Text(s)
|
|
}
|
|
.padding(12)
|
|
.frame(minHeight: 54)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(ToolbarMaterial.material(toolbarMaterial))
|
|
}
|
|
|
|
private func reportReasonView(_ reason: ReportReason) -> some View {
|
|
let reportText = switch reason {
|
|
case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
|
|
case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
|
|
case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
|
|
case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
|
|
case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
|
|
case .unknown: "" // Should never happen
|
|
}
|
|
|
|
return Text(reportText)
|
|
.italic()
|
|
.font(.caption)
|
|
.padding(12)
|
|
.frame(minHeight: 44)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.thinMaterial)
|
|
}
|
|
|
|
|
|
@ViewBuilder private func contextItemView() -> some View {
|
|
switch composeState.contextItem {
|
|
case .noContextItem:
|
|
EmptyView()
|
|
case let .quotedItem(chatItem: quotedItem):
|
|
ContextItemView(
|
|
chat: chat,
|
|
contextItems: [quotedItem],
|
|
contextIcon: "arrowshape.turn.up.left",
|
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
|
)
|
|
Divider()
|
|
case let .editingItem(chatItem: editingItem):
|
|
ContextItemView(
|
|
chat: chat,
|
|
contextItems: [editingItem],
|
|
contextIcon: "pencil",
|
|
cancelContextItem: { clearState() }
|
|
)
|
|
Divider()
|
|
case let .forwardingItems(chatItems, _):
|
|
ContextItemView(
|
|
chat: chat,
|
|
contextItems: chatItems,
|
|
contextIcon: "arrowshape.turn.up.forward",
|
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
|
)
|
|
Divider()
|
|
case let .reportedItem(chatItem: reportedItem, _):
|
|
ContextItemView(
|
|
chat: chat,
|
|
contextItems: [reportedItem],
|
|
contextIcon: "flag",
|
|
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
|
contextIconForeground: Color.red
|
|
)
|
|
Divider()
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#sendMessage
|
|
private func sendMessage(ttl: Int?) {
|
|
logger.debug("ChatView sendMessage")
|
|
Task {
|
|
logger.debug("ChatView sendMessage: in Task")
|
|
_ = await sendMessageAsync(nil, live: false, ttl: ttl)
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#sendMessageAsync
|
|
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
|
|
var sent: ChatItem?
|
|
let msgText = text ?? composeState.message
|
|
let liveMessage = composeState.liveMessage
|
|
let mentions = composeState.memberMentions
|
|
if !live {
|
|
if liveMessage != nil { composeState = composeState.copy(liveMessage: nil) }
|
|
await sending()
|
|
}
|
|
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 {
|
|
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
|
|
}
|
|
} else if case let .editingItem(ci) = composeState.contextItem {
|
|
sent = await updateMessage(ci, live: live)
|
|
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
|
sent = await updateMessage(liveMessage.chatItem, live: live)
|
|
} else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
|
|
sent = await send(reason, chatItemId: chatItem.id)
|
|
} else {
|
|
var quoted: Int64? = nil
|
|
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
|
|
quoted = quotedItem.id
|
|
}
|
|
|
|
switch (composeState.preview) {
|
|
case .noPreview:
|
|
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
|
|
case .linkPreview:
|
|
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
|
|
case let .chatLinkPreview(chatLink, ownerSig):
|
|
let linkStr = chatLink.connLinkStr
|
|
let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr
|
|
sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
|
|
case let .mediaPreviews(media):
|
|
// TODO: CHECK THIS
|
|
let last = media.count - 1
|
|
var msgs: [ComposedMessage] = []
|
|
if last >= 0 {
|
|
for i in 0..<last {
|
|
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))
|
|
}
|
|
}
|
|
if let (fileSource, msgContent) = mediaContent(media[last], text: msgText) {
|
|
msgs.append(ComposedMessage(fileSource: fileSource, quotedItemId: quoted, msgContent: msgContent))
|
|
}
|
|
}
|
|
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)
|
|
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
|
|
case let .filePreview(_, file):
|
|
if let savedFile = saveFileFromURL(file) {
|
|
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
|
|
}
|
|
}
|
|
}
|
|
await MainActor.run {
|
|
let wasForwarding = composeState.forwarding
|
|
clearState(live: live)
|
|
if wasForwarding,
|
|
chatModel.draftChatId == chat.chatInfo.id,
|
|
let draft = chatModel.draft {
|
|
composeState = draft
|
|
}
|
|
}
|
|
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 updateMessage(_ ei: ChatItem, live: Bool) async -> ChatItem? {
|
|
if let oldMsgContent = ei.content.msgContent {
|
|
do {
|
|
let mc = updateMsgContent(oldMsgContent)
|
|
if mc != oldMsgContent || (ei.meta.itemLive ?? false) {
|
|
let chatItem = try await apiUpdateChatItem(
|
|
type: chat.chatInfo.chatType,
|
|
id: chat.chatInfo.apiId,
|
|
scope: chat.chatInfo.groupChatScope(),
|
|
itemId: ei.id,
|
|
updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions),
|
|
live: live
|
|
)
|
|
await MainActor.run {
|
|
_ = self.chatModel.upsertChatItem(self.chat.chatInfo, chatItem)
|
|
}
|
|
return chatItem
|
|
} else {
|
|
return nil
|
|
}
|
|
} catch {
|
|
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
|
AlertManager.shared.showAlertMsg(title: "Error updating message", message: "Error: \(responseError(error))")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
|
switch msgContent {
|
|
case .text:
|
|
return checkLinkPreview()
|
|
case .link:
|
|
return checkLinkPreview()
|
|
case .image(_, let image):
|
|
return .image(text: msgText, image: image)
|
|
case .video(_, let image, let duration):
|
|
return .video(text: msgText, image: image, duration: duration)
|
|
case .voice(_, let duration):
|
|
return .voice(text: msgText, duration: duration)
|
|
case .file:
|
|
return .file(msgText)
|
|
case .report(_, let reason):
|
|
return .report(text: msgText, reason: reason)
|
|
case let .chat(_, chatLink, ownerSig):
|
|
let text = msgText.isEmpty ? chatLink.connLinkStr : msgText + "\n" + chatLink.connLinkStr
|
|
return .chat(text: text, chatLink: chatLink, ownerSig: ownerSig)
|
|
case .unknown(let type, _):
|
|
return .unknown(type: type, text: msgText)
|
|
}
|
|
}
|
|
|
|
func voiceCryptoFile(_ fileName: String) -> CryptoFile? {
|
|
if !privacyEncryptLocalFilesGroupDefault.get() {
|
|
return CryptoFile.plain(fileName)
|
|
}
|
|
let url = getAppFilePath(fileName)
|
|
let toFile = generateNewFileName("voice", "m4a")
|
|
let toUrl = getAppFilePath(toFile)
|
|
if let cfArgs = try? encryptCryptoFile(fromPath: url.path, toPath: toUrl.path) {
|
|
removeFile(url)
|
|
return CryptoFile(filePath: toFile, cryptoArgs: cfArgs)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
|
|
if let chatItems = await apiReportMessage(
|
|
groupId: chat.chatInfo.apiId,
|
|
chatItemId: chatItemId,
|
|
reportReason: reportReason,
|
|
reportText: msgText
|
|
) {
|
|
if showReportsInSupportChatAlertDefault.get() {
|
|
await MainActor.run {
|
|
showReportsInSupportChatAlert()
|
|
}
|
|
}
|
|
return chatItems.first
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func showReportsInSupportChatAlert() {
|
|
showAlert(
|
|
NSLocalizedString("Report sent to moderators", comment: "alert title"),
|
|
message: NSLocalizedString("You can view your reports in Chat with admins.", comment: "alert message"),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Don't show again", comment: "alert action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
showReportsInSupportChatAlertDefault.set(false)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Ok", comment: "alert action"),
|
|
style: .default
|
|
)
|
|
]}
|
|
)
|
|
}
|
|
|
|
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
|
|
await send(
|
|
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
|
|
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: msgs)
|
|
: await apiSendMessages(
|
|
type: chat.chatInfo.chatType,
|
|
id: chat.chatInfo.apiId,
|
|
scope: chat.chatInfo.groupChatScope(),
|
|
sendAsGroup: chat.chatInfo.sendAsGroup,
|
|
live: live,
|
|
ttl: ttl,
|
|
composedMessages: msgs
|
|
) {
|
|
await MainActor.run {
|
|
chatModel.removeLiveDummy(animated: false)
|
|
for chatItem in chatItems {
|
|
chatModel.addChatItem(chat.chatInfo, chatItem)
|
|
}
|
|
}
|
|
return chatItems
|
|
}
|
|
for msg in msgs {
|
|
if let file = msg.fileSource {
|
|
removeFile(file.filePath)
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
func forwardItems(_ forwardedItems: [ChatItem], _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> [ChatItem] {
|
|
if let chatItems = await apiForwardChatItems(
|
|
toChatType: chat.chatInfo.chatType,
|
|
toChatId: chat.chatInfo.apiId,
|
|
toScope: chat.chatInfo.groupChatScope(),
|
|
sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
|
|
fromChatType: fromChatInfo.chatType,
|
|
fromChatId: fromChatInfo.apiId,
|
|
fromScope: fromChatInfo.groupChatScope(),
|
|
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")
|
|
)
|
|
}
|
|
}
|
|
return chatItems
|
|
} else {
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
func sending() async {
|
|
await MainActor.run { composeState.inProgress = true }
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#startVoiceMessageRecording
|
|
private func startVoiceMessageRecording() async {
|
|
startingRecording = true
|
|
let fileName = generateNewFileName("voice", "m4a")
|
|
chatModel.stopPreviousRecPlay = getAppFilePath(fileName)
|
|
audioRecorder = AudioRecorder(
|
|
onTimer: { voiceMessageRecordingTime = $0 },
|
|
onFinishRecording: {
|
|
updateComposeVMRFinished()
|
|
if let fileSize = fileSize(getAppFilePath(fileName)) {
|
|
logger.debug("onFinishRecording recording file size = \(fileSize)")
|
|
}
|
|
}
|
|
)
|
|
if let recStartError = await audioRecorder?.start(fileName: fileName) {
|
|
switch recStartError {
|
|
case .permission:
|
|
AlertManager.shared.showAlert(Alert(
|
|
title: Text("No permission to record voice message"),
|
|
message: Text("To record voice message please grant permission to use Microphone."),
|
|
primaryButton: .default(Text("Open Settings")) {
|
|
DispatchQueue.main.async {
|
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
|
}
|
|
},
|
|
secondaryButton: .cancel()
|
|
))
|
|
case let .error(error):
|
|
AlertManager.shared.showAlertMsg(
|
|
title: "Unable to record voice message",
|
|
message: "Error: \(error)"
|
|
)
|
|
}
|
|
} else {
|
|
composeState = composeState.copy(
|
|
preview: .voicePreview(recordingFileName: fileName, duration: 0),
|
|
voiceMessageRecordingState: .recording
|
|
)
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#finishVoiceMessageRecording
|
|
private func finishVoiceMessageRecording() {
|
|
audioRecorder?.stop()
|
|
audioRecorder = nil
|
|
updateComposeVMRFinished()
|
|
if let fileName = composeState.voiceMessageRecordingFileName,
|
|
let fileSize = fileSize(getAppFilePath(fileName)) {
|
|
logger.debug("finishVoiceMessageRecording recording file size = \(fileSize)")
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#allowVoiceMessagesToContact
|
|
private func allowVoiceMessagesToContact() {
|
|
if case let .direct(contact) = chat.chatInfo {
|
|
allowFeatureToContact(contact, .voice)
|
|
}
|
|
}
|
|
|
|
// ? maybe we shouldn't have duration in ComposePreview.voicePreview
|
|
private func updateComposeVMRFinished() {
|
|
var preview = composeState.preview
|
|
if let recordingFileName = composeState.voiceMessageRecordingFileName,
|
|
let recordingTime = voiceMessageRecordingTime {
|
|
preview = .voicePreview(recordingFileName: recordingFileName, duration: Int(recordingTime.rounded()))
|
|
}
|
|
composeState = composeState.copy(
|
|
preview: preview,
|
|
voiceMessageRecordingState: .finished
|
|
)
|
|
}
|
|
|
|
private func cancelCurrentVoiceRecording() {
|
|
if let fileName = composeState.voiceMessageRecordingFileName {
|
|
cancelVoiceMessageRecording(fileName)
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#cancelVoiceMessageRecording
|
|
private func cancelVoiceMessageRecording(_ fileName: String) {
|
|
stopPlayback.toggle()
|
|
audioRecorder?.stop()
|
|
removeFile(fileName)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#clearState
|
|
private func clearState(live: Bool = false) {
|
|
if live {
|
|
composeState.inProgress = false
|
|
} else {
|
|
composeState = ComposeState()
|
|
resetLinkPreview()
|
|
}
|
|
chosenMedia = []
|
|
audioRecorder = nil
|
|
voiceMessageRecordingTime = nil
|
|
startingRecording = false
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#saveCurrentDraft
|
|
private func saveCurrentDraft() {
|
|
chatModel.draft = composeState
|
|
chatModel.draftChatId = chat.id
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#clearCurrentDraft
|
|
private func clearCurrentDraft() {
|
|
if chatModel.draftChatId == chat.id {
|
|
chatModel.draft = nil
|
|
chatModel.draftChatId = nil
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#showLinkPreview
|
|
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
|
|
prevLinkUrl = linkUrl
|
|
(linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
|
|
if let url = linkUrl {
|
|
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
|
|
pendingLinkUrl = url
|
|
if prevLinkUrl == url {
|
|
loadLinkPreview(url)
|
|
} else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
loadLinkPreview(url)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#getMessageLinks
|
|
private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) {
|
|
guard let parsedMsg else { return (nil, false) }
|
|
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
|
|
for ft in parsedMsg {
|
|
if let link = ft.linkUri, !cancelledLinks.contains(link) && !isSimplexLink(link) {
|
|
return (link, simplexLink)
|
|
}
|
|
}
|
|
return (nil, simplexLink)
|
|
}
|
|
|
|
private func isSimplexLink(_ link: String) -> Bool {
|
|
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") || link.starts(with: "simplex:/")
|
|
}
|
|
|
|
private func cancelLinkPreview() {
|
|
if let pendingLink = pendingLinkUrl {
|
|
cancelledLinks.insert(pendingLink)
|
|
}
|
|
if let uri = composeState.linkPreview?.uri {
|
|
cancelledLinks.insert(uri)
|
|
}
|
|
pendingLinkUrl = nil
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
|
|
// Spec: spec/client/compose.md#loadLinkPreview
|
|
private func loadLinkPreview(_ urlStr: String) {
|
|
if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
|
|
if linkPreviewsShowAlert {
|
|
showLinkPreviewsConfirmAlert { enable in
|
|
if let enable {
|
|
linkPreviewsShowAlert = false
|
|
useLinkPreviews = enable
|
|
UserDefaults.standard.set(enable, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
|
|
if enable {
|
|
fetchLinkPreview(url, urlStr: urlStr)
|
|
} else {
|
|
pendingLinkUrl = nil
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
} else {
|
|
cancelLinkPreview()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
fetchLinkPreview(url, urlStr: urlStr)
|
|
}
|
|
}
|
|
|
|
private func fetchLinkPreview(_ url: URL, urlStr: String) {
|
|
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
|
|
getLinkPreview(url: url) { linkPreview in
|
|
if let linkPreview, pendingLinkUrl == urlStr {
|
|
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
|
|
} else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
composeState = composeState.copy(preview: .noPreview)
|
|
}
|
|
}
|
|
pendingLinkUrl = nil
|
|
}
|
|
}
|
|
|
|
private func showLinkPreviewsConfirmAlert(onChoice: @escaping (Bool?) -> Void) {
|
|
showAlert(
|
|
NSLocalizedString("Enable link previews?", comment: "alert title"),
|
|
message: NSLocalizedString("Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.", comment: "alert message"),
|
|
actions: {
|
|
[
|
|
UIAlertAction(title: NSLocalizedString("Disable", comment: "alert button"), style: .destructive) { _ in onChoice(false) },
|
|
UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in onChoice(true) }
|
|
]
|
|
}
|
|
)
|
|
}
|
|
|
|
private func resetLinkPreview() {
|
|
linkUrl = nil
|
|
prevLinkUrl = nil
|
|
pendingLinkUrl = nil
|
|
cancelledLinks = []
|
|
}
|
|
}
|
|
|
|
func sanitizeMessage(_ parsedMsg: [FormattedText]) -> (message: String, parsedMsg: [FormattedText], sanitizedPos: Int?) {
|
|
var pos: Int = 0
|
|
var updatedMsg = ""
|
|
var sanitizedPos: Int? = nil
|
|
let updatedParsedMsg = parsedMsg.map { ft in
|
|
var updated = ft
|
|
switch ft.format {
|
|
case .uri:
|
|
if let sanitized = parseSanitizeUri(ft.text, safe: true)?.uriInfo?.sanitized {
|
|
updated = FormattedText(text: sanitized, format: .uri)
|
|
pos += updated.text.count
|
|
sanitizedPos = pos
|
|
}
|
|
case let .hyperLink(text, uri):
|
|
if let sanitized = parseSanitizeUri(uri, safe: true)?.uriInfo?.sanitized {
|
|
let updatedText = if let text { "[\(text)](\(sanitized))" } else { sanitized }
|
|
updated = FormattedText(text: updatedText, format: .hyperLink(showText: text, linkUri: sanitized))
|
|
pos += updated.text.count
|
|
sanitizedPos = pos
|
|
}
|
|
default:
|
|
pos += ft.text.count
|
|
}
|
|
updatedMsg += updated.text
|
|
return updated
|
|
}
|
|
return (message: updatedMsg, parsedMsg: updatedParsedMsg, sanitizedPos: sanitizedPos)
|
|
}
|