Merge branch '2.7.8' into tak-server

This commit is contained in:
Ben Meadors 2026-01-19 12:46:07 -06:00 committed by GitHub
commit be971c2d2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 654 additions and 151 deletions

View file

@ -31,7 +31,10 @@ struct MessageContextMenuItems: View {
}
Button("Tapback") {
isShowingTapbackInput = true
// The context menu needs a moment to dismiss before the focus state can be changed.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isShowingTapbackInput = true
}
}
Button(action: onReply) {

View file

@ -30,8 +30,10 @@ struct MessageText: View {
@State private var isShowingTapbackInput = false
@State private var tapbackText = ""
@FocusState private var isTapbackInputFocused: Bool
@State private var tapbackText = ""
var body: some View {
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
Text(markdownText)
@ -96,35 +98,10 @@ struct MessageText: View {
onReply: onReply
)
}
messageContent
.environment(\.openURL, OpenURLAction { url in
saveChannelLink = nil
var addChannels = false
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
// Handle contact URL
ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared)
return .handled // Prevent default browser opening
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
// Handle channel URL
let components = url.absoluteString.components(separatedBy: "#")
guard !components.isEmpty, let lastComponent = components.last else {
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
return .discarded
}
addChannels = Bool(url.query?.contains("add=true") ?? false)
guard let lastComponent = components.last else {
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
self.saveChannelLink = nil
return .discarded
}
let cs = lastComponent.components(separatedBy: "?").first ?? ""
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
Logger.services.debug("Add Channel: \(addChannels, privacy: .public)")
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
return .handled // Prevent default browser opening
}
return .systemAction // Open other URLs in browser
handleURL(url)
})
// Display sheet for channel settings
.sheet(item: $saveChannelLink) { link in
SaveChannelQRCode(
channelSetLink: link.data,
@ -170,17 +147,155 @@ struct MessageText: View {
titleVisibility: .visible
) {
Button("Delete Message", role: .destructive) {
context.delete(message)
do {
try context.save()
} catch {
Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
deleteMessage()
}
Button("Cancel", role: .cancel) {}
}
}
}
private var messageContent: some View {
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
return Text(markdownText)
.tint(Self.linkBlue)
.padding(.vertical, 10)
.padding(.horizontal, 8)
.foregroundColor(.white)
.background(isCurrentUser ? .accentColor : Color(.gray))
.cornerRadius(15)
.background {
TextField("", text: $tapbackText)
.keyboardType(.emoji)
.scrollDismissesKeyboard(.immediately)
.focused($isTapbackInputFocused)
.frame(width: 0, height: 0)
.opacity(0)
.onChange(of: tapbackText) {
processTapback()
}
}
.overlay(messageOverlays)
.contextMenu {
MessageContextMenuItems(
message: message,
tapBackDestination: tapBackDestination,
isCurrentUser: isCurrentUser,
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
isShowingTapbackInput: Binding(
get: { isTapbackInputFocused },
set: { isTapbackInputFocused = $0 }
),
onReply: onReply
)
}
}
@ViewBuilder
private var messageOverlays: some View {
if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted {
VStack(alignment: .trailing) {
Spacer()
HStack {
Spacer()
Image(systemName: "lock.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .green)
.font(.system(size: 20))
.offset(x: 8, y: 8)
}
}
}
if message.portNum == Int32(PortNum.storeForwardApp.rawValue) {
VStack(alignment: .trailing) {
Spacer()
HStack {
Spacer()
Image(systemName: "envelope.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .gray)
.font(.system(size: 20))
.offset(x: 8, y: 8)
}
}
}
if tapBackDestination.overlaySensorMessage && message.portNum == Int32(PortNum.detectionSensorApp.rawValue) {
Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.symbolRenderingMode(.multicolor)
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
.offset(x: 20, y: -20)
}
}
private func handleURL(_ url: URL) -> OpenURLAction.Result {
saveChannelLink = nil
var addChannels = false
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
// Handle contact URL
ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared)
return .handled // Prevent default browser opening
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
// Handle channel URL
let components = url.absoluteString.components(separatedBy: "#")
guard !components.isEmpty, let lastComponent = components.last else {
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
return .discarded
}
addChannels = Bool(url.query?.contains("add=true") ?? false)
guard let lastComponent = components.last else {
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
self.saveChannelLink = nil
return .discarded
}
let cs = lastComponent.components(separatedBy: "?").first ?? ""
self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels)
Logger.services.debug("Add Channel: \(addChannels, privacy: .public)")
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
return .handled // Prevent default browser opening
}
return .systemAction // Open other URLs in browser
}
private func deleteMessage() {
context.delete(message)
do {
try context.save()
} catch {
Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
private func processTapback() {
guard !tapbackText.isEmpty else { return }
let emojiToSend = tapbackText
Task {
do {
try await accessoryManager.sendMessage(
message: emojiToSend,
toUserNum: tapBackDestination.userNum,
channel: tapBackDestination.channelNum,
isEmoji: true,
replyID: message.messageId
)
await MainActor.run {
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.")
}
}
tapbackText = ""
isTapbackInputFocused = false
}
}
private extension MessageDestination {

View file

@ -9,40 +9,25 @@ struct TapbackInputView: View {
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
TextField("Tap to enter emoji", text: $text)
.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: 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 = ""
}
},
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)

View file

@ -0,0 +1,61 @@
import CoreData
import SwiftUI
import OSLog
struct ExchangeUserInfoButton: View {
var node: NodeInfoEntity
var connectedNode: NodeInfoEntity
@EnvironmentObject var accessoryManager: AccessoryManager
@State private var isPresentingUserInfoSentAlert: Bool = false
@State private var isPresentingUserInfoFailedAlert: Bool = false
var body: some View {
Button {
Task {
if let fromUser = connectedNode.user, let toUser = node.user {
do {
_ = try await accessoryManager.exchangeUserInfo(fromUser: fromUser, toUser: toUser)
Task { @MainActor in
isPresentingUserInfoSentAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
isPresentingUserInfoSentAlert = false
}
}
} catch {
Logger.mesh.warning("Failed to exchange user info")
Task { @MainActor in
isPresentingUserInfoFailedAlert = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
isPresentingUserInfoFailedAlert = false
}
}
}
}
}
} label: {
Label {
Text("Exchange User Info")
} icon: {
Image(systemName: "person.2.badge.gearshape")
.symbolRenderingMode(.hierarchical)
}
}.alert(
"User Info Sent",
isPresented: $isPresentingUserInfoSentAlert
) {
Button("OK") { }.keyboardShortcut(.defaultAction)
} message: {
Text("Your user info has been sent with a request for a response with their user info.")
}.alert(
"User Info Exchange Failed",
isPresented: $isPresentingUserInfoFailedAlert
) {
Button("OK") { }.keyboardShortcut(.defaultAction)
} message: {
Text("Failed to exchange user info.")
}
}
}

View file

@ -29,7 +29,6 @@ struct WaypointForm: View {
@State private var expire: Date = Date.now.addingTimeInterval(60 * 480) // 1 minute * 480 = 8 Hours
@State private var locked: Bool = false
@State private var lockedTo: Int64 = 0
@State private var detents: Set<PresentationDetent> = [.medium, .fraction(0.85)]
@State private var selectedDetent: PresentationDetent = .medium
@State private var waypointFailedAlert: Bool = false
@ -111,26 +110,19 @@ struct WaypointForm: View {
HStack {
Text("Icon")
Spacer()
EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji")
TextField("Select an emoji", text: $icon)
.keyboardType(.emoji)
.font(.title)
.focused($iconIsFocused)
.onChange(of: icon) { _, value in
// If you have anything other than emojis in your string make it empty
if !value.onlyEmojis() {
icon = ""
}
// If a second emoji is entered delete the first one
if value.count >= 1 {
if value.count > 1 {
let index = value.index(value.startIndex, offsetBy: 1)
icon = String(value[index])
}
iconIsFocused = false
}
}
}
Toggle(isOn: $expires) {
Label("Expires", systemImage: "clock.badge.xmark")
@ -458,7 +450,6 @@ struct WaypointForm: View {
longitude = waypoint.coordinate.longitude
}
}
.presentationDetents(detents, selection: $selectedDetent)
.presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.85)))
.presentationDragIndicator(.visible)
}

View file

@ -464,6 +464,10 @@ struct NodeDetail: View {
node: node,
connectedNode: connectedNode
)
ExchangeUserInfoButton(
node: node,
connectedNode: connectedNode
)
TraceRouteButton(
node: node
)

View file

@ -120,16 +120,14 @@ struct MeshMap: View {
}
.sheet(item: $selectedWaypoint) { selection in
WaypointForm(waypoint: selection)
.padding()
.presentationDetents([.large])
}
.sheet(item: $editingWaypoint) { selection in
WaypointForm(waypoint: selection, editMode: true)
.padding()
.presentationDetents([.large])
}
.sheet(isPresented: $editingSettings) {
MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap, enabledOverlayConfigs: $enabledOverlayConfigs)
.presentationDetents([.large])
}
.onChange(of: router.navigationState) {
guard case .map = router.navigationState.selectedTab else { return }

View file

@ -253,6 +253,19 @@ fileprivate struct FilteredNodeList: View {
} label: {
Label("Exchange Positions", systemImage: "arrow.triangle.2.circlepath")
}
Button {
Task {
if let fromUser = connectedNode.user, let toUser = node.user {
do {
_ = try await accessoryManager.exchangeUserInfo(fromUser: fromUser, toUser: toUser)
} catch {
Logger.mesh.warning("Failed to exchange user info")
}
}
}
} label: {
Label("Exchange User Info", systemImage: "person.2.badge.gearshape")
}
TraceRouteButton(
node: node
)