Skip to content

Commit 7ddb512

Browse files
committed
Fix tapback keyboard covering target message
Move the hidden emoji TextField from per-message-row (MessageText) to the list level (ChannelMessageList / UserMessageList). This ensures only a single TextField exists in the view hierarchy and lets the list's ScrollViewReader scroll the target message into view when the emoji keyboard opens. Changes: - MessageText: Remove tapbackText state, FocusState, hidden TextField, and processTapback(). Accept an onTapback closure instead. - MessageContextMenuItems: Replace isShowingTapbackInput binding with an onTapback closure. - ChannelMessageRow / UserMessageRow: Accept and forward onTapback closure to MessageText. - ChannelMessageList / UserMessageList: Own the tapback state, hidden TextField, processTapback(), and scroll-to-target logic via onChange(of: tapbackFocused). Restore Mac Catalyst character palette support.
1 parent 6d77853 commit 7ddb512

6 files changed

Lines changed: 140 additions & 68 deletions

File tree

Meshtastic/Views/Messages/ChannelMessageList.swift

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ struct ChannelMessageList: View {
2424
@State private var messageToHighlight: Int64 = 0
2525
@State private var needsReadSync: Bool = false
2626
@State private var messageLimit: Int = 50
27+
@State private var tapbackTargetMessage: MessageEntity?
28+
@State private var tapbackText = ""
29+
@FocusState var tapbackFocused: Bool
2730
@Query private var allPrivateMessages: [MessageEntity]
2831

2932
init(myInfo: MyInfoEntity, channel: ChannelEntity) {
@@ -62,6 +65,30 @@ struct ChannelMessageList: View {
6265
return scenePhase == .active
6366
}
6467

68+
private func processTapback() {
69+
guard !tapbackText.isEmpty, let target = tapbackTargetMessage else { return }
70+
let emojiToSend = tapbackText
71+
let destination = MessageDestination.channel(channel)
72+
73+
Task {
74+
do {
75+
try await accessoryManager.sendMessage(
76+
message: emojiToSend,
77+
toUserNum: destination.userNum,
78+
channel: destination.channelNum,
79+
isEmoji: true,
80+
replyID: target.messageId
81+
)
82+
} catch {
83+
Logger.services.warning("Failed to send tapback.")
84+
}
85+
}
86+
87+
tapbackText = ""
88+
tapbackFocused = false
89+
tapbackTargetMessage = nil
90+
}
91+
6592
var body: some View {
6693
// Show only the most recent N messages to limit memory usage
6794
let messages = allPrivateMessages.suffix(messageLimit)
@@ -101,7 +128,21 @@ struct ChannelMessageList: View {
101128
messageFieldFocused: $messageFieldFocused,
102129
messageToHighlight: $messageToHighlight,
103130
scrollView: scrollView,
104-
onInteractionComplete: handleInteractionComplete
131+
onInteractionComplete: handleInteractionComplete,
132+
onTapback: { message in
133+
tapbackTargetMessage = message
134+
tapbackFocused = true
135+
#if targetEnvironment(macCatalyst)
136+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
137+
if let nsApp = NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication") as? NSObject {
138+
let selector = NSSelectorFromString("orderFrontCharacterPalette:")
139+
if nsApp.responds(to: selector) {
140+
nsApp.perform(selector, with: nil)
141+
}
142+
}
143+
}
144+
#endif
145+
}
105146
)
106147
.onAppear {
107148
if !message.read && UIApplication.shared.applicationState == .active {
@@ -135,6 +176,26 @@ struct ChannelMessageList: View {
135176
}
136177
}
137178
}
179+
.onChange(of: tapbackFocused) {
180+
if tapbackFocused, let target = tapbackTargetMessage {
181+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
182+
withAnimation {
183+
scrollView.scrollTo(target.messageId, anchor: .center)
184+
}
185+
}
186+
}
187+
}
188+
.background {
189+
TextField("", text: $tapbackText)
190+
.keyboardType(.emoji)
191+
.focused($tapbackFocused)
192+
.frame(width: 1, height: 1)
193+
.opacity(0.01)
194+
.allowsHitTesting(false)
195+
.onChange(of: tapbackText) {
196+
processTapback()
197+
}
198+
}
138199
TextMessageField(
139200
destination: .channel(channel),
140201
replyMessageId: $replyMessageId,

Meshtastic/Views/Messages/ChannelMessageRow.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ struct ChannelMessageRow: View {
1818
@Binding var messageToHighlight: Int64
1919
let scrollView: ScrollViewProxy
2020
let onInteractionComplete: () -> Void
21+
let onTapback: (MessageEntity) -> Void
2122

2223
private var isCurrentUser: Bool {
2324
Int64(preferredPeripheralNum) == message.fromUser?.num
@@ -32,7 +33,8 @@ struct ChannelMessageRow: View {
3233
messageFieldFocused: FocusState<Bool>.Binding,
3334
messageToHighlight: Binding<Int64>,
3435
scrollView: ScrollViewProxy,
35-
onInteractionComplete: @escaping () -> Void) {
36+
onInteractionComplete: @escaping () -> Void,
37+
onTapback: @escaping (MessageEntity) -> Void) {
3638
// Initialize ObservedObject with the concrete instance
3739
self.message = message
3840
self.allMessages = allMessages
@@ -44,6 +46,7 @@ struct ChannelMessageRow: View {
4446
self._messageToHighlight = messageToHighlight
4547
self.scrollView = scrollView
4648
self.onInteractionComplete = onInteractionComplete
49+
self.onTapback = onTapback
4750
}
4851

4952
var body: some View {
@@ -122,6 +125,8 @@ struct ChannelMessageRow: View {
122125
) {
123126
self.replyMessageId = message.messageId
124127
self.messageFieldFocused = true
128+
} onTapback: {
129+
onTapback(message)
125130
}
126131

127132
if isCurrentUser && message.canRetry {

Meshtastic/Views/Messages/MessageContextMenuItems.swift

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ struct MessageContextMenuItems: View {
1010
let tapBackDestination: MessageDestination
1111
let isCurrentUser: Bool
1212
@Binding var isShowingDeleteConfirmation: Bool
13-
@Binding var isShowingTapbackInput: Bool
13+
let onTapback: () -> Void
1414
let onReply: () -> Void
1515
let canTranslate: Bool
1616
let hasTranslatedText: Bool
@@ -39,19 +39,7 @@ struct MessageContextMenuItems: View {
3939
Button("Tapback") {
4040
// The context menu needs a moment to dismiss before the focus state can be changed.
4141
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
42-
isShowingTapbackInput = true
43-
#if targetEnvironment(macCatalyst)
44-
// On Mac Catalyst, open the system Character Palette (emoji picker)
45-
// by calling orderFrontCharacterPalette: directly on NSApplication.
46-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
47-
if let nsApp = NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication") as? NSObject {
48-
let selector = NSSelectorFromString("orderFrontCharacterPalette:")
49-
if nsApp.responds(to: selector) {
50-
nsApp.perform(selector, with: nil)
51-
}
52-
}
53-
}
54-
#endif
42+
onTapback()
5543
}
5644
}
5745

Meshtastic/Views/Messages/MessageText.swift

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@ struct MessageText: View {
1515
let tapBackDestination: MessageDestination
1616
let isCurrentUser: Bool
1717
let onReply: () -> Void
18+
let onTapback: () -> Void
1819
// State for handling channel URL sheet
1920
@State private var saveChannelLink: SaveChannelLinkData?
2021
@State private var isShowingDeleteConfirmation = false
2122
@State private var isShowingTranslationPresentation = false
22-
@State private var tapbackText = ""
23-
@FocusState private var isTapbackInputFocused: Bool
2423

2524
var body: some View {
2625
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
@@ -104,29 +103,14 @@ struct MessageText: View {
104103
.foregroundColor(isCurrentUser ? .white : Color("Colors/MeshtasticBubbleText"))
105104
.background(isCurrentUser ? .accentColor : Color("Colors/MeshtasticBubble"))
106105
.cornerRadius(15)
107-
.background {
108-
TextField("", text: $tapbackText)
109-
.keyboardType(.emoji)
110-
.scrollDismissesKeyboard(.immediately)
111-
.focused($isTapbackInputFocused)
112-
.frame(width: 1, height: 1)
113-
.opacity(0.01)
114-
.allowsHitTesting(false)
115-
.onChange(of: tapbackText) {
116-
processTapback()
117-
}
118-
}
119106
.overlay(messageOverlays)
120107
.contextMenu {
121108
MessageContextMenuItems(
122109
message: message,
123110
tapBackDestination: tapBackDestination,
124111
isCurrentUser: isCurrentUser,
125112
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
126-
isShowingTapbackInput: Binding(
127-
get: { isTapbackInputFocused },
128-
set: { isTapbackInputFocused = $0 }
129-
),
113+
onTapback: onTapback,
130114
onReply: onReply,
131115
canTranslate: canTranslate,
132116
hasTranslatedText: hasTranslatedText,
@@ -257,36 +241,6 @@ struct MessageText: View {
257241
Logger.data.error("Failed to clear translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
258242
}
259243
}
260-
261-
private func processTapback() {
262-
guard !tapbackText.isEmpty else { return }
263-
let emojiToSend = tapbackText
264-
265-
Task {
266-
do {
267-
try await accessoryManager.sendMessage(
268-
message: emojiToSend,
269-
toUserNum: tapBackDestination.userNum,
270-
channel: tapBackDestination.channelNum,
271-
isEmoji: true,
272-
replyID: message.messageId
273-
)
274-
await MainActor.run {
275-
switch tapBackDestination {
276-
case .channel:
277-
break
278-
case .user:
279-
break
280-
}
281-
}
282-
} catch {
283-
Logger.services.warning("Failed to send tapback.")
284-
}
285-
}
286-
287-
tapbackText = ""
288-
isTapbackInputFocused = false
289-
}
290244
}
291245

292246
private extension MessageDestination {

Meshtastic/Views/Messages/UserMessageList.swift

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ struct UserMessageList: View {
2121
@State private var messageToHighlight: Int64 = 0
2222
@State private var redrawTapbacksTrigger = UUID()
2323
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
24+
@State private var tapbackTargetMessage: MessageEntity?
25+
@State private var tapbackText = ""
26+
@FocusState var tapbackFocused: Bool
2427
private var allPrivateMessages: [MessageEntity] {
2528
let sent = user.sentMessages ?? []
2629
let received = user.receivedMessages ?? []
@@ -57,6 +60,30 @@ struct UserMessageList: View {
5760
return scenePhase == .active
5861
}
5962

63+
private func processTapback() {
64+
guard !tapbackText.isEmpty, let target = tapbackTargetMessage else { return }
65+
let emojiToSend = tapbackText
66+
let destination = MessageDestination.user(user)
67+
68+
Task {
69+
do {
70+
try await accessoryManager.sendMessage(
71+
message: emojiToSend,
72+
toUserNum: destination.userNum,
73+
channel: destination.channelNum,
74+
isEmoji: true,
75+
replyID: target.messageId
76+
)
77+
} catch {
78+
Logger.services.warning("Failed to send tapback.")
79+
}
80+
}
81+
82+
tapbackText = ""
83+
tapbackFocused = false
84+
tapbackTargetMessage = nil
85+
}
86+
6087
var body: some View {
6188
// Cast user.messageList to an array for easier indexing and ForEach.
6289
let messages: [MessageEntity] = Array(allPrivateMessages)
@@ -86,7 +113,21 @@ struct UserMessageList: View {
86113
messageFieldFocused: $messageFieldFocused,
87114
messageToHighlight: $messageToHighlight,
88115
scrollView: scrollView,
89-
onInteractionComplete: handleInteractionComplete
116+
onInteractionComplete: handleInteractionComplete,
117+
onTapback: { message in
118+
tapbackTargetMessage = message
119+
tapbackFocused = true
120+
#if targetEnvironment(macCatalyst)
121+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
122+
if let nsApp = NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication") as? NSObject {
123+
let selector = NSSelectorFromString("orderFrontCharacterPalette:")
124+
if nsApp.responds(to: selector) {
125+
nsApp.perform(selector, with: nil)
126+
}
127+
}
128+
}
129+
#endif
130+
}
90131
)
91132
.onAppear {
92133
// Only mark as read if the app is in the foreground
@@ -119,6 +160,26 @@ struct UserMessageList: View {
119160
}
120161
}
121162
}
163+
.onChange(of: tapbackFocused) {
164+
if tapbackFocused, let target = tapbackTargetMessage {
165+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
166+
withAnimation {
167+
scrollView.scrollTo(target.messageId, anchor: .center)
168+
}
169+
}
170+
}
171+
}
172+
.background {
173+
TextField("", text: $tapbackText)
174+
.keyboardType(.emoji)
175+
.focused($tapbackFocused)
176+
.frame(width: 1, height: 1)
177+
.opacity(0.01)
178+
.allowsHitTesting(false)
179+
.onChange(of: tapbackText) {
180+
processTapback()
181+
}
182+
}
122183
}
123184
TextMessageField(
124185
destination: .user(user),

Meshtastic/Views/Messages/UserMessageRow.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ struct UserMessageRow: View {
2222
@Binding var messageToHighlight: Int64
2323
let scrollView: ScrollViewProxy
2424
let onInteractionComplete: () -> Void
25+
let onTapback: (MessageEntity) -> Void
2526

2627
private var isCurrentUser: Bool {
2728
Int64(preferredPeripheralNum) == message.fromUser?.num
@@ -36,7 +37,8 @@ struct UserMessageRow: View {
3637
messageFieldFocused: FocusState<Bool>.Binding,
3738
messageToHighlight: Binding<Int64>,
3839
scrollView: ScrollViewProxy,
39-
onInteractionComplete: @escaping () -> Void) {
40+
onInteractionComplete: @escaping () -> Void,
41+
onTapback: @escaping (MessageEntity) -> Void) {
4042
// Initialize ObservedObject with the concrete instance
4143
self.message = message
4244
self.allMessages = allMessages
@@ -48,6 +50,7 @@ struct UserMessageRow: View {
4850
self._messageToHighlight = messageToHighlight
4951
self.scrollView = scrollView
5052
self.onInteractionComplete = onInteractionComplete
53+
self.onTapback = onTapback
5154
}
5255

5356
var body: some View {
@@ -127,8 +130,8 @@ struct UserMessageRow: View {
127130
isCurrentUser: isCurrentUser
128131
) {
129132
self.replyMessageId = message.messageId
130-
self.messageFieldFocused = true
131-
}
133+
self.messageFieldFocused = true } onTapback: {
134+
onTapback(message) }
132135

133136
if isCurrentUser && message.canRetry || (isCurrentUser && message.receivedACK && !message.realACK) {
134137
RetryButton(message: message, destination: .user(user))

0 commit comments

Comments
 (0)