mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge remote-tracking branch 'origin/2.7.7' into tak-server
This commit is contained in:
commit
08cddc8cc5
13 changed files with 267 additions and 71 deletions
|
|
@ -14348,6 +14348,7 @@
|
|||
}
|
||||
},
|
||||
"Favorited and ignored nodes are always retained. Nodes without PKC keys are cleared from the app database on the schedule set by the user, nodes with PKC keys are cleared only if the interval is set to 7 days or longer. This feature only purges nodes from the app that are not stored in the device node database." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -14368,6 +14369,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Favorited and ignored nodes are always retained. Other nodes are cleared from the app database on the schedule set by the user. (Nodes with PKC keys are always retained for at least 7 days.) This feature only purges nodes from the app that are not stored in the device node database." : {
|
||||
|
||||
},
|
||||
"Favorites" : {
|
||||
"localizations" : {
|
||||
|
|
@ -31683,6 +31687,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select an emoji" : {
|
||||
|
||||
},
|
||||
"Select Channel" : {
|
||||
"localizations" : {
|
||||
|
|
@ -42402,4 +42409,4 @@
|
|||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,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 */; };
|
||||
|
|
@ -453,6 +454,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>"; };
|
||||
|
|
@ -1307,6 +1309,7 @@
|
|||
D93068D62B8146690066FBC8 /* MessageText.swift */,
|
||||
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */,
|
||||
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */,
|
||||
D93068D92B81509D0066FBC8 /* TapbackInputView.swift */,
|
||||
);
|
||||
path = Messages;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1868,6 +1871,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 */,
|
||||
|
|
@ -2173,7 +2177,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -2208,7 +2212,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -2240,7 +2244,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -2273,7 +2277,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.7.6;
|
||||
MARKETING_VERSION = 2.7.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -52,8 +52,12 @@ extension AccessoryManager {
|
|||
existing.rssi = newDevice.rssi
|
||||
self.devices[index] = existing
|
||||
} else {
|
||||
// This is a new device, add it to our list
|
||||
self.devices.append(newDevice)
|
||||
// This is a new device, add it to our list if we are in the foreground
|
||||
if !(self.isInBackground) {
|
||||
self.devices.append(newDevice)
|
||||
} else {
|
||||
Logger.transport.debug("🔎 [Discovery] Found a new device but not in the foreground, not adding to our list: peripheral \(newDevice.name)")
|
||||
}
|
||||
}
|
||||
|
||||
if self.shouldAutomaticallyConnectToPreferredPeripheral,
|
||||
|
|
|
|||
|
|
@ -441,8 +441,6 @@ extension AccessoryManager {
|
|||
Logger.services.error("Error while sending saveChannelSet request. No active device.")
|
||||
throw AccessoryError.ioFailed("No active device")
|
||||
}
|
||||
var i: Int32 = 0
|
||||
var myInfo: MyInfoEntity
|
||||
// Before we get started delete the existing channels from the myNodeInfo
|
||||
if !addChannels {
|
||||
tryClearExistingChannels()
|
||||
|
|
@ -451,64 +449,74 @@ extension AccessoryManager {
|
|||
let decodedString = base64UrlString.base64urlToBase64()
|
||||
if let decodedData = Data(base64Encoded: decodedString) {
|
||||
let channelSet: ChannelSet = try ChannelSet(serializedBytes: decodedData)
|
||||
|
||||
var myInfo: MyInfoEntity!
|
||||
var i: Int32 = 0
|
||||
|
||||
if addChannels {
|
||||
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
|
||||
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum))
|
||||
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
|
||||
if fetchedMyInfo.count != 1 {
|
||||
throw AccessoryError.appError("MyInfo not found")
|
||||
}
|
||||
|
||||
// We are trying to add a channel so lets get the last index
|
||||
myInfo = fetchedMyInfo[0]
|
||||
i = Int32(myInfo.channels?.count ?? -1)
|
||||
|
||||
// Bail out if the index is negative or bigger than our max of 8
|
||||
if i < 0 || i > 8 {
|
||||
throw AccessoryError.appError("Index out of range \(i)")
|
||||
}
|
||||
}
|
||||
|
||||
for cs in channelSet.settings {
|
||||
|
||||
if addChannels {
|
||||
// We are trying to add a channel so lets get the last index
|
||||
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
|
||||
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum))
|
||||
do {
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
|
||||
if fetchedMyInfo.count == 1 {
|
||||
i = Int32(fetchedMyInfo[0].channels?.count ?? -1)
|
||||
myInfo = fetchedMyInfo[0]
|
||||
// Bail out if the index is negative or bigger than our max of 8
|
||||
if i < 0 || i > 8 {
|
||||
throw AccessoryError.appError("Index out of range \(i)")
|
||||
}
|
||||
// Bail out if there are no channels or if the same channel name already exists
|
||||
guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else {
|
||||
throw AccessoryError.appError("No channels or channel")
|
||||
}
|
||||
if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity {
|
||||
throw AccessoryError.appError("Channel already exists")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)")
|
||||
guard let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet else {
|
||||
throw AccessoryError.appError("No channels or channel")
|
||||
}
|
||||
|
||||
// Bail out if there are no channels or if the same channel name already exists
|
||||
if mutableChannels.first(where: { ($0 as AnyObject).name == cs.name }) is ChannelEntity {
|
||||
throw AccessoryError.appError("Channel already exists")
|
||||
}
|
||||
}
|
||||
|
||||
var chan = Channel()
|
||||
if i == 0 {
|
||||
chan.role = Channel.Role.primary
|
||||
} else {
|
||||
chan.role = Channel.Role.secondary
|
||||
}
|
||||
chan.role = (i == 0) ? .primary : .secondary
|
||||
chan.settings = cs
|
||||
chan.index = i
|
||||
i += 1
|
||||
|
||||
var adminPacket = AdminMessage()
|
||||
adminPacket.setChannel = chan
|
||||
var meshPacket: MeshPacket = MeshPacket()
|
||||
|
||||
var meshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(deviceNum)
|
||||
meshPacket.from = UInt32(deviceNum)
|
||||
meshPacket.from = UInt32(deviceNum)
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.wantAck = true
|
||||
meshPacket.channel = 0
|
||||
guard let adminData: Data = try? adminPacket.serializedData() else {
|
||||
|
||||
guard let adminData = try? adminPacket.serializedData() else {
|
||||
throw AccessoryError.ioFailed("saveChannelSet: Unable to serialize Admin packet")
|
||||
}
|
||||
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = adminData
|
||||
dataMessage.portnum = PortNum.adminApp
|
||||
meshPacket.decoded = dataMessage
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
let logString = String.localizedStringWithFormat("Sent a Channel for: %@ Channel Index %d".localized, String(deviceNum), chan.index)
|
||||
try await send(toRadio, debugDescription: logString)
|
||||
channelPacket(channel: chan, fromNum: self.activeDeviceNum ?? 0, context: context)
|
||||
}
|
||||
if !addChannels {
|
||||
// Save the LoRa Config and the device will reboot
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
@Published var lastConnectionError: Error?
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var isInBackground: Bool = false
|
||||
|
||||
var activeConnection: (device: Device, connection: any Connection)?
|
||||
|
||||
|
|
@ -196,6 +197,8 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Logger.transport.error("Unable to send wantConfig (config): No device connected")
|
||||
return
|
||||
}
|
||||
|
||||
_ = clearStaleNodes(nodeExpireDays: Int(UserDefaults.purgeStaleNodeDays), context: self.context)
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
var toRadio: ToRadio = ToRadio()
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ extension UserDefaults {
|
|||
case showDeviceOnboarding
|
||||
case usageDataAndCrashReporting
|
||||
case autoconnectOnDiscovery
|
||||
case purgeStaleNodeDays
|
||||
case manualConnections
|
||||
case testIntEnum
|
||||
}
|
||||
|
|
@ -178,6 +179,9 @@ extension UserDefaults {
|
|||
@UserDefault(.autoconnectOnDiscovery, defaultValue: true)
|
||||
static var autoconnectOnDiscovery: Bool
|
||||
|
||||
@UserDefault(.purgeStaleNodeDays, defaultValue: 0)
|
||||
static var purgeStaleNodeDays: Double
|
||||
|
||||
@UserDefault(.testIntEnum, defaultValue: .one)
|
||||
static var testIntEnum: TestIntEnum
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -881,8 +881,8 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage
|
|||
let meshActivity = Activity<MeshActivityAttributes>.activities.first(where: { $0.attributes.nodeNum == connectedNode })
|
||||
if meshActivity != nil {
|
||||
Task {
|
||||
await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration)
|
||||
// await meshActivity?.update(updatedContent)
|
||||
// await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration)
|
||||
await meshActivity?.update(updatedContent)
|
||||
Logger.services.debug("Updated live activity.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ struct MeshtasticAppleApp: App {
|
|||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { (_, newScenePhase) in
|
||||
accessoryManager.isInBackground = (newScenePhase == .background)
|
||||
switch newScenePhase {
|
||||
case .background:
|
||||
Logger.services.info("🎬 [App] Scene is in the background")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ struct AppSettings: View {
|
|||
Text("180")
|
||||
}
|
||||
}
|
||||
Text("Favorited and ignored nodes are always retained. Nodes without PKC keys are cleared from the app database on the schedule set by the user, nodes with PKC keys are cleared only if the interval is set to 7 days or longer. This feature only purges nodes from the app that are not stored in the device node database.")
|
||||
Text("Favorited and ignored nodes are always retained. Other nodes are cleared from the app database on the schedule set by the user. (Nodes with PKC keys are always retained for at least 7 days.) This feature only purges nodes from the app that are not stored in the device node database.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(idiom == .phone ? .caption : .callout)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue