diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 03458818..a48ee273 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -803,6 +803,9 @@ } } } + }, + "%@ dBm" : { + }, "%@, %@" : { "localizations" : { @@ -1086,6 +1089,16 @@ } } }, + "%d %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d %2$@" + } + } + } + }, "%d Hops" : { "localizations" : { "en" : { @@ -2569,6 +2582,9 @@ } } } + }, + "Ack SNR" : { + }, "Ack SNR: %@ dB" : { "localizations" : { @@ -2603,6 +2619,9 @@ } } } + }, + "Ack Time" : { + }, "Ack Time: %@" : { "localizations" : { @@ -11870,6 +11889,9 @@ } } } + }, + "Delivered" : { + }, "Description" : { "localizations" : { @@ -18709,6 +18731,9 @@ } } } + }, + "From" : { + }, "From Radio (RX): %lld" : { "localizations" : { @@ -21544,6 +21569,9 @@ } } } + }, + "In Reply To" : { + }, "Include" : { "localizations" : { @@ -24976,6 +25004,12 @@ } } } + }, + "Message ID" : { + + }, + "Message Info" : { + }, "Message received from the text message app." : { "extractionState" : "stale", @@ -25104,6 +25138,9 @@ } } } + }, + "Message Text" : { + }, "Messages" : { "localizations" : { @@ -30346,6 +30383,9 @@ } } } + }, + "Pending..." : { + }, "Perform a factory reset on the node you are connected to" : { "localizations" : { @@ -33153,6 +33193,9 @@ } } } + }, + "Read" : { + }, "Reboot" : { "localizations" : { @@ -33830,6 +33873,12 @@ } } } + }, + "Relay" : { + + }, + "Relayed by" : { + }, "Relayed by %d %@" : { "localizations" : { @@ -35727,6 +35776,9 @@ } } } + }, + "RSSI" : { + }, "RSSI %@ dBm" : { "localizations" : { @@ -41604,7 +41656,6 @@ } }, "TAK" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -45497,6 +45548,10 @@ } } }, + "Type an emoji to send as a tapback" : { + "comment" : "A description below the text field in the tapback picker view, instructing the user to type an emoji.", + "isCommentAutoGenerated" : true + }, "UDP Broadcast" : { "localizations" : { "it" : { @@ -49740,4 +49795,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 1544d0eb..29f6e0b9 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; }; + BC4E1A742F4BA29C0065501B /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = EXYTECHAT123456789ABCD2 /* ExyteChat */; }; BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; }; BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA9A82B2EC802CF00166292 /* CompassView.swift */; }; BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */; }; @@ -715,6 +716,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BC4E1A742F4BA29C0065501B /* ExyteChat in Frameworks */, 102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */, 25A978BA2C13F8ED0003AAE7 /* MeshtasticProtobufs in Frameworks */, 102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */, @@ -1510,6 +1512,7 @@ 102B5EB02E172F41003D191E /* DatadogRUM */, 10D109F12E2047D600536CE6 /* DatadogSessionReplay */, 10D109F32E2047D600536CE6 /* DatadogTrace */, + EXYTECHAT123456789ABCD2 /* ExyteChat */, ); productName = MeshtasticClient; productReference = DDC2E15426CE248E0042C5E4 /* Meshtastic.app */; @@ -1581,6 +1584,7 @@ 25A978B82C13F8ED0003AAE7 /* XCLocalSwiftPackageReference "MeshtasticProtobufs" */, 259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */, 102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */, + BC4E1A772F4BA3500065501B /* XCRemoteSwiftPackageReference "Chat" */, ); productRefGroup = DDC2E15526CE248E0042C5E4 /* Products */; projectDirPath = ""; @@ -2356,6 +2360,14 @@ minimumVersion = 1.26.0; }; }; + BC4E1A772F4BA3500065501B /* XCRemoteSwiftPackageReference "Chat" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/Chat.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.7.6; + }; + }; DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/emqx/CocoaMQTT"; @@ -2364,6 +2376,14 @@ minimumVersion = 2.0.0; }; }; + EXYTECHAT123456789ABCD1 /* XCRemoteSwiftPackageReference "Chat" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/Chat.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2410,6 +2430,11 @@ package = DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */; productName = CocoaMQTT; }; + EXYTECHAT123456789ABCD2 /* ExyteChat */ = { + isa = XCSwiftPackageProductDependency; + package = EXYTECHAT123456789ABCD1 /* XCRemoteSwiftPackageReference "Chat" */; + productName = ExyteChat; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb5d36cf..e206c4a8 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4", + "originHash" : "4f3205f567bace7f065677192bcfcea8bf01bc42c5efdbe9058f03b10f5cccd6", "pins" : [ + { + "identity" : "activityindicatorview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/ActivityIndicatorView", + "state" : { + "revision" : "36140867802ae4a1d2b11490bcbbefe058001d14", + "version" : "1.2.1" + } + }, + { + "identity" : "anchoredpopup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/AnchoredPopup.git", + "state" : { + "revision" : "dfcd04d7a265808333674a7ccf001838102a391e", + "version" : "1.1.0" + } + }, + { + "identity" : "chat", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/Chat.git", + "state" : { + "revision" : "ecf7edb1ba6d4406543af3796c512005dc013802", + "version" : "2.7.6" + } + }, { "identity" : "cocoamqtt", "kind" : "remoteSourceControl", @@ -15,8 +42,53 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e", - "version" : "3.4.0" + "revision" : "4b9d2c543dec767b181b18a6ba016ca1fa297027", + "version" : "3.7.0" + } + }, + { + "identity" : "giphy-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Giphy/giphy-ios-sdk", + "state" : { + "revision" : "f7a8edf513ab14147ef33c942b10b45cdac1e765", + "version" : "2.3.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "e227df15448d2ad1a5d4e4c49722a71c68f9058a", + "version" : "8.7.0" + } + }, + { + "identity" : "kscrash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kstenerud/KSCrash.git", + "state" : { + "revision" : "95a8895d75f3c22aa9ad9f2a15d2fbd97b0a55e2", + "version" : "2.5.1" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "mediapicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/MediaPicker.git", + "state" : { + "revision" : "fd71c0b560aa79eee1b87f73ce93fac5576a01df", + "version" : "3.2.4" } }, { @@ -29,21 +101,12 @@ } }, { - "identity" : "opentelemetry-swift-packages", + "identity" : "opentelemetry-swift-core", "kind" : "remoteSourceControl", - "location" : "https://github.com/DataDog/opentelemetry-swift-packages.git", + "location" : "https://github.com/open-telemetry/opentelemetry-swift-core", "state" : { - "revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e", - "version" : "1.13.1" - } - }, - { - "identity" : "plcrashreporter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/microsoft/plcrashreporter.git", - "state" : { - "revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65", - "version" : "1.12.0" + "revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f", + "version" : "2.3.0" } }, { @@ -55,13 +118,22 @@ "version" : "4.0.8" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", - "version" : "1.33.3" + "revision" : "9bbb079b69af9d66470ced85461bf13bb40becac", + "version" : "1.35.0" } } ], diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index ba8776f7..9c2c72b8 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e", + "originHash" : "4cc10f5e2e37a0271a5ab373060c79138767c500c1475a2c04a71631e136f3b4", "pins" : [ + { + "identity" : "activityindicatorview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/ActivityIndicatorView", + "state" : { + "revision" : "36140867802ae4a1d2b11490bcbbefe058001d14", + "version" : "1.2.1" + } + }, + { + "identity" : "anchoredpopup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/AnchoredPopup.git", + "state" : { + "revision" : "dfcd04d7a265808333674a7ccf001838102a391e", + "version" : "1.1.0" + } + }, + { + "identity" : "chat", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/Chat.git", + "state" : { + "revision" : "ecf7edb1ba6d4406543af3796c512005dc013802", + "version" : "2.7.6" + } + }, { "identity" : "cocoamqtt", "kind" : "remoteSourceControl", @@ -19,6 +46,42 @@ "version" : "3.3.0" } }, + { + "identity" : "giphy-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Giphy/giphy-ios-sdk", + "state" : { + "revision" : "f7a8edf513ab14147ef33c942b10b45cdac1e765", + "version" : "2.3.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "e227df15448d2ad1a5d4e4c49722a71c68f9058a", + "version" : "8.7.0" + } + }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, + { + "identity" : "mediapicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/exyte/MediaPicker.git", + "state" : { + "revision" : "fd71c0b560aa79eee1b87f73ce93fac5576a01df", + "version" : "3.2.4" + } + }, { "identity" : "mqttcocoaasyncsocket", "kind" : "remoteSourceControl", diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index be3959d2..bea2ef0d 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -2,13 +2,82 @@ // ChannelMessageList.swift // Meshtastic // -// Created by Garth Vander Houwen on 12/24/21. +// Migrated to use ExyteChat library with full functionality // import CoreData import MeshtasticProtobufs import OSLog import SwiftUI +import ExyteChat + +private enum ChatMessageAction: MessageMenuAction { + case reply + case copy + case info + case tapback + + func title() -> String { + switch self { + case .reply: return "Reply" + case .copy: return "Copy" + case .info: return "Info" + case .tapback: return "Tapback" + } + } + + func icon() -> Image { + switch self { + case .reply: return Image(systemName: "arrowshape.turn.up.left") + case .copy: return Image(systemName: "doc.on.doc") + case .info: return Image(systemName: "info.circle") + case .tapback: return Image(systemName: "hand.thumbsup.fill") + } + } +} + +private extension Array where Element == MessageEntity { + func convertToChatMessages(currentUserNum: Int64, preferredPeripheralNum: Int) -> [ExyteChat.Message] { + return self.map { entity in + let messageId = String(entity.messageId) + let fromUserEntity = entity.fromUser + + let isCurrentUser: Bool + if let fromUser = fromUserEntity { + isCurrentUser = fromUser.num == currentUserNum + } else { + isCurrentUser = false + } + + let user: ExyteChat.User + if let fromUser = fromUserEntity { + user = ExyteChat.User( + id: String(fromUser.num), + name: fromUser.longName ?? fromUser.shortName ?? "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } else { + user = ExyteChat.User( + id: "unknown", + name: "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } + + return ExyteChat.Message( + id: messageId, + user: user, + status: nil, + createdAt: entity.timestamp, + text: entity.messagePayload ?? "", + attachments: [], + replyMessage: nil + ) + } + } +} struct ChannelMessageList: View { @EnvironmentObject var appState: AppState @@ -22,13 +91,16 @@ struct ChannelMessageList: View { @State private var redrawTapbacksTrigger = UUID() @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 @State private var messageToHighlight: Int64 = 0 + @State private var selectedMessageForDetails: MessageEntity? + @State private var showingMessageDetails = false + @State private var showingTapbackInput = false + @State private var tapbackMessage: MessageEntity? @FetchRequest private var allPrivateMessages: FetchedResults init(myInfo: MyInfoEntity, channel: ChannelEntity) { self.myInfo = myInfo self.channel = channel - // Configure fetch request here let request: NSFetchRequest = MessageEntity.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(keyPath: \MessageEntity.messageTimestamp, ascending: true) @@ -58,79 +130,104 @@ struct ChannelMessageList: View { Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)") } } - - private func routerIsShowingThisChannel() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } - return scenePhase == .active - } - - var body: some View { - // Cast allPrivateMessages to an array for easier indexing and ForEach. - let messages: [MessageEntity] = Array(allPrivateMessages) - - // Precompute previous message - let previousByID: [Int64: MessageEntity?] = { - var dict = [Int64: MessageEntity?]() - var prev: MessageEntity? - for m in messages { dict[m.messageId] = prev; prev = m } - return dict - }() - - ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach(messages, id: \.messageId) { message in - let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil - - ChannelMessageRow( - message: message, - allMessages: allPrivateMessages, - previousMessage: previousMessage, - preferredPeripheralNum: preferredPeripheralNum, - channel: channel, - replyMessageId: $replyMessageId, - messageFieldFocused: $messageFieldFocused, - messageToHighlight: $messageToHighlight, - scrollView: scrollView, - onInteractionComplete: handleInteractionComplete - ) - .onAppear { - // Only mark as read if the app is in the foreground - if !message.read && UIApplication.shared.applicationState == .active { - message.read = true - LocalNotificationManager().cancelNotificationForMessageId(message.messageId) - // Race condition, sometimes the app doesn't update unread count if we run this too early - // So, run it in the main queue after everything saves and stabilizes - DispatchQueue.main.async { - markMessagesAsRead() - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } - } - - } - Color.clear - .frame(height: 1) - .id("bottomAnchor") - } + + func markMessageAsRead(_ message: MessageEntity) { + if !message.read { + message.read = true + do { + try context.save() + appState.unreadChannelMessages = myInfo.unreadMessages + } catch { + Logger.data.error("Failed to mark message as read: \(error.localizedDescription, privacy: .public)") } - .defaultScrollAnchor(.bottom) - .defaultScrollAnchorTopAlignment() - .defaultScrollAnchorBottomSizeChanges() - .scrollDismissesKeyboard(.immediately) - .onChange(of: messageFieldFocused) { - if messageFieldFocused { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } - } - TextMessageField( - destination: .channel(channel), - replyMessageId: $replyMessageId, - isFocused: $messageFieldFocused - ) } + } + + func retryMessage(_ message: MessageEntity) { + Task { + do { + try await accessoryManager.sendMessage( + message: message.messagePayload ?? "", + toUserNum: 0, + channel: Int32(channel.index), + isEmoji: false, + replyID: message.replyID + ) + } catch { + Logger.mesh.error("Failed to retry message: \(error.localizedDescription, privacy: .public)") + } + } + } + + func sendTapback(_ emoji: String, to message: MessageEntity) { + Task { + do { + try await accessoryManager.sendMessage( + message: emoji, + toUserNum: message.fromUser?.num ?? 0, + channel: Int32(channel.index), + isEmoji: true, + replyID: message.messageId + ) + await MainActor.run { + context.refresh(channel, mergeChanges: true) + } + } catch { + Logger.services.warning("Failed to send tapback.") + } + } + } + + func copyMessage(_ text: String) { + UIPasteboard.general.string = text + } + + private var currentUserNum: Int64 { + Int64(preferredPeripheralNum) + } + + private var chatMessages: [Message] { + let entities = Array(allPrivateMessages) + return entities.convertToChatMessages( + currentUserNum: currentUserNum, + preferredPeripheralNum: preferredPeripheralNum + ) + } + + private func sendMessage(draft: DraftMessage) { + guard !draft.text.isEmpty else { return } + + Task { + do { + try await accessoryManager.sendMessage( + message: draft.text, + toUserNum: 0, + channel: Int32(channel.index), + isEmoji: false, + replyID: replyMessageId + ) + replyMessageId = 0 + } catch { + Logger.mesh.info("Error sending channel message") + } + } + } + + var body: some View { + let messages = chatMessages + + ChatView( + messages: messages, + chatType: .conversation, + replyMode: .quote + ) { draft in + sendMessage(draft: draft) + } + .messageUseMarkdown(true) + .setAvailableInputs([.text]) + .showDateHeaders(true) + .isScrollEnabled(true) + .keyboardDismissMode(.interactive) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { @@ -152,5 +249,443 @@ struct ChannelMessageList: View { } } } + .sheet(isPresented: $showingMessageDetails) { + if let msg = selectedMessageForDetails { + MessageDetailsView(message: msg, destination: .channel(channel)) + } + } + .sheet(isPresented: $showingTapbackInput) { + if let msg = tapbackMessage { + TapbackPickerView(message: msg) { emoji in + sendTapback(emoji, to: msg) + } + } + } + } +} + +struct ChannelCustomMessageCell: View { + let message: Message + let currentUserNum: Int64 + @Binding var replyMessageId: Int64 + @FocusState.Binding var messageFieldFocused: Bool + let channel: ChannelEntity + let allMessages: [MessageEntity] + let onRead: (MessageEntity) -> Void + let onRetry: (MessageEntity) -> Void + + @Environment(\.managedObjectContext) var context + + private var isCurrentUser: Bool { + message.user.isCurrentUser + } + + private var messageEntity: MessageEntity? { + allMessages.first { String($0.messageId) == message.id } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + if isCurrentUser { Spacer(minLength: 50) } + + if !isCurrentUser { + if let msgEntity = messageEntity { + CircleText( + text: msgEntity.fromUser?.shortName ?? "?", + color: Color(UIColor(hex: UInt32(msgEntity.fromUser?.num ?? 0))), + circleSize: 50 + ) + .onTapGesture(count: 2) { + if let nodeNum = msgEntity.fromUser?.num { + // Navigate to node detail + } + } + .onAppear { + onRead(msgEntity) + } + .padding(.all, 5) + .offset(y: -7) + } else { + CircleText(text: "?", color: .gray, circleSize: 50) + .padding(.all, 5) + .offset(y: -7) + } + } + + VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 0) { + if !isCurrentUser, let msgEntity = messageEntity { + Text("\(msgEntity.fromUser?.longName ?? "Unknown") (\(msgEntity.fromUser?.userId ?? "?"))") + .font(.caption).foregroundColor(.gray) + .padding(.bottom, 2) + } + + HStack(alignment: .bottom) { + Text(LocalizedStringKey(message.text)) + .padding(.vertical, 10) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background(isCurrentUser ? Color.accentColor : Color.gray) + .cornerRadius(15) + + if isCurrentUser, let msgEntity = messageEntity { + if msgEntity.canRetry { + Button { + onRetry(msgEntity) + } label: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + } + } + } + } + + if let msgEntity = messageEntity { + ChannelMessageStatusView(message: msgEntity) + + TapbackResponsesView(message: msgEntity) { + onRead(msgEntity) + } + } + } + .padding(.bottom) + + if !isCurrentUser { Spacer(minLength: 50) } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + } + .id(message.id) + } +} + +struct ChannelMessageStatusView: View { + @ObservedObject var message: MessageEntity + + var body: some View { + HStack { + if isCurrentUser { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if message.receivedACK { + if message.realACK { + HStack(spacing: 2) { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.gray) + Text(ackErrorVal?.display ?? "Sent") + .font(.caption2) + .foregroundStyle(.gray) + } + } else { + HStack(spacing: 2) { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.gray) + Text("Acknowledged by another node") + .font(.caption2) + .foregroundStyle(.gray) + } + } + } else if message.ackError == 0 { + HStack(spacing: 2) { + Image(systemName: "clock.fill") + .font(.caption2) + .foregroundColor(.yellow) + Text("Waiting to be acknowledged. . .") + .font(.caption2) + .foregroundColor(.yellow) + } + } else if message.ackError > 0 { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2) + .foregroundColor(.red) + Text(ackErrorVal?.display ?? "Error") + .font(.caption2) + .foregroundColor(.red) + } + } + } + } + .padding(.top, 2) + } + + private var isCurrentUser: Bool { + Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num + } +} + +struct TapbackResponsesView: View { + @ObservedObject var message: MessageEntity + let onRead: () -> Void + + @Environment(\.managedObjectContext) var context + + var body: some View { + let tapbacks = message.tapbacks + if !tapbacks.isEmpty { + HStack(spacing: 4) { + ForEach(tapbacks, id: \.messageId) { tapback in + VStack { + if let image = tapback.messagePayload?.image(fontSize: 16) { + Image(uiImage: image) + .font(.caption) + } + Text("\(tapback.fromUser?.shortName ?? "?")") + .font(.caption2) + .foregroundColor(.gray) + } + .onAppear { + if !tapback.read { + tapback.read = true + onRead() + try? context.save() + } + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6))) + .padding(.top, 2) + } + } +} + +struct TapbackPickerView: View { + @Environment(\.dismiss) var dismiss + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + let message: MessageEntity + let onTapbackSelected: (String) -> Void + + @State private var emojiText: String = "" + + var body: some View { + NavigationView { + VStack(spacing: 0) { + TextField("Tap to enter emoji", text: $emojiText) + .keyboardType(.emoji) + .frame(height: 50) + .padding(.horizontal) + .background( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.tertiary, lineWidth: 1) + ) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemBackground)) + ) + .padding(.horizontal) + .padding(.top, 8) + .onChange(of: emojiText) { oldValue, newValue in + if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) { + onTapbackSelected(firstEmoji) + emojiText = "" + dismiss() + } + } + + Text("Type an emoji to send as a tapback") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + } + .navigationTitle("Tapback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + dismiss() + } + } + } + } + .presentationDetents([.height(150)]) + } + + private func extractFirstEmoji(from string: String) -> String? { + guard !string.isEmpty else { return nil } + + let firstChar = string[string.startIndex] + + if firstChar.isEmoji { + var emojiEnd = string.index(after: string.startIndex) + + while emojiEnd < string.endIndex { + let nextChar = string[emojiEnd] + if let scalar = nextChar.unicodeScalars.first, + (scalar.properties.isVariationSelector || + scalar.value == 0xFE0F || + (scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) || + scalar.value == 0x200D) { + emojiEnd = string.index(after: emojiEnd) + } else if nextChar.isEmoji { + emojiEnd = string.index(after: emojiEnd) + } else { + break + } + } + + return String(string[string.startIndex.. 0 ? + Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)).formatted(date: .abbreviated, time: .shortened) : "N/A") + } + } else if message.ackError > 0 { + LabeledContent("Status") { + let error = RoutingError(rawValue: Int(message.ackError)) + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + Text(error?.display ?? "Error") + } + } + } else { + LabeledContent("Status") { + HStack { + Image(systemName: "clock.fill") + .foregroundColor(.yellow) + Text("Pending...") + } + } + } + + LabeledContent("Read") { + Image(systemName: message.read ? "checkmark.circle.fill" : "circle") + .foregroundColor(message.read ? .green : .gray) + } + } + } + + Section("Message Info") { + if let relayDisplay = relayDisplay { + LabeledContent("Relay") { + Text(relayDisplay) + .foregroundColor(relayDisplay.contains("Node ") ? .secondary : .primary) + } + } + + if message.relays != 0 && !message.realACK { + LabeledContent("Relayed by") { + Text("\(message.relays) \(message.relays == 1 ? "node" : "nodes")") + } + } + + if message.ackSNR != 0 { + LabeledContent("Ack SNR") { + Text("\(String(format: "%.2f", message.ackSNR)) dB") + } + } + + if message.snr != 0 { + LabeledContent("SNR") { + Text("\(String(format: "%.2f", message.snr)) dB") + } + } + + if message.rssi != 0 { + LabeledContent("RSSI") { + Text("\(String(format: "%.2f", message.rssi)) dBm") + } + } + + if let node = message.fromUser?.userNode, node.hopsAway > 0 { + LabeledContent("Hops Away") { + Text("\(node.hopsAway)") + } + } + } + + if message.replyID > 0 { + Section("Reply") { + LabeledContent("In Reply To") { + Text(String(message.replyID)) + .font(.caption) + } + } + } + + Section("Message Text") { + Text(message.messagePayload ?? "Empty") + .font(.body) + } + } + .navigationTitle("Message Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .onAppear { + DispatchQueue.global(qos: .userInitiated).async { + let result = message.relayDisplay() + DispatchQueue.main.async { + relayDisplay = result + } + } + } + } } } diff --git a/Meshtastic/Views/Messages/ChatAdapters.swift b/Meshtastic/Views/Messages/ChatAdapters.swift new file mode 100644 index 00000000..813d0f65 --- /dev/null +++ b/Meshtastic/Views/Messages/ChatAdapters.swift @@ -0,0 +1,104 @@ +// +// ChatAdapters.swift +// Meshtastic +// +// Adapters to convert between Meshtastic Core Data entities and ExyteChat library types +// + +import Foundation +import CoreData +import ExyteChat + +extension UserEntity { + + func toChatUser(currentUserNum: Int64) -> User { + let isCurrentUser = self.num == currentUserNum + return User( + id: String(self.num), + name: self.longName ?? self.shortName ?? "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } +} + +extension MessageEntity { + + func toChatMessage( + currentUserNum: Int64, + allMessages: [MessageEntity] = [], + preferredPeripheralNum: Int = -1 + ) -> Message { + let messageId = String(self.messageId) + let fromUserEntity = self.fromUser + + let isCurrentUser: Bool + if let fromUser = fromUserEntity { + isCurrentUser = fromUser.num == currentUserNum + } else { + isCurrentUser = false + } + + let user: User + if let fromUser = fromUserEntity { + user = fromUser.toChatUser(currentUserNum: currentUserNum) + } else { + user = User( + id: "unknown", + name: "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } + + var replyMessage: Message? = nil + if self.replyID > 0, let replyEntity = allMessages.first(where: { $0.messageId == self.replyID }) { + replyMessage = replyEntity.toChatMessage( + currentUserNum: currentUserNum, + allMessages: [], + preferredPeripheralNum: preferredPeripheralNum + ) + } + + return Message( + id: messageId, + user: user, + text: self.messagePayload ?? "", + attachments: [], + createdAt: self.timestamp, + replyMessage: replyMessage, + status: self.determineMessageStatus(preferredPeripheralNum: Int64(preferredPeripheralNum)) + ) + } + + private func determineMessageStatus(preferredPeripheralNum: Int64) -> MessageStatus { + guard Int64(preferredPeripheralNum) == fromUser?.num else { + return .read + } + + if receivedACK { + return .read + } else if ackError > 0 { + return .error + } else { + return .sending + } + } +} + +struct ChatMessageAdapter { + + static func convertMessages( + from entities: [MessageEntity], + currentUserNum: Int64, + preferredPeripheralNum: Int = -1 + ) -> [Message] { + return entities.map { entity in + entity.toChatMessage( + currentUserNum: currentUserNum, + allMessages: entities, + preferredPeripheralNum: preferredPeripheralNum + ) + } + } +} diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 9a3425bc..4cb77709 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -1,14 +1,84 @@ // -//  UserMessageList.swift -//  MeshtasticApple +// UserMessageList.swift +// MeshtasticApple // -//  Created by Garth Vander Houwen on 12/24/21. +// Migrated to use ExyteChat library with full functionality // import SwiftUI import CoreData import OSLog -import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed +import MeshtasticProtobufs +import ExyteChat +import LinkPresentation + +private enum ChatMessageAction: MessageMenuAction { + case reply + case copy + case info + case tapback + + func title() -> String { + switch self { + case .reply: return "Reply" + case .copy: return "Copy" + case .info: return "Info" + case .tapback: return "Tapback" + } + } + + func icon() -> Image { + switch self { + case .reply: return Image(systemName: "arrowshape.turn.up.left") + case .copy: return Image(systemName: "doc.on.doc") + case .info: return Image(systemName: "info.circle") + case .tapback: return Image(systemName: "hand.thumbsup.fill") + } + } +} + +private extension Array where Element == MessageEntity { + func convertToChatMessages(currentUserNum: Int64, preferredPeripheralNum: Int) -> [ExyteChat.Message] { + return self.map { entity in + let messageId = String(entity.messageId) + let fromUserEntity = entity.fromUser + + let isCurrentUser: Bool + if let fromUser = fromUserEntity { + isCurrentUser = fromUser.num == currentUserNum + } else { + isCurrentUser = false + } + + let user: ExyteChat.User + if let fromUser = fromUserEntity { + user = ExyteChat.User( + id: String(fromUser.num), + name: fromUser.longName ?? fromUser.shortName ?? "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } else { + user = ExyteChat.User( + id: "unknown", + name: "Unknown", + avatarURL: nil, + isCurrentUser: isCurrentUser + ) + } + + return ExyteChat.Message( + id: messageId, + user: user, + status: nil, + createdAt: entity.timestamp, + text: entity.messagePayload ?? "", + attachments: [], + replyMessage: nil + ) + } + } +} struct UserMessageList: View { @EnvironmentObject var appState: AppState @@ -20,22 +90,25 @@ struct UserMessageList: View { @State private var replyMessageId: Int64 = 0 @State private var messageToHighlight: Int64 = 0 @State private var redrawTapbacksTrigger = UUID() + @State private var selectedMessageForDetails: MessageEntity? + @State private var showingMessageDetails = false + @State private var showingTapbackInput = false + @State private var tapbackMessage: MessageEntity? @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 @FetchRequest private var allPrivateMessages: FetchedResults - + init(user: UserEntity) { self.user = user - - // Configure fetch request here + let request: NSFetchRequest = user.messageFetchRequest _allPrivateMessages = FetchRequest(fetchRequest: request) } - + func handleInteractionComplete() { markMessagesAsRead() redrawTapbacksTrigger = UUID() } - + func markMessagesAsRead() { do { for unreadMessage in allPrivateMessages.filter({ !$0.read }) { @@ -43,94 +116,120 @@ struct UserMessageList: View { } try context.save() Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).") - + if let connectedPeripheralNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context), let connectedUser = connectedNode.user { - appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) // skipLastMessageCheck=true because we don't update lastMessage on our own connected node + appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) } - + context.refresh(user, mergeChanges: true) } catch { Logger.data.error("Failed to read direct messages: \(error.localizedDescription, privacy: .public)") } } - - private func routerIsShowingThisUser() -> Bool { - guard appState.router.navigationState.selectedTab == .messages else { return false } - return scenePhase == .active - } - - var body: some View { - // Cast user.messageList to an array for easier indexing and ForEach. - let messages: [MessageEntity] = Array(allPrivateMessages) - - // Precompute previous message - let previousByID: [Int64: MessageEntity?] = { - var dict = [Int64: MessageEntity?]() - var prev: MessageEntity? - for m in messages { dict[m.messageId] = prev; prev = m } - return dict - }() - - VStack { - ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach(messages, id: \.messageId) { message in - let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil - - UserMessageRow( - message: message, - allMessages: messages, - previousMessage: previousMessage, - preferredPeripheralNum: preferredPeripheralNum, - user: user, - replyMessageId: $replyMessageId, - messageFieldFocused: $messageFieldFocused, - messageToHighlight: $messageToHighlight, - scrollView: scrollView, - onInteractionComplete: handleInteractionComplete - ) - .onAppear { - // Only mark as read if the app is in the foreground - if !message.read && UIApplication.shared.applicationState == .active { - message.read = true - LocalNotificationManager().cancelNotificationForMessageId(message.messageId) - // Race condition, sometimes the app doesn't update unread count if we run this too early - // So, run it in the main queue after everything saves and stabilizes - DispatchQueue.main.async { - markMessagesAsRead() - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } - } - - } - // Invisible spacer to detect reaching bottom - Color.clear - .frame(height: 1) - .id("bottomAnchor") - } - } - .defaultScrollAnchor(.bottom) - .defaultScrollAnchorTopAlignment() - .defaultScrollAnchorBottomSizeChanges() - .scrollDismissesKeyboard(.immediately) - .onChange(of: messageFieldFocused) { - if messageFieldFocused { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } + + func markMessageAsRead(_ message: MessageEntity) { + if !message.read { + message.read = true + do { + try context.save() + if let connectedPeripheralNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context), + let connectedUser = connectedNode.user { + appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) } + } catch { + Logger.data.error("Failed to mark message as read: \(error.localizedDescription, privacy: .public)") } - TextMessageField( - destination: .user(user), - replyMessageId: $replyMessageId, - isFocused: $messageFieldFocused - ) } + } + + func retryMessage(_ message: MessageEntity) { + Task { + do { + try await accessoryManager.sendMessage( + message: message.messagePayload ?? "", + toUserNum: user.num, + channel: 0, + isEmoji: false, + replyID: message.replyID + ) + } catch { + Logger.mesh.error("Failed to retry message: \(error.localizedDescription, privacy: .public)") + } + } + } + + func sendTapback(_ emoji: String, to message: MessageEntity) { + Task { + do { + try await accessoryManager.sendMessage( + message: emoji, + toUserNum: message.fromUser?.num ?? user.num, + channel: 0, + isEmoji: true, + replyID: message.messageId + ) + await MainActor.run { + context.refresh(user, mergeChanges: true) + } + } catch { + Logger.services.warning("Failed to send tapback.") + } + } + } + + func copyMessage(_ text: String) { + UIPasteboard.general.string = text + } + + private var currentUserNum: Int64 { + Int64(preferredPeripheralNum) + } + + private var chatMessages: [Message] { + let entities = Array(allPrivateMessages) + return entities.convertToChatMessages( + currentUserNum: currentUserNum, + preferredPeripheralNum: preferredPeripheralNum + ) + } + + private func sendMessage(draft: DraftMessage) { + guard !draft.text.isEmpty else { return } + + Task { + do { + try await accessoryManager.sendMessage( + message: draft.text, + toUserNum: user.num, + channel: 0, + isEmoji: false, + replyID: replyMessageId + ) + replyMessageId = 0 + } catch { + Logger.mesh.info("Error sending message") + } + } + } + + var body: some View { + let messages = chatMessages + + return ChatView( + messages: messages, + chatType: .conversation, + replyMode: .quote + ) { draft in + sendMessage(draft: draft) + } + .messageUseMarkdown(true) + .setAvailableInputs([.text]) + .showDateHeaders(true) + .isScrollEnabled(true) + .keyboardDismissMode(.interactive) .navigationBarTitleDisplayMode(.inline) .toolbar { if !user.keyMatch { @@ -168,5 +267,278 @@ struct UserMessageList: View { } } } + .sheet(isPresented: $showingTapbackInput) { + if let msg = tapbackMessage { + TapbackPickerViewDM(message: msg) { emoji in + sendTapback(emoji, to: msg) + } + } + } + .sheet(isPresented: $showingMessageDetails) { + if let msg = selectedMessageForDetails { + MessageDetailsView(message: msg, destination: .user(user)) + } + } + } +} + +struct CustomMessageCell: View { + let message: Message + let currentUserNum: Int64 + @Binding var replyMessageId: Int64 + @FocusState.Binding var messageFieldFocused: Bool + let destination: MessageDestination + let allMessages: [MessageEntity] + let onRead: (MessageEntity) -> Void + let onRetry: (MessageEntity) -> Void + + @Environment(\.managedObjectContext) var context + + private var isCurrentUser: Bool { + message.user.isCurrentUser + } + + private var messageEntity: MessageEntity? { + allMessages.first { String($0.messageId) == message.id } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + if isCurrentUser { Spacer(minLength: 50) } + + if !isCurrentUser { + if let msgEntity = messageEntity { + CircleText( + text: msgEntity.fromUser?.shortName ?? "?", + color: Color(UIColor(hex: UInt32(msgEntity.fromUser?.num ?? 0))), + circleSize: 50 + ) + .onTapGesture(count: 2) { + if let nodeNum = msgEntity.fromUser?.num { + // Navigate to node detail + } + } + .onAppear { + onRead(msgEntity) + } + .padding(.all, 5) + .offset(y: -7) + } else { + CircleText(text: "?", color: .gray, circleSize: 50) + .padding(.all, 5) + .offset(y: -7) + } + } + + VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 0) { + if !isCurrentUser, let msgEntity = messageEntity { + Text("\(msgEntity.fromUser?.longName ?? "Unknown") (\(msgEntity.fromUser?.userId ?? "?"))") + .font(.caption).foregroundColor(.gray) + .padding(.bottom, 2) + } + + HStack(alignment: .bottom) { + Text(LocalizedStringKey(message.text)) + .padding(.vertical, 10) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background(isCurrentUser ? Color.accentColor : Color.gray) + .cornerRadius(15) + + if isCurrentUser, let msgEntity = messageEntity { + if msgEntity.canRetry || (msgEntity.receivedACK && !msgEntity.realACK) { + Button { + onRetry(msgEntity) + } label: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + } + } + } + } + + if let msgEntity = messageEntity { + DMMessageStatusView(message: msgEntity) + + TapbackResponsesViewDM(message: msgEntity) { + onRead(msgEntity) + } + } + } + .padding(.bottom) + + if !isCurrentUser { Spacer(minLength: 50) } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + } + .id(message.id) + } +} + +struct DMMessageStatusView: View { + @ObservedObject var message: MessageEntity + + var body: some View { + HStack { + if isCurrentUser { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if message.receivedACK { + HStack(spacing: 2) { + Image(systemName: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.gray) + Text(ackErrorVal?.display ?? "Sent") + .font(.caption2) + .foregroundStyle(.gray) + } + } else if message.ackError == 0 { + HStack(spacing: 2) { + Image(systemName: "clock.fill") + .font(.caption2) + .foregroundColor(.yellow) + Text("Waiting to be acknowledged. . .") + .font(.caption2) + .foregroundColor(.yellow) + } + } else if message.ackError > 0 { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2) + .foregroundColor(.red) + Text(ackErrorVal?.display ?? "Error") + .font(.caption2) + .foregroundColor(.red) + } + } + } + } + .padding(.top, 2) + } + + private var isCurrentUser: Bool { + Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num + } +} + +struct TapbackResponsesViewDM: View { + @ObservedObject var message: MessageEntity + let onRead: () -> Void + + @Environment(\.managedObjectContext) var context + + var body: some View { + let tapbacks = message.tapbacks + if !tapbacks.isEmpty { + HStack(spacing: 4) { + ForEach(tapbacks, id: \.messageId) { tapback in + VStack { + if let image = tapback.messagePayload?.image(fontSize: 16) { + Image(uiImage: image) + .font(.caption) + } + Text("\(tapback.fromUser?.shortName ?? "?")") + .font(.caption2) + .foregroundColor(.gray) + } + .onAppear { + if !tapback.read { + tapback.read = true + onRead() + try? context.save() + } + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6))) + .padding(.top, 2) + } + } +} + +struct TapbackPickerViewDM: View { + @Environment(\.dismiss) var dismiss + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + let message: MessageEntity + let onTapbackSelected: (String) -> Void + + @State private var emojiText: String = "" + + var body: some View { + NavigationView { + VStack(spacing: 0) { + TextField("Tap to enter emoji", text: $emojiText) + .keyboardType(.emoji) + .frame(height: 50) + .padding(.horizontal) + .background( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.tertiary, lineWidth: 1) + ) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemBackground)) + ) + .padding(.horizontal) + .padding(.top, 8) + .onChange(of: emojiText) { oldValue, newValue in + if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) { + onTapbackSelected(firstEmoji) + emojiText = "" + dismiss() + } + } + + Text("Type an emoji to send as a tapback") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + } + .navigationTitle("Tapback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + dismiss() + } + } + } + } + .presentationDetents([.height(150)]) + } + + private func extractFirstEmoji(from string: String) -> String? { + guard !string.isEmpty else { return nil } + + let firstChar = string[string.startIndex] + + if firstChar.isEmoji { + var emojiEnd = string.index(after: string.startIndex) + + while emojiEnd < string.endIndex { + let nextChar = string[emojiEnd] + if let scalar = nextChar.unicodeScalars.first, + (scalar.properties.isVariationSelector || + scalar.value == 0xFE0F || + (scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) || + scalar.value == 0x200D) { + emojiEnd = string.index(after: emojiEnd) + } else if nextChar.isEmoji { + emojiEnd = string.index(after: emojiEnd) + } else { + break + } + } + + return String(string[string.startIndex..