Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -31617,6 +31617,9 @@
}
}
}
},
"Select an emoji" : {

},
"Select Channel" : {
"localizations" : {
Expand Down
4 changes: 4 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -427,6 +428,7 @@
D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = "<group>"; };
D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = "<group>"; };
D93068D92B81509D0066FBC8 /* TapbackInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackInputView.swift; sourceTree = "<group>"; };
D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerConfig.swift; sourceTree = "<group>"; };
D93068DC2B81CA820066FBC8 /* ConfigHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigHeader.swift; sourceTree = "<group>"; };
D93069062B81D8900066FBC8 /* MeshtasticDataModelV 27.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 27.xcdatamodel"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1250,6 +1252,7 @@
D93068D62B8146690066FBC8 /* MessageText.swift */,
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */,
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */,
D93068D92B81509D0066FBC8 /* TapbackInputView.swift */,
);
path = Messages;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
46 changes: 46 additions & 0 deletions Meshtastic/Helpers/EmojiOnlyTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import SwiftUI

class SwiftUIEmojiTextField: UITextField {
var shouldBecomeFirstResponderOnAppear = false

func setEmoji() {
_ = self.textInputMode
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
}
}
27 changes: 3 additions & 24 deletions Meshtastic/Views/Messages/MessageContextMenuItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand Down
36 changes: 34 additions & 2 deletions Meshtastic/Views/Messages/MessageText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -91,6 +92,7 @@ struct MessageText: View {
tapBackDestination: tapBackDestination,
isCurrentUser: isCurrentUser,
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
isShowingTapbackInput: $isShowingTapbackInput,
onReply: onReply
)
}
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions Meshtastic/Views/Messages/TapbackInputView.swift
Original file line number Diff line number Diff line change
@@ -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..<emojiEnd])
}

return nil
}
}

extension UIView {
var firstResponder: UIView? {
guard !isFirstResponder else { return self }
for subview in subviews {
if let firstResponder = subview.firstResponder {
return firstResponder
}
}
return nil
}
}