mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge branch '2.7.8' into tak-server
This commit is contained in:
commit
be971c2d2d
25 changed files with 654 additions and 151 deletions
|
|
@ -48,7 +48,7 @@ extension AccessoryManager {
|
|||
}
|
||||
|
||||
// Step 1: Setup the connection
|
||||
Step(timeout: .seconds(2)) { @MainActor _ in
|
||||
Step(timeout: .seconds(5)) { @MainActor _ in
|
||||
Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id, privacy: .public)")
|
||||
do {
|
||||
let connection: Connection
|
||||
|
|
@ -352,7 +352,6 @@ actor SequentialSteps {
|
|||
return
|
||||
}
|
||||
isRunning = false
|
||||
return
|
||||
throw AccessoryError.tooManyRetries
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ extension AccessoryManager {
|
|||
let tasks = transports.map { transport in
|
||||
Task {
|
||||
Logger.transport.info("🔎 [Discovery] Discovery stream started for transport \(String(describing: transport.type), privacy: .public)")
|
||||
for await event in transport.discoverDevices() {
|
||||
for await event in await transport.discoverDevices() {
|
||||
continuation.yield(event)
|
||||
}
|
||||
Logger.transport.info("🔎 [Discovery] Discovery stream closed for transport \(String(describing: transport.type), privacy: .public)")
|
||||
|
|
|
|||
|
|
@ -2118,4 +2118,34 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
}
|
||||
|
||||
public func exchangeUserInfo(fromUser: UserEntity, toUser: UserEntity) async throws -> Int64 {
|
||||
|
||||
let userProto = fromUser.toProto()
|
||||
guard let userPayload: Data = try? userProto.serializedData() else {
|
||||
throw AccessoryError.ioFailed("exchangeUserInfo: Unable to serialize User protobuf")
|
||||
}
|
||||
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = userPayload
|
||||
dataMessage.portnum = PortNum.nodeinfoApp
|
||||
dataMessage.wantResponse = true
|
||||
|
||||
var meshPacket: MeshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(toUser.num)
|
||||
meshPacket.from = UInt32(fromUser.num)
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.wantAck = true
|
||||
meshPacket.channel = UInt32(toUser.userNode?.channel ?? 0)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio: ToRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
let logString = String.localizedStringWithFormat("Sent User Info Exchange request from %@ to %@".localized, fromUser.longName ?? "Unknown".localized, toUser.longName ?? "Unknown".localized)
|
||||
try await send(toRadio, debugDescription: logString)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ protocol Connection: Actor {
|
|||
var isConnected: Bool { get }
|
||||
func send(_ data: ToRadio) async throws
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent>
|
||||
func disconnect(withError: Error?, shouldReconnect: Bool) throws
|
||||
func disconnect(withError: Error?, shouldReconnect: Bool) async throws
|
||||
func drainPendingPackets() async throws
|
||||
func startDrainPendingPackets() throws
|
||||
|
||||
|
|
|
|||
|
|
@ -42,10 +42,10 @@ enum DiscoveryEvent {
|
|||
|
||||
protocol Transport {
|
||||
var type: TransportType { get }
|
||||
var status: TransportStatus { get }
|
||||
var status: TransportStatus { get async }
|
||||
|
||||
// Discovers devices asynchronously. For ongoing scans (e.g., BLE), this can yield via AsyncStream.
|
||||
func discoverDevices() -> AsyncStream<DiscoveryEvent>
|
||||
func discoverDevices() async -> AsyncStream<DiscoveryEvent>
|
||||
|
||||
// Connects to a device and returns a Connection.
|
||||
func connect(to device: Device) async throws -> any Connection
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ actor BLEConnection: Connection {
|
|||
self.delegate.setConnection(self)
|
||||
}
|
||||
|
||||
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws {
|
||||
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) async throws {
|
||||
if peripheral.state == .connected {
|
||||
if let characteristic = FROMRADIO_characteristic {
|
||||
peripheral.setNotifyValue(false, for: characteristic)
|
||||
|
|
@ -82,7 +82,7 @@ actor BLEConnection: Connection {
|
|||
}
|
||||
}
|
||||
|
||||
transport?.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
await transport?.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
peripheral.delegate = nil
|
||||
|
|
@ -217,8 +217,8 @@ actor BLEConnection: Connection {
|
|||
self.connectContinuation = nil
|
||||
}
|
||||
|
||||
private func notifyTransportOfDisconnect() {
|
||||
transport?.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
private func notifyTransportOfDisconnect() async {
|
||||
await transport?.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
}
|
||||
|
||||
func startRSSITask() {
|
||||
|
|
@ -450,7 +450,7 @@ actor BLEConnection: Connection {
|
|||
}
|
||||
|
||||
// Inform the active connection that there was an error and it should disconnect
|
||||
try self.disconnect(withError: error, shouldReconnect: shouldReconnect)
|
||||
try await self.disconnect(withError: error, shouldReconnect: shouldReconnect)
|
||||
}
|
||||
|
||||
func appDidEnterBackground() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
class BLETransport: Transport {
|
||||
actor BLETransport: Transport {
|
||||
|
||||
let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD")
|
||||
private let kCentralRestoreID = "com.meshtastic.central"
|
||||
|
|
@ -31,7 +31,7 @@ class BLETransport: Transport {
|
|||
private var cleanupTask: Task<Void, Never>?
|
||||
|
||||
// Transport properties
|
||||
var supportsManualConnection: Bool = false
|
||||
let supportsManualConnection: Bool = false
|
||||
let requiresPeriodicHeartbeat = false
|
||||
|
||||
init() {
|
||||
|
|
@ -46,19 +46,24 @@ class BLETransport: Transport {
|
|||
self.delegate.setTransport(self)
|
||||
}
|
||||
|
||||
nonisolated func discoverDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
private func setDiscoveredDeviceContinuation(_ cont: AsyncStream<DiscoveryEvent>.Continuation?) {
|
||||
self.discoveredDeviceContinuation = cont
|
||||
}
|
||||
|
||||
func discoverDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
AsyncStream { cont in
|
||||
Task {
|
||||
self.discoveredDeviceContinuation = cont
|
||||
await self.setDiscoveredDeviceContinuation(cont)
|
||||
|
||||
// This gate is opened when the CBCentralManager is in poweredOn state.
|
||||
// Its probably open already, but just to be sure in case we get here too quickly.
|
||||
try await self.setupCompleteGate.wait()
|
||||
|
||||
if !restoreInProgress {
|
||||
if await !self.restoreInProgress {
|
||||
centralManager.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
|
||||
|
||||
for alreadyDiscoveredPeripheral in self.discoveredPeripherals.values.map({$0.peripheral}) {
|
||||
let peripherals = await self.discoveredPeripherals.values.map({$0.peripheral})
|
||||
for alreadyDiscoveredPeripheral in peripherals {
|
||||
let device = Device(id: alreadyDiscoveredPeripheral.identifier,
|
||||
name: alreadyDiscoveredPeripheral.name ?? "Unknown",
|
||||
transportType: .ble,
|
||||
|
|
@ -66,11 +71,13 @@ class BLETransport: Transport {
|
|||
cont.yield(.deviceFound(device))
|
||||
}
|
||||
}
|
||||
setupCleanupTask()
|
||||
await setupCleanupTask()
|
||||
}
|
||||
cont.onTermination = { _ in
|
||||
Logger.transport.error("🛜 [BLE] Discovery event stream has been canecelled.")
|
||||
self.stopScanning()
|
||||
Task {
|
||||
await self.stopScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +195,12 @@ class BLETransport: Transport {
|
|||
}
|
||||
}
|
||||
|
||||
private func cancelConnectContinuation(for peripheral: CBPeripheral) {
|
||||
self.connectContinuation?.resume(throwing: CancellationError())
|
||||
self.connectContinuation = nil
|
||||
self.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
}
|
||||
|
||||
func connect(to device: Device) async throws -> any Connection {
|
||||
guard let peripheral = discoveredPeripherals[UUID(uuidString: device.identifier)!] else {
|
||||
throw AccessoryError.connectionFailed("Peripheral not found")
|
||||
|
|
@ -211,9 +224,9 @@ class BLETransport: Transport {
|
|||
self.activeConnection = newConnection
|
||||
return newConnection
|
||||
} onCancel: {
|
||||
self.connectContinuation?.resume(throwing: CancellationError())
|
||||
self.connectContinuation = nil
|
||||
self.connectionDidDisconnect(fromPeripheral: peripheral.peripheral)
|
||||
Task {
|
||||
await self.cancelConnectContinuation(for: peripheral.peripheral)
|
||||
}
|
||||
}
|
||||
Logger.transport.debug("🛜 [BLE] Connect complete.")
|
||||
return returnConnection
|
||||
|
|
@ -226,7 +239,7 @@ class BLETransport: Transport {
|
|||
Task {
|
||||
if await connection.peripheral.identifier == peripheral.identifier {
|
||||
try await connection.disconnect(withError: AccessoryError.disconnected("BLE connection lost"), shouldReconnect: true)
|
||||
self.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
await self.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -264,7 +277,7 @@ class BLETransport: Transport {
|
|||
Logger.transport.debug("🛜 [BLETransport] Error while connecting. Disconnecting the active connection.")
|
||||
Task {
|
||||
try? await activeConnection.disconnect(withError: error, shouldReconnect: shouldReconnect)
|
||||
self.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
await self.connectionDidDisconnect(fromPeripheral: peripheral)
|
||||
}
|
||||
} else {
|
||||
Logger.transport.error("🚨 [BLETransport] unhandled error. May be in an inconsistent state.")
|
||||
|
|
@ -372,15 +385,20 @@ class BLETransport: Transport {
|
|||
}
|
||||
|
||||
Logger.transport.error("🛜 [BLE] Restoring peripheral in connecting state. ✅ didConnect Received!")
|
||||
Task { @MainActor in
|
||||
// In this case we need a full reconnect, so do the wantConfig, wantDatabase, and versionCheck
|
||||
try? await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: true, wantDatabase: true, versionCheck: true)
|
||||
restoreInProgress = false
|
||||
let connectTask = Task { @MainActor in
|
||||
try await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: true, wantDatabase: true, versionCheck: true)
|
||||
}
|
||||
|
||||
do {
|
||||
try await connectTask.value
|
||||
} catch {
|
||||
Logger.transport.error("🛜 [BLE] Error connecting during state restoration: \(error, privacy: .public)")
|
||||
}
|
||||
self.restoreInProgress = false
|
||||
} catch {
|
||||
// We had a conneciton failure during restoration.
|
||||
// We had a connection failure during restoration.
|
||||
Logger.transport.error("🛜 [BLE] Error restoring peripheral in connecting state. \(error, privacy: .public)")
|
||||
restoreInProgress = false
|
||||
self.restoreInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -388,22 +406,28 @@ class BLETransport: Transport {
|
|||
let restoredConnection = BLEConnection(peripheral: peripheral, central: central, transport: self)
|
||||
self.activeConnection = restoredConnection
|
||||
Logger.transport.error("🛜 [BLE] Peripheral Connection found and state is connected setting this connection as the activeConnection.")
|
||||
Task { @MainActor in
|
||||
let connectTask = Task { @MainActor in
|
||||
// In this case we need a full reconnect, so do the wantConfig, wantDatabase, and versionCheck
|
||||
try? await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: false, wantDatabase: false, versionCheck: false)
|
||||
restoreInProgress = false
|
||||
try await AccessoryManager.shared.connect(to: device, withConnection: restoredConnection, wantConfig: false, wantDatabase: false, versionCheck: false)
|
||||
}
|
||||
do {
|
||||
try await connectTask.value
|
||||
} catch {
|
||||
Logger.transport.error("🛜 [BLE] Error connecting during state restoration: \(error, privacy: .public)")
|
||||
}
|
||||
|
||||
self.restoreInProgress = false
|
||||
Logger.transport.error("🛜 [BLE] Connection state successfully restored in the background.")
|
||||
default:
|
||||
// Since we're not going to attempt to reconnect in then allow normal device discovery
|
||||
Logger.transport.error("🛜 [BLE] Unhandled state restoration for state: \(cbPeripheralStateDescription(peripheral.state), privacy: .public).")
|
||||
restoreInProgress = false
|
||||
self.restoreInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func device(forManualConnection: String) -> Device? {
|
||||
nonisolated func device(forManualConnection: String) -> Device? {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -438,33 +462,33 @@ class BLEDelegate: NSObject, CBCentralManagerDelegate {
|
|||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
transport?.handleCentralState(central.state, central: central)
|
||||
Task { await transport?.handleCentralState(central.state, central: central) }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
transport?.didDiscover(peripheral: peripheral, rssi: RSSI)
|
||||
Task { await transport?.didDiscover(peripheral: peripheral, rssi: RSSI) }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
transport?.handleDidConnect(peripheral: peripheral, central: central)
|
||||
Task { await transport?.handleDidConnect(peripheral: peripheral, central: central) }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
transport?.handleDidFailToConnect(peripheral: peripheral, error: error)
|
||||
Task { await transport?.handleDidFailToConnect(peripheral: peripheral, error: error) }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
if let error = error as? NSError {
|
||||
Logger.transport.error("🛜 [BLETransport] Error while disconnecting peripheral: \(peripheral.name ?? ""): \(error)")
|
||||
transport?.handlePeripheralDisconnectError(peripheral: peripheral, error: error)
|
||||
Task { await transport?.handlePeripheralDisconnectError(peripheral: peripheral, error: error) }
|
||||
} else {
|
||||
Logger.transport.error("🛜 [BLETransport] Did succesfully disconnect peripheral: \(peripheral.name ?? "")")
|
||||
transport?.handlePeripheralDisconnect(peripheral: peripheral)
|
||||
Task { await transport?.handlePeripheralDisconnect(peripheral: peripheral) }
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
self.transport?.handleWillRestoreState(dict: dict, central: central)
|
||||
Task { await self.transport?.handleWillRestoreState(dict: dict, central: central) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
13
Meshtastic/Extensions/UIKeyboardType.swift
Normal file
13
Meshtastic/Extensions/UIKeyboardType.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// UIKeyboard.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 1/7/26.
|
||||
//
|
||||
import UIKit
|
||||
|
||||
extension UIKeyboardType {
|
||||
static var emoji: UIKeyboardType {
|
||||
return UIKeyboardType(rawValue: 124) ?? .default
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ struct EmojiOnlyTextField: UIViewRepresentable {
|
|||
@Binding var text: String
|
||||
var placeholder: String = ""
|
||||
var onBecomeFirstResponder: (() -> Void)?
|
||||
var onKeyboardTypeChanged: ((Bool) -> Void)? // true if emoji, false otherwise
|
||||
var onKeyboardTypeChanged: ((Bool) -> Void)? // true if NOT emoji (should dismiss), false if emoji
|
||||
var onKeyboardDismissed: (() -> Void)? // Called when keyboard is dismissed
|
||||
|
||||
func makeUIView(context: Context) -> SwiftUIEmojiTextField {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,6 +464,10 @@ struct NodeDetail: View {
|
|||
node: node,
|
||||
connectedNode: connectedNode
|
||||
)
|
||||
ExchangeUserInfoButton(
|
||||
node: node,
|
||||
connectedNode: connectedNode
|
||||
)
|
||||
TraceRouteButton(
|
||||
node: node
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue