diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 78c9454b..877f87f1 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -36916,6 +36916,9 @@ } } } + }, + "Select an emoji" : { + }, "Select Channel" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index cd52c496..a504bb13 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; }; D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; }; D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; }; + D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D92B81509D0066FBC8 /* TapbackInputView.swift */; }; D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */; }; D93068DD2B81CA820066FBC8 /* ConfigHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */; }; D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93069072B81DF040066FBC8 /* SaveConfigButton.swift */; }; @@ -427,6 +428,7 @@ D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = ""; }; D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = ""; }; + D93068D92B81509D0066FBC8 /* TapbackInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackInputView.swift; sourceTree = ""; }; D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerConfig.swift; sourceTree = ""; }; D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigHeader.swift; sourceTree = ""; }; D93069062B81D8900066FBC8 /* MeshtasticDataModelV 27.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 27.xcdatamodel"; sourceTree = ""; }; @@ -1250,6 +1252,7 @@ D93068D62B8146690066FBC8 /* MessageText.swift */, D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */, D93068D82B81509C0066FBC8 /* TapbackResponses.swift */, + D93068D92B81509D0066FBC8 /* TapbackInputView.swift */, ); path = Messages; sourceTree = ""; @@ -1809,6 +1812,7 @@ DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, + D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */, DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */, DDF82CBD2D5BC69200DC25EC /* NavigateToButton.swift in Sources */, 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */, diff --git a/Meshtastic/Helpers/EmojiOnlyTextField.swift b/Meshtastic/Helpers/EmojiOnlyTextField.swift index 0982ab33..4928da73 100644 --- a/Meshtastic/Helpers/EmojiOnlyTextField.swift +++ b/Meshtastic/Helpers/EmojiOnlyTextField.swift @@ -7,6 +7,7 @@ import SwiftUI class SwiftUIEmojiTextField: UITextField { + var shouldBecomeFirstResponderOnAppear = false func setEmoji() { _ = self.textInputMode @@ -23,22 +24,39 @@ class SwiftUIEmojiTextField: UITextField { } return nil } + + override func didMoveToWindow() { + super.didMoveToWindow() + if shouldBecomeFirstResponderOnAppear && window != nil { + DispatchQueue.main.async { [weak self] in + self?.becomeFirstResponder() + } + } + } } struct EmojiOnlyTextField: UIViewRepresentable { @Binding var text: String var placeholder: String = "" + var onBecomeFirstResponder: (() -> Void)? + var onKeyboardTypeChanged: ((Bool) -> Void)? // true if emoji, false otherwise + var onKeyboardDismissed: (() -> Void)? // Called when keyboard is dismissed func makeUIView(context: Context) -> SwiftUIEmojiTextField { let emojiTextField = SwiftUIEmojiTextField() emojiTextField.placeholder = placeholder emojiTextField.text = text emojiTextField.delegate = context.coordinator + emojiTextField.shouldBecomeFirstResponderOnAppear = true + context.coordinator.textField = emojiTextField return emojiTextField } func updateUIView(_ uiView: SwiftUIEmojiTextField, context: Context) { uiView.text = text + context.coordinator.onBecomeFirstResponder = onBecomeFirstResponder + context.coordinator.onKeyboardTypeChanged = onKeyboardTypeChanged + context.coordinator.onKeyboardDismissed = onKeyboardDismissed } func makeCoordinator() -> Coordinator { @@ -47,13 +65,41 @@ struct EmojiOnlyTextField: UIViewRepresentable { class Coordinator: NSObject, UITextFieldDelegate { var parent: EmojiOnlyTextField + var textField: SwiftUIEmojiTextField? + var onBecomeFirstResponder: (() -> Void)? + var onKeyboardTypeChanged: ((Bool) -> Void)? + var onKeyboardDismissed: (() -> Void)? + var previousInputMode: String? + init(parent: EmojiOnlyTextField) { self.parent = parent } + + func textFieldDidBeginEditing(_ textField: UITextField) { + onBecomeFirstResponder?() + checkInputMode(textField) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + // Keyboard was dismissed + onKeyboardDismissed?() + } + func textFieldDidChangeSelection(_ textField: UITextField) { DispatchQueue.main.async { [weak self] in self?.parent.text = textField.text ?? "" } + checkInputMode(textField) + } + + private func checkInputMode(_ textField: UITextField) { + if let inputMode = textField.textInputMode { + let isEmoji = inputMode.primaryLanguage == "emoji" + if previousInputMode != inputMode.primaryLanguage { + previousInputMode = inputMode.primaryLanguage + onKeyboardTypeChanged?(!isEmoji) // true if NOT emoji (should dismiss) + } + } } } } diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 63104320..14d5b3f7 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -10,6 +10,7 @@ struct MessageContextMenuItems: View { let tapBackDestination: MessageDestination let isCurrentUser: Bool @Binding var isShowingDeleteConfirmation: Bool + @Binding var isShowingTapbackInput: Bool let onReply: () -> Void @State var relayDisplay: String? = nil @@ -29,30 +30,8 @@ struct MessageContextMenuItems: View { } } - Menu("Tapback") { - ForEach(Tapbacks.allCases) { tb in - Button { - Task { - do { - try await accessoryManager.sendMessage( - message: tb.emojiString, - toUserNum: tapBackDestination.userNum, - channel: tapBackDestination.channelNum, - isEmoji: true, - replyID: message.messageId - ) - Task { @MainActor in - self.context.refresh(tapBackDestination.managedObject, mergeChanges: true) - } - } catch { - Logger.services.warning("Failed to send tapback.") - } - } - } label: { - Text(tb.description) - Image(uiImage: tb.emojiString.image()!) - } - } + Button("Tapback") { + isShowingTapbackInput = true } Button(action: onReply) { diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index 28df8fba..98734b24 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -27,13 +27,14 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false + @State private var isShowingTapbackInput = false + @State private var tapbackText = "" var body: some View { SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { - let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - return Text(markdownText) + Text(markdownText) .tint(Self.linkBlue) .padding(.vertical, 10) .padding(.horizontal, 8) @@ -91,6 +92,7 @@ struct MessageText: View { tapBackDestination: tapBackDestination, isCurrentUser: isCurrentUser, isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + isShowingTapbackInput: $isShowingTapbackInput, onReply: onReply ) } @@ -132,6 +134,36 @@ struct MessageText: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) } + .sheet(isPresented: $isShowingTapbackInput) { + TapbackInputView( + text: $tapbackText, + isPresented: $isShowingTapbackInput, + onEmojiSelected: { emoji in + Task { + do { + try await accessoryManager.sendMessage( + message: emoji, + toUserNum: tapBackDestination.userNum, + channel: tapBackDestination.channelNum, + isEmoji: true, + replyID: message.messageId + ) + Task { @MainActor in + switch tapBackDestination { + case let .channel(channel): + context.refresh(channel, mergeChanges: true) + case let .user(user): + context.refresh(user, mergeChanges: true) + } + } + } catch { + Logger.services.warning("Failed to send tapback.") + } + } + isShowingTapbackInput = false + } + ) + } .confirmationDialog( "Are you sure you want to delete this message?", isPresented: $isShowingDeleteConfirmation, diff --git a/Meshtastic/Views/Messages/TapbackInputView.swift b/Meshtastic/Views/Messages/TapbackInputView.swift new file mode 100644 index 00000000..4b961295 --- /dev/null +++ b/Meshtastic/Views/Messages/TapbackInputView.swift @@ -0,0 +1,108 @@ +import SwiftUI +import UIKit + +struct TapbackInputView: View { + @Binding var text: String + @Binding var isPresented: Bool + let onEmojiSelected: (String) -> Void + + var body: some View { + NavigationView { + VStack(spacing: 0) { + EmojiOnlyTextField( + text: $text, + placeholder: "Tap to enter emoji", + onBecomeFirstResponder: { + // Text field will automatically become first responder + }, + onKeyboardTypeChanged: { shouldDismiss in + // Dismiss if keyboard switched away from emoji + if shouldDismiss { + isPresented = false + } + }, + onKeyboardDismissed: { + // Dismiss sheet when keyboard is dismissed + isPresented = false + } + ) + .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: text) { oldValue, newValue in + // Extract first emoji character and send it + if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) { + onEmojiSelected(firstEmoji) + // Clear the text box after getting the emoji + text = "" + } + } + } + .navigationTitle("Tapback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { + isPresented = false + } + } + } + } + .presentationDetents([.height(120)]) + } + + private func extractFirstEmoji(from string: String) -> String? { + // Extract the first emoji character(s) - handle both single and multi-scalar emojis + guard !string.isEmpty else { return nil } + + // Try to get the first character + let firstChar = string[string.startIndex] + + // Check if it's an emoji using the existing extension + if firstChar.isEmoji { + // For multi-scalar emojis (like emojis with skin tones), we need to find the full emoji sequence + var emojiEnd = string.index(after: string.startIndex) + + // Check if there are continuation scalars (for emojis with skin tones, variation selectors, etc.) + while emojiEnd < string.endIndex { + let nextChar = string[emojiEnd] + // Check if this is a continuation (variation selector, skin tone modifier, zero-width joiner, etc.) + if let scalar = nextChar.unicodeScalars.first, + (scalar.properties.isVariationSelector || + scalar.value == 0xFE0F || // Variation selector + (scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) || // Skin tone modifiers + scalar.value == 0x200D) { // Zero-width joiner + emojiEnd = string.index(after: emojiEnd) + } else if nextChar.isEmoji { + // If it's another emoji, include it (for compound emojis like flags) + emojiEnd = string.index(after: emojiEnd) + } else { + break + } + } + + return String(string[string.startIndex..