mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
tapback with any emoji (#1538)
This commit is contained in:
parent
f25fdfb89f
commit
575cb887b0
6 changed files with 198 additions and 26 deletions
|
|
@ -36916,6 +36916,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select an emoji" : {
|
||||
|
||||
},
|
||||
"Select Channel" : {
|
||||
"localizations" : {
|
||||
|
|
|
|||
|
|
@ -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 = "<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>"; };
|
||||
|
|
@ -1250,6 +1252,7 @@
|
|||
D93068D62B8146690066FBC8 /* MessageText.swift */,
|
||||
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */,
|
||||
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */,
|
||||
D93068D92B81509D0066FBC8 /* TapbackInputView.swift */,
|
||||
);
|
||||
path = Messages;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
108
Meshtastic/Views/Messages/TapbackInputView.swift
Normal file
108
Meshtastic/Views/Messages/TapbackInputView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue