mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
working
This commit is contained in:
parent
d9e169142e
commit
27a9546c46
8 changed files with 1462 additions and 182 deletions
|
|
@ -803,6 +803,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ dBm" : {
|
||||
|
||||
},
|
||||
"%@, %@" : {
|
||||
"localizations" : {
|
||||
|
|
@ -1086,6 +1089,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"%d %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$d %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%d Hops" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
|
@ -2569,6 +2582,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ack SNR" : {
|
||||
|
||||
},
|
||||
"Ack SNR: %@ dB" : {
|
||||
"localizations" : {
|
||||
|
|
@ -2603,6 +2619,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ack Time" : {
|
||||
|
||||
},
|
||||
"Ack Time: %@" : {
|
||||
"localizations" : {
|
||||
|
|
@ -11870,6 +11889,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delivered" : {
|
||||
|
||||
},
|
||||
"Description" : {
|
||||
"localizations" : {
|
||||
|
|
@ -18709,6 +18731,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"From" : {
|
||||
|
||||
},
|
||||
"From Radio (RX): %lld" : {
|
||||
"localizations" : {
|
||||
|
|
@ -21544,6 +21569,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"In Reply To" : {
|
||||
|
||||
},
|
||||
"Include" : {
|
||||
"localizations" : {
|
||||
|
|
@ -24976,6 +25004,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message ID" : {
|
||||
|
||||
},
|
||||
"Message Info" : {
|
||||
|
||||
},
|
||||
"Message received from the text message app." : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -25104,6 +25138,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message Text" : {
|
||||
|
||||
},
|
||||
"Messages" : {
|
||||
"localizations" : {
|
||||
|
|
@ -30346,6 +30383,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pending..." : {
|
||||
|
||||
},
|
||||
"Perform a factory reset on the node you are connected to" : {
|
||||
"localizations" : {
|
||||
|
|
@ -33153,6 +33193,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Read" : {
|
||||
|
||||
},
|
||||
"Reboot" : {
|
||||
"localizations" : {
|
||||
|
|
@ -33830,6 +33873,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Relay" : {
|
||||
|
||||
},
|
||||
"Relayed by" : {
|
||||
|
||||
},
|
||||
"Relayed by %d %@" : {
|
||||
"localizations" : {
|
||||
|
|
@ -35727,6 +35776,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RSSI" : {
|
||||
|
||||
},
|
||||
"RSSI %@ dBm" : {
|
||||
"localizations" : {
|
||||
|
|
@ -41604,7 +41656,6 @@
|
|||
}
|
||||
},
|
||||
"TAK" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -45497,6 +45548,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Type an emoji to send as a tapback" : {
|
||||
"comment" : "A description below the text field in the tapback picker view, instructing the user to type an emoji.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"UDP Broadcast" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -49740,4 +49795,4 @@
|
|||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +108,7 @@
|
|||
B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; };
|
||||
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
|
||||
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; };
|
||||
BC4E1A742F4BA29C0065501B /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = EXYTECHAT123456789ABCD2 /* ExyteChat */; };
|
||||
BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6B45FE2CB2F98900723CEB /* SaveChannelSettingsIntent.swift */; };
|
||||
BCA9A82C2EC802CF00166292 /* CompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA9A82B2EC802CF00166292 /* CompassView.swift */; };
|
||||
BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB35B4E2E5FC41E00B04F60 /* MessageNodeIntent.swift */; };
|
||||
|
|
@ -715,6 +716,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BC4E1A742F4BA29C0065501B /* ExyteChat in Frameworks */,
|
||||
102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */,
|
||||
25A978BA2C13F8ED0003AAE7 /* MeshtasticProtobufs in Frameworks */,
|
||||
102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */,
|
||||
|
|
@ -1510,6 +1512,7 @@
|
|||
102B5EB02E172F41003D191E /* DatadogRUM */,
|
||||
10D109F12E2047D600536CE6 /* DatadogSessionReplay */,
|
||||
10D109F32E2047D600536CE6 /* DatadogTrace */,
|
||||
EXYTECHAT123456789ABCD2 /* ExyteChat */,
|
||||
);
|
||||
productName = MeshtasticClient;
|
||||
productReference = DDC2E15426CE248E0042C5E4 /* Meshtastic.app */;
|
||||
|
|
@ -1581,6 +1584,7 @@
|
|||
25A978B82C13F8ED0003AAE7 /* XCLocalSwiftPackageReference "MeshtasticProtobufs" */,
|
||||
259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */,
|
||||
102B5EA92E172F41003D191E /* XCRemoteSwiftPackageReference "dd-sdk-ios" */,
|
||||
BC4E1A772F4BA3500065501B /* XCRemoteSwiftPackageReference "Chat" */,
|
||||
);
|
||||
productRefGroup = DDC2E15526CE248E0042C5E4 /* Products */;
|
||||
projectDirPath = "";
|
||||
|
|
@ -2356,6 +2360,14 @@
|
|||
minimumVersion = 1.26.0;
|
||||
};
|
||||
};
|
||||
BC4E1A772F4BA3500065501B /* XCRemoteSwiftPackageReference "Chat" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/exyte/Chat.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.7.6;
|
||||
};
|
||||
};
|
||||
DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/emqx/CocoaMQTT";
|
||||
|
|
@ -2364,6 +2376,14 @@
|
|||
minimumVersion = 2.0.0;
|
||||
};
|
||||
};
|
||||
EXYTECHAT123456789ABCD1 /* XCRemoteSwiftPackageReference "Chat" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/exyte/Chat.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
|
|
@ -2410,6 +2430,11 @@
|
|||
package = DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */;
|
||||
productName = CocoaMQTT;
|
||||
};
|
||||
EXYTECHAT123456789ABCD2 /* ExyteChat */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EXYTECHAT123456789ABCD1 /* XCRemoteSwiftPackageReference "Chat" */;
|
||||
productName = ExyteChat;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,33 @@
|
|||
{
|
||||
"originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4",
|
||||
"originHash" : "4f3205f567bace7f065677192bcfcea8bf01bc42c5efdbe9058f03b10f5cccd6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "activityindicatorview",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/ActivityIndicatorView",
|
||||
"state" : {
|
||||
"revision" : "36140867802ae4a1d2b11490bcbbefe058001d14",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "anchoredpopup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/AnchoredPopup.git",
|
||||
"state" : {
|
||||
"revision" : "dfcd04d7a265808333674a7ccf001838102a391e",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "chat",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/Chat.git",
|
||||
"state" : {
|
||||
"revision" : "ecf7edb1ba6d4406543af3796c512005dc013802",
|
||||
"version" : "2.7.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -15,8 +42,53 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
|
||||
"state" : {
|
||||
"revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e",
|
||||
"version" : "3.4.0"
|
||||
"revision" : "4b9d2c543dec767b181b18a6ba016ca1fa297027",
|
||||
"version" : "3.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "giphy-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Giphy/giphy-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "f7a8edf513ab14147ef33c942b10b45cdac1e765",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "e227df15448d2ad1a5d4e4c49722a71c68f9058a",
|
||||
"version" : "8.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "kscrash",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kstenerud/KSCrash.git",
|
||||
"state" : {
|
||||
"revision" : "95a8895d75f3c22aa9ad9f2a15d2fbd97b0a55e2",
|
||||
"version" : "2.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "libwebp-xcode",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/libwebp-Xcode",
|
||||
"state" : {
|
||||
"revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mediapicker",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/MediaPicker.git",
|
||||
"state" : {
|
||||
"revision" : "fd71c0b560aa79eee1b87f73ce93fac5576a01df",
|
||||
"version" : "3.2.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -29,21 +101,12 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift-packages",
|
||||
"identity" : "opentelemetry-swift-core",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/opentelemetry-swift-packages.git",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift-core",
|
||||
"state" : {
|
||||
"revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e",
|
||||
"version" : "1.13.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "plcrashreporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/plcrashreporter.git",
|
||||
"state" : {
|
||||
"revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65",
|
||||
"version" : "1.12.0"
|
||||
"revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -55,13 +118,22 @@
|
|||
"version" : "4.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
"revision" : "9bbb079b69af9d66470ced85461bf13bb40becac",
|
||||
"version" : "1.35.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,33 @@
|
|||
{
|
||||
"originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e",
|
||||
"originHash" : "4cc10f5e2e37a0271a5ab373060c79138767c500c1475a2c04a71631e136f3b4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "activityindicatorview",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/ActivityIndicatorView",
|
||||
"state" : {
|
||||
"revision" : "36140867802ae4a1d2b11490bcbbefe058001d14",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "anchoredpopup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/AnchoredPopup.git",
|
||||
"state" : {
|
||||
"revision" : "dfcd04d7a265808333674a7ccf001838102a391e",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "chat",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/Chat.git",
|
||||
"state" : {
|
||||
"revision" : "ecf7edb1ba6d4406543af3796c512005dc013802",
|
||||
"version" : "2.7.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -19,6 +46,42 @@
|
|||
"version" : "3.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "giphy-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Giphy/giphy-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "f7a8edf513ab14147ef33c942b10b45cdac1e765",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "e227df15448d2ad1a5d4e4c49722a71c68f9058a",
|
||||
"version" : "8.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "libwebp-xcode",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/libwebp-Xcode",
|
||||
"state" : {
|
||||
"revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mediapicker",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/exyte/MediaPicker.git",
|
||||
"state" : {
|
||||
"revision" : "fd71c0b560aa79eee1b87f73ce93fac5576a01df",
|
||||
"version" : "3.2.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mqttcocoaasyncsocket",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -2,13 +2,82 @@
|
|||
// ChannelMessageList.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Garth Vander Houwen on 12/24/21.
|
||||
// Migrated to use ExyteChat library with full functionality
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import MeshtasticProtobufs
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import ExyteChat
|
||||
|
||||
private enum ChatMessageAction: MessageMenuAction {
|
||||
case reply
|
||||
case copy
|
||||
case info
|
||||
case tapback
|
||||
|
||||
func title() -> String {
|
||||
switch self {
|
||||
case .reply: return "Reply"
|
||||
case .copy: return "Copy"
|
||||
case .info: return "Info"
|
||||
case .tapback: return "Tapback"
|
||||
}
|
||||
}
|
||||
|
||||
func icon() -> Image {
|
||||
switch self {
|
||||
case .reply: return Image(systemName: "arrowshape.turn.up.left")
|
||||
case .copy: return Image(systemName: "doc.on.doc")
|
||||
case .info: return Image(systemName: "info.circle")
|
||||
case .tapback: return Image(systemName: "hand.thumbsup.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == MessageEntity {
|
||||
func convertToChatMessages(currentUserNum: Int64, preferredPeripheralNum: Int) -> [ExyteChat.Message] {
|
||||
return self.map { entity in
|
||||
let messageId = String(entity.messageId)
|
||||
let fromUserEntity = entity.fromUser
|
||||
|
||||
let isCurrentUser: Bool
|
||||
if let fromUser = fromUserEntity {
|
||||
isCurrentUser = fromUser.num == currentUserNum
|
||||
} else {
|
||||
isCurrentUser = false
|
||||
}
|
||||
|
||||
let user: ExyteChat.User
|
||||
if let fromUser = fromUserEntity {
|
||||
user = ExyteChat.User(
|
||||
id: String(fromUser.num),
|
||||
name: fromUser.longName ?? fromUser.shortName ?? "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
} else {
|
||||
user = ExyteChat.User(
|
||||
id: "unknown",
|
||||
name: "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
}
|
||||
|
||||
return ExyteChat.Message(
|
||||
id: messageId,
|
||||
user: user,
|
||||
status: nil,
|
||||
createdAt: entity.timestamp,
|
||||
text: entity.messagePayload ?? "",
|
||||
attachments: [],
|
||||
replyMessage: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelMessageList: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
|
@ -22,13 +91,16 @@ struct ChannelMessageList: View {
|
|||
@State private var redrawTapbacksTrigger = UUID()
|
||||
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
|
||||
@State private var messageToHighlight: Int64 = 0
|
||||
@State private var selectedMessageForDetails: MessageEntity?
|
||||
@State private var showingMessageDetails = false
|
||||
@State private var showingTapbackInput = false
|
||||
@State private var tapbackMessage: MessageEntity?
|
||||
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
|
||||
|
||||
init(myInfo: MyInfoEntity, channel: ChannelEntity) {
|
||||
self.myInfo = myInfo
|
||||
self.channel = channel
|
||||
|
||||
// Configure fetch request here
|
||||
let request: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \MessageEntity.messageTimestamp, ascending: true)
|
||||
|
|
@ -58,79 +130,104 @@ struct ChannelMessageList: View {
|
|||
Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func routerIsShowingThisChannel() -> Bool {
|
||||
guard appState.router.navigationState.selectedTab == .messages else { return false }
|
||||
return scenePhase == .active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Cast allPrivateMessages to an array for easier indexing and ForEach.
|
||||
let messages: [MessageEntity] = Array(allPrivateMessages)
|
||||
|
||||
// Precompute previous message
|
||||
let previousByID: [Int64: MessageEntity?] = {
|
||||
var dict = [Int64: MessageEntity?]()
|
||||
var prev: MessageEntity?
|
||||
for m in messages { dict[m.messageId] = prev; prev = m }
|
||||
return dict
|
||||
}()
|
||||
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(messages, id: \.messageId) { message in
|
||||
let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil
|
||||
|
||||
ChannelMessageRow(
|
||||
message: message,
|
||||
allMessages: allPrivateMessages,
|
||||
previousMessage: previousMessage,
|
||||
preferredPeripheralNum: preferredPeripheralNum,
|
||||
channel: channel,
|
||||
replyMessageId: $replyMessageId,
|
||||
messageFieldFocused: $messageFieldFocused,
|
||||
messageToHighlight: $messageToHighlight,
|
||||
scrollView: scrollView,
|
||||
onInteractionComplete: handleInteractionComplete
|
||||
)
|
||||
.onAppear {
|
||||
// Only mark as read if the app is in the foreground
|
||||
if !message.read && UIApplication.shared.applicationState == .active {
|
||||
message.read = true
|
||||
LocalNotificationManager().cancelNotificationForMessageId(message.messageId)
|
||||
// Race condition, sometimes the app doesn't update unread count if we run this too early
|
||||
// So, run it in the main queue after everything saves and stabilizes
|
||||
DispatchQueue.main.async {
|
||||
markMessagesAsRead()
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("bottomAnchor")
|
||||
}
|
||||
|
||||
func markMessageAsRead(_ message: MessageEntity) {
|
||||
if !message.read {
|
||||
message.read = true
|
||||
do {
|
||||
try context.save()
|
||||
appState.unreadChannelMessages = myInfo.unreadMessages
|
||||
} catch {
|
||||
Logger.data.error("Failed to mark message as read: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.defaultScrollAnchorTopAlignment()
|
||||
.defaultScrollAnchorBottomSizeChanges()
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.onChange(of: messageFieldFocused) {
|
||||
if messageFieldFocused {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextMessageField(
|
||||
destination: .channel(channel),
|
||||
replyMessageId: $replyMessageId,
|
||||
isFocused: $messageFieldFocused
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func retryMessage(_ message: MessageEntity) {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: message.messagePayload ?? "",
|
||||
toUserNum: 0,
|
||||
channel: Int32(channel.index),
|
||||
isEmoji: false,
|
||||
replyID: message.replyID
|
||||
)
|
||||
} catch {
|
||||
Logger.mesh.error("Failed to retry message: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendTapback(_ emoji: String, to message: MessageEntity) {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: emoji,
|
||||
toUserNum: message.fromUser?.num ?? 0,
|
||||
channel: Int32(channel.index),
|
||||
isEmoji: true,
|
||||
replyID: message.messageId
|
||||
)
|
||||
await MainActor.run {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
}
|
||||
} catch {
|
||||
Logger.services.warning("Failed to send tapback.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyMessage(_ text: String) {
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
|
||||
private var currentUserNum: Int64 {
|
||||
Int64(preferredPeripheralNum)
|
||||
}
|
||||
|
||||
private var chatMessages: [Message] {
|
||||
let entities = Array(allPrivateMessages)
|
||||
return entities.convertToChatMessages(
|
||||
currentUserNum: currentUserNum,
|
||||
preferredPeripheralNum: preferredPeripheralNum
|
||||
)
|
||||
}
|
||||
|
||||
private func sendMessage(draft: DraftMessage) {
|
||||
guard !draft.text.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: draft.text,
|
||||
toUserNum: 0,
|
||||
channel: Int32(channel.index),
|
||||
isEmoji: false,
|
||||
replyID: replyMessageId
|
||||
)
|
||||
replyMessageId = 0
|
||||
} catch {
|
||||
Logger.mesh.info("Error sending channel message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let messages = chatMessages
|
||||
|
||||
ChatView(
|
||||
messages: messages,
|
||||
chatType: .conversation,
|
||||
replyMode: .quote
|
||||
) { draft in
|
||||
sendMessage(draft: draft)
|
||||
}
|
||||
.messageUseMarkdown(true)
|
||||
.setAvailableInputs([.text])
|
||||
.showDateHeaders(true)
|
||||
.isScrollEnabled(true)
|
||||
.keyboardDismissMode(.interactive)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
|
|
@ -152,5 +249,443 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingMessageDetails) {
|
||||
if let msg = selectedMessageForDetails {
|
||||
MessageDetailsView(message: msg, destination: .channel(channel))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingTapbackInput) {
|
||||
if let msg = tapbackMessage {
|
||||
TapbackPickerView(message: msg) { emoji in
|
||||
sendTapback(emoji, to: msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelCustomMessageCell: View {
|
||||
let message: Message
|
||||
let currentUserNum: Int64
|
||||
@Binding var replyMessageId: Int64
|
||||
@FocusState.Binding var messageFieldFocused: Bool
|
||||
let channel: ChannelEntity
|
||||
let allMessages: [MessageEntity]
|
||||
let onRead: (MessageEntity) -> Void
|
||||
let onRetry: (MessageEntity) -> Void
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
private var isCurrentUser: Bool {
|
||||
message.user.isCurrentUser
|
||||
}
|
||||
|
||||
private var messageEntity: MessageEntity? {
|
||||
allMessages.first { String($0.messageId) == message.id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .bottom) {
|
||||
if isCurrentUser { Spacer(minLength: 50) }
|
||||
|
||||
if !isCurrentUser {
|
||||
if let msgEntity = messageEntity {
|
||||
CircleText(
|
||||
text: msgEntity.fromUser?.shortName ?? "?",
|
||||
color: Color(UIColor(hex: UInt32(msgEntity.fromUser?.num ?? 0))),
|
||||
circleSize: 50
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
if let nodeNum = msgEntity.fromUser?.num {
|
||||
// Navigate to node detail
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
onRead(msgEntity)
|
||||
}
|
||||
.padding(.all, 5)
|
||||
.offset(y: -7)
|
||||
} else {
|
||||
CircleText(text: "?", color: .gray, circleSize: 50)
|
||||
.padding(.all, 5)
|
||||
.offset(y: -7)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 0) {
|
||||
if !isCurrentUser, let msgEntity = messageEntity {
|
||||
Text("\(msgEntity.fromUser?.longName ?? "Unknown") (\(msgEntity.fromUser?.userId ?? "?"))")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
Text(LocalizedStringKey(message.text))
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 8)
|
||||
.foregroundColor(.white)
|
||||
.background(isCurrentUser ? Color.accentColor : Color.gray)
|
||||
.cornerRadius(15)
|
||||
|
||||
if isCurrentUser, let msgEntity = messageEntity {
|
||||
if msgEntity.canRetry {
|
||||
Button {
|
||||
onRetry(msgEntity)
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msgEntity = messageEntity {
|
||||
ChannelMessageStatusView(message: msgEntity)
|
||||
|
||||
TapbackResponsesView(message: msgEntity) {
|
||||
onRead(msgEntity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
if !isCurrentUser { Spacer(minLength: 50) }
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChannelMessageStatusView: View {
|
||||
@ObservedObject var message: MessageEntity
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isCurrentUser {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
if message.receivedACK {
|
||||
if message.realACK {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
Text(ackErrorVal?.display ?? "Sent")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
Text("Acknowledged by another node")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
} else if message.ackError == 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.yellow)
|
||||
Text("Waiting to be acknowledged. . .")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
} else if message.ackError > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
Text(ackErrorVal?.display ?? "Error")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private var isCurrentUser: Bool {
|
||||
Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num
|
||||
}
|
||||
}
|
||||
|
||||
struct TapbackResponsesView: View {
|
||||
@ObservedObject var message: MessageEntity
|
||||
let onRead: () -> Void
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
var body: some View {
|
||||
let tapbacks = message.tapbacks
|
||||
if !tapbacks.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(tapbacks, id: \.messageId) { tapback in
|
||||
VStack {
|
||||
if let image = tapback.messagePayload?.image(fontSize: 16) {
|
||||
Image(uiImage: image)
|
||||
.font(.caption)
|
||||
}
|
||||
Text("\(tapback.fromUser?.shortName ?? "?")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.onAppear {
|
||||
if !tapback.read {
|
||||
tapback.read = true
|
||||
onRead()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray6)))
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TapbackPickerView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
let message: MessageEntity
|
||||
let onTapbackSelected: (String) -> Void
|
||||
|
||||
@State private var emojiText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Tap to enter emoji", text: $emojiText)
|
||||
.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: emojiText) { oldValue, newValue in
|
||||
if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) {
|
||||
onTapbackSelected(firstEmoji)
|
||||
emojiText = ""
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Type an emoji to send as a tapback")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Tapback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(150)])
|
||||
}
|
||||
|
||||
private func extractFirstEmoji(from string: String) -> String? {
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
let firstChar = string[string.startIndex]
|
||||
|
||||
if firstChar.isEmoji {
|
||||
var emojiEnd = string.index(after: string.startIndex)
|
||||
|
||||
while emojiEnd < string.endIndex {
|
||||
let nextChar = string[emojiEnd]
|
||||
if let scalar = nextChar.unicodeScalars.first,
|
||||
(scalar.properties.isVariationSelector ||
|
||||
scalar.value == 0xFE0F ||
|
||||
(scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) ||
|
||||
scalar.value == 0x200D) {
|
||||
emojiEnd = string.index(after: emojiEnd)
|
||||
} else if nextChar.isEmoji {
|
||||
emojiEnd = string.index(after: emojiEnd)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return String(string[string.startIndex..<emojiEnd])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageDetailsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@ObservedObject var message: MessageEntity
|
||||
let destination: MessageDestination
|
||||
|
||||
@State private var relayDisplay: String? = nil
|
||||
|
||||
private var isCurrentUser: Bool {
|
||||
Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
LabeledContent("From") {
|
||||
Text(message.fromUser?.longName ?? "Unknown")
|
||||
}
|
||||
LabeledContent("Time") {
|
||||
Text(message.timestamp.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
LabeledContent("Message ID") {
|
||||
Text(String(message.messageId))
|
||||
.font(.caption)
|
||||
}
|
||||
LabeledContent("Channel") {
|
||||
Text(String(message.channel))
|
||||
}
|
||||
}
|
||||
|
||||
if message.pkiEncrypted {
|
||||
Section("Security") {
|
||||
HStack {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Encrypted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isCurrentUser {
|
||||
Section("Status") {
|
||||
if message.receivedACK {
|
||||
LabeledContent("Status") {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(message.realACK ? "Delivered" : "Acknowledged by another node")
|
||||
}
|
||||
}
|
||||
LabeledContent("Ack Time") {
|
||||
Text(message.ackTimestamp > 0 ?
|
||||
Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)).formatted(date: .abbreviated, time: .shortened) : "N/A")
|
||||
}
|
||||
} else if message.ackError > 0 {
|
||||
LabeledContent("Status") {
|
||||
let error = RoutingError(rawValue: Int(message.ackError))
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(error?.display ?? "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LabeledContent("Status") {
|
||||
HStack {
|
||||
Image(systemName: "clock.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Pending...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Read") {
|
||||
Image(systemName: message.read ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(message.read ? .green : .gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Message Info") {
|
||||
if let relayDisplay = relayDisplay {
|
||||
LabeledContent("Relay") {
|
||||
Text(relayDisplay)
|
||||
.foregroundColor(relayDisplay.contains("Node ") ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
if message.relays != 0 && !message.realACK {
|
||||
LabeledContent("Relayed by") {
|
||||
Text("\(message.relays) \(message.relays == 1 ? "node" : "nodes")")
|
||||
}
|
||||
}
|
||||
|
||||
if message.ackSNR != 0 {
|
||||
LabeledContent("Ack SNR") {
|
||||
Text("\(String(format: "%.2f", message.ackSNR)) dB")
|
||||
}
|
||||
}
|
||||
|
||||
if message.snr != 0 {
|
||||
LabeledContent("SNR") {
|
||||
Text("\(String(format: "%.2f", message.snr)) dB")
|
||||
}
|
||||
}
|
||||
|
||||
if message.rssi != 0 {
|
||||
LabeledContent("RSSI") {
|
||||
Text("\(String(format: "%.2f", message.rssi)) dBm")
|
||||
}
|
||||
}
|
||||
|
||||
if let node = message.fromUser?.userNode, node.hopsAway > 0 {
|
||||
LabeledContent("Hops Away") {
|
||||
Text("\(node.hopsAway)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if message.replyID > 0 {
|
||||
Section("Reply") {
|
||||
LabeledContent("In Reply To") {
|
||||
Text(String(message.replyID))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Message Text") {
|
||||
Text(message.messagePayload ?? "Empty")
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Message Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = message.relayDisplay()
|
||||
DispatchQueue.main.async {
|
||||
relayDisplay = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
Meshtastic/Views/Messages/ChatAdapters.swift
Normal file
104
Meshtastic/Views/Messages/ChatAdapters.swift
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//
|
||||
// ChatAdapters.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Adapters to convert between Meshtastic Core Data entities and ExyteChat library types
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import ExyteChat
|
||||
|
||||
extension UserEntity {
|
||||
|
||||
func toChatUser(currentUserNum: Int64) -> User {
|
||||
let isCurrentUser = self.num == currentUserNum
|
||||
return User(
|
||||
id: String(self.num),
|
||||
name: self.longName ?? self.shortName ?? "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageEntity {
|
||||
|
||||
func toChatMessage(
|
||||
currentUserNum: Int64,
|
||||
allMessages: [MessageEntity] = [],
|
||||
preferredPeripheralNum: Int = -1
|
||||
) -> Message {
|
||||
let messageId = String(self.messageId)
|
||||
let fromUserEntity = self.fromUser
|
||||
|
||||
let isCurrentUser: Bool
|
||||
if let fromUser = fromUserEntity {
|
||||
isCurrentUser = fromUser.num == currentUserNum
|
||||
} else {
|
||||
isCurrentUser = false
|
||||
}
|
||||
|
||||
let user: User
|
||||
if let fromUser = fromUserEntity {
|
||||
user = fromUser.toChatUser(currentUserNum: currentUserNum)
|
||||
} else {
|
||||
user = User(
|
||||
id: "unknown",
|
||||
name: "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
}
|
||||
|
||||
var replyMessage: Message? = nil
|
||||
if self.replyID > 0, let replyEntity = allMessages.first(where: { $0.messageId == self.replyID }) {
|
||||
replyMessage = replyEntity.toChatMessage(
|
||||
currentUserNum: currentUserNum,
|
||||
allMessages: [],
|
||||
preferredPeripheralNum: preferredPeripheralNum
|
||||
)
|
||||
}
|
||||
|
||||
return Message(
|
||||
id: messageId,
|
||||
user: user,
|
||||
text: self.messagePayload ?? "",
|
||||
attachments: [],
|
||||
createdAt: self.timestamp,
|
||||
replyMessage: replyMessage,
|
||||
status: self.determineMessageStatus(preferredPeripheralNum: Int64(preferredPeripheralNum))
|
||||
)
|
||||
}
|
||||
|
||||
private func determineMessageStatus(preferredPeripheralNum: Int64) -> MessageStatus {
|
||||
guard Int64(preferredPeripheralNum) == fromUser?.num else {
|
||||
return .read
|
||||
}
|
||||
|
||||
if receivedACK {
|
||||
return .read
|
||||
} else if ackError > 0 {
|
||||
return .error
|
||||
} else {
|
||||
return .sending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatMessageAdapter {
|
||||
|
||||
static func convertMessages(
|
||||
from entities: [MessageEntity],
|
||||
currentUserNum: Int64,
|
||||
preferredPeripheralNum: Int = -1
|
||||
) -> [Message] {
|
||||
return entities.map { entity in
|
||||
entity.toChatMessage(
|
||||
currentUserNum: currentUserNum,
|
||||
allMessages: entities,
|
||||
preferredPeripheralNum: preferredPeripheralNum
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,84 @@
|
|||
//
|
||||
// UserMessageList.swift
|
||||
// MeshtasticApple
|
||||
// UserMessageList.swift
|
||||
// MeshtasticApple
|
||||
//
|
||||
// Created by Garth Vander Houwen on 12/24/21.
|
||||
// Migrated to use ExyteChat library with full functionality
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import OSLog
|
||||
import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed
|
||||
import MeshtasticProtobufs
|
||||
import ExyteChat
|
||||
import LinkPresentation
|
||||
|
||||
private enum ChatMessageAction: MessageMenuAction {
|
||||
case reply
|
||||
case copy
|
||||
case info
|
||||
case tapback
|
||||
|
||||
func title() -> String {
|
||||
switch self {
|
||||
case .reply: return "Reply"
|
||||
case .copy: return "Copy"
|
||||
case .info: return "Info"
|
||||
case .tapback: return "Tapback"
|
||||
}
|
||||
}
|
||||
|
||||
func icon() -> Image {
|
||||
switch self {
|
||||
case .reply: return Image(systemName: "arrowshape.turn.up.left")
|
||||
case .copy: return Image(systemName: "doc.on.doc")
|
||||
case .info: return Image(systemName: "info.circle")
|
||||
case .tapback: return Image(systemName: "hand.thumbsup.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array where Element == MessageEntity {
|
||||
func convertToChatMessages(currentUserNum: Int64, preferredPeripheralNum: Int) -> [ExyteChat.Message] {
|
||||
return self.map { entity in
|
||||
let messageId = String(entity.messageId)
|
||||
let fromUserEntity = entity.fromUser
|
||||
|
||||
let isCurrentUser: Bool
|
||||
if let fromUser = fromUserEntity {
|
||||
isCurrentUser = fromUser.num == currentUserNum
|
||||
} else {
|
||||
isCurrentUser = false
|
||||
}
|
||||
|
||||
let user: ExyteChat.User
|
||||
if let fromUser = fromUserEntity {
|
||||
user = ExyteChat.User(
|
||||
id: String(fromUser.num),
|
||||
name: fromUser.longName ?? fromUser.shortName ?? "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
} else {
|
||||
user = ExyteChat.User(
|
||||
id: "unknown",
|
||||
name: "Unknown",
|
||||
avatarURL: nil,
|
||||
isCurrentUser: isCurrentUser
|
||||
)
|
||||
}
|
||||
|
||||
return ExyteChat.Message(
|
||||
id: messageId,
|
||||
user: user,
|
||||
status: nil,
|
||||
createdAt: entity.timestamp,
|
||||
text: entity.messagePayload ?? "",
|
||||
attachments: [],
|
||||
replyMessage: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserMessageList: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
|
@ -20,22 +90,25 @@ struct UserMessageList: View {
|
|||
@State private var replyMessageId: Int64 = 0
|
||||
@State private var messageToHighlight: Int64 = 0
|
||||
@State private var redrawTapbacksTrigger = UUID()
|
||||
@State private var selectedMessageForDetails: MessageEntity?
|
||||
@State private var showingMessageDetails = false
|
||||
@State private var showingTapbackInput = false
|
||||
@State private var tapbackMessage: MessageEntity?
|
||||
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
|
||||
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
|
||||
|
||||
|
||||
init(user: UserEntity) {
|
||||
self.user = user
|
||||
|
||||
// Configure fetch request here
|
||||
|
||||
let request: NSFetchRequest<MessageEntity> = user.messageFetchRequest
|
||||
_allPrivateMessages = FetchRequest(fetchRequest: request)
|
||||
}
|
||||
|
||||
|
||||
func handleInteractionComplete() {
|
||||
markMessagesAsRead()
|
||||
redrawTapbacksTrigger = UUID()
|
||||
}
|
||||
|
||||
|
||||
func markMessagesAsRead() {
|
||||
do {
|
||||
for unreadMessage in allPrivateMessages.filter({ !$0.read }) {
|
||||
|
|
@ -43,94 +116,120 @@ struct UserMessageList: View {
|
|||
}
|
||||
try context.save()
|
||||
Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).")
|
||||
|
||||
|
||||
if let connectedPeripheralNum = accessoryManager.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
|
||||
let connectedUser = connectedNode.user {
|
||||
appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
|
||||
appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true)
|
||||
}
|
||||
|
||||
|
||||
context.refresh(user, mergeChanges: true)
|
||||
} catch {
|
||||
Logger.data.error("Failed to read direct messages: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func routerIsShowingThisUser() -> Bool {
|
||||
guard appState.router.navigationState.selectedTab == .messages else { return false }
|
||||
return scenePhase == .active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Cast user.messageList to an array for easier indexing and ForEach.
|
||||
let messages: [MessageEntity] = Array(allPrivateMessages)
|
||||
|
||||
// Precompute previous message
|
||||
let previousByID: [Int64: MessageEntity?] = {
|
||||
var dict = [Int64: MessageEntity?]()
|
||||
var prev: MessageEntity?
|
||||
for m in messages { dict[m.messageId] = prev; prev = m }
|
||||
return dict
|
||||
}()
|
||||
|
||||
VStack {
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(messages, id: \.messageId) { message in
|
||||
let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil
|
||||
|
||||
UserMessageRow(
|
||||
message: message,
|
||||
allMessages: messages,
|
||||
previousMessage: previousMessage,
|
||||
preferredPeripheralNum: preferredPeripheralNum,
|
||||
user: user,
|
||||
replyMessageId: $replyMessageId,
|
||||
messageFieldFocused: $messageFieldFocused,
|
||||
messageToHighlight: $messageToHighlight,
|
||||
scrollView: scrollView,
|
||||
onInteractionComplete: handleInteractionComplete
|
||||
)
|
||||
.onAppear {
|
||||
// Only mark as read if the app is in the foreground
|
||||
if !message.read && UIApplication.shared.applicationState == .active {
|
||||
message.read = true
|
||||
LocalNotificationManager().cancelNotificationForMessageId(message.messageId)
|
||||
// Race condition, sometimes the app doesn't update unread count if we run this too early
|
||||
// So, run it in the main queue after everything saves and stabilizes
|
||||
DispatchQueue.main.async {
|
||||
markMessagesAsRead()
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Invisible spacer to detect reaching bottom
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("bottomAnchor")
|
||||
}
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.defaultScrollAnchorTopAlignment()
|
||||
.defaultScrollAnchorBottomSizeChanges()
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.onChange(of: messageFieldFocused) {
|
||||
if messageFieldFocused {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
func markMessageAsRead(_ message: MessageEntity) {
|
||||
if !message.read {
|
||||
message.read = true
|
||||
do {
|
||||
try context.save()
|
||||
if let connectedPeripheralNum = accessoryManager.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context),
|
||||
let connectedUser = connectedNode.user {
|
||||
appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true)
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("Failed to mark message as read: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
TextMessageField(
|
||||
destination: .user(user),
|
||||
replyMessageId: $replyMessageId,
|
||||
isFocused: $messageFieldFocused
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func retryMessage(_ message: MessageEntity) {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: message.messagePayload ?? "",
|
||||
toUserNum: user.num,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: message.replyID
|
||||
)
|
||||
} catch {
|
||||
Logger.mesh.error("Failed to retry message: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendTapback(_ emoji: String, to message: MessageEntity) {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: emoji,
|
||||
toUserNum: message.fromUser?.num ?? user.num,
|
||||
channel: 0,
|
||||
isEmoji: true,
|
||||
replyID: message.messageId
|
||||
)
|
||||
await MainActor.run {
|
||||
context.refresh(user, mergeChanges: true)
|
||||
}
|
||||
} catch {
|
||||
Logger.services.warning("Failed to send tapback.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyMessage(_ text: String) {
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
|
||||
private var currentUserNum: Int64 {
|
||||
Int64(preferredPeripheralNum)
|
||||
}
|
||||
|
||||
private var chatMessages: [Message] {
|
||||
let entities = Array(allPrivateMessages)
|
||||
return entities.convertToChatMessages(
|
||||
currentUserNum: currentUserNum,
|
||||
preferredPeripheralNum: preferredPeripheralNum
|
||||
)
|
||||
}
|
||||
|
||||
private func sendMessage(draft: DraftMessage) {
|
||||
guard !draft.text.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: draft.text,
|
||||
toUserNum: user.num,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: replyMessageId
|
||||
)
|
||||
replyMessageId = 0
|
||||
} catch {
|
||||
Logger.mesh.info("Error sending message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let messages = chatMessages
|
||||
|
||||
return ChatView(
|
||||
messages: messages,
|
||||
chatType: .conversation,
|
||||
replyMode: .quote
|
||||
) { draft in
|
||||
sendMessage(draft: draft)
|
||||
}
|
||||
.messageUseMarkdown(true)
|
||||
.setAvailableInputs([.text])
|
||||
.showDateHeaders(true)
|
||||
.isScrollEnabled(true)
|
||||
.keyboardDismissMode(.interactive)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !user.keyMatch {
|
||||
|
|
@ -168,5 +267,278 @@ struct UserMessageList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingTapbackInput) {
|
||||
if let msg = tapbackMessage {
|
||||
TapbackPickerViewDM(message: msg) { emoji in
|
||||
sendTapback(emoji, to: msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingMessageDetails) {
|
||||
if let msg = selectedMessageForDetails {
|
||||
MessageDetailsView(message: msg, destination: .user(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomMessageCell: View {
|
||||
let message: Message
|
||||
let currentUserNum: Int64
|
||||
@Binding var replyMessageId: Int64
|
||||
@FocusState.Binding var messageFieldFocused: Bool
|
||||
let destination: MessageDestination
|
||||
let allMessages: [MessageEntity]
|
||||
let onRead: (MessageEntity) -> Void
|
||||
let onRetry: (MessageEntity) -> Void
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
private var isCurrentUser: Bool {
|
||||
message.user.isCurrentUser
|
||||
}
|
||||
|
||||
private var messageEntity: MessageEntity? {
|
||||
allMessages.first { String($0.messageId) == message.id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .bottom) {
|
||||
if isCurrentUser { Spacer(minLength: 50) }
|
||||
|
||||
if !isCurrentUser {
|
||||
if let msgEntity = messageEntity {
|
||||
CircleText(
|
||||
text: msgEntity.fromUser?.shortName ?? "?",
|
||||
color: Color(UIColor(hex: UInt32(msgEntity.fromUser?.num ?? 0))),
|
||||
circleSize: 50
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
if let nodeNum = msgEntity.fromUser?.num {
|
||||
// Navigate to node detail
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
onRead(msgEntity)
|
||||
}
|
||||
.padding(.all, 5)
|
||||
.offset(y: -7)
|
||||
} else {
|
||||
CircleText(text: "?", color: .gray, circleSize: 50)
|
||||
.padding(.all, 5)
|
||||
.offset(y: -7)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 0) {
|
||||
if !isCurrentUser, let msgEntity = messageEntity {
|
||||
Text("\(msgEntity.fromUser?.longName ?? "Unknown") (\(msgEntity.fromUser?.userId ?? "?"))")
|
||||
.font(.caption).foregroundColor(.gray)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
Text(LocalizedStringKey(message.text))
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 8)
|
||||
.foregroundColor(.white)
|
||||
.background(isCurrentUser ? Color.accentColor : Color.gray)
|
||||
.cornerRadius(15)
|
||||
|
||||
if isCurrentUser, let msgEntity = messageEntity {
|
||||
if msgEntity.canRetry || (msgEntity.receivedACK && !msgEntity.realACK) {
|
||||
Button {
|
||||
onRetry(msgEntity)
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let msgEntity = messageEntity {
|
||||
DMMessageStatusView(message: msgEntity)
|
||||
|
||||
TapbackResponsesViewDM(message: msgEntity) {
|
||||
onRead(msgEntity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
if !isCurrentUser { Spacer(minLength: 50) }
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
struct DMMessageStatusView: View {
|
||||
@ObservedObject var message: MessageEntity
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isCurrentUser {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
if message.receivedACK {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
Text(ackErrorVal?.display ?? "Sent")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
} else if message.ackError == 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.yellow)
|
||||
Text("Waiting to be acknowledged. . .")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
} else if message.ackError > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
Text(ackErrorVal?.display ?? "Error")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private var isCurrentUser: Bool {
|
||||
Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num
|
||||
}
|
||||
}
|
||||
|
||||
struct TapbackResponsesViewDM: View {
|
||||
@ObservedObject var message: MessageEntity
|
||||
let onRead: () -> Void
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
var body: some View {
|
||||
let tapbacks = message.tapbacks
|
||||
if !tapbacks.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(tapbacks, id: \.messageId) { tapback in
|
||||
VStack {
|
||||
if let image = tapback.messagePayload?.image(fontSize: 16) {
|
||||
Image(uiImage: image)
|
||||
.font(.caption)
|
||||
}
|
||||
Text("\(tapback.fromUser?.shortName ?? "?")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.onAppear {
|
||||
if !tapback.read {
|
||||
tapback.read = true
|
||||
onRead()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray6)))
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TapbackPickerViewDM: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
let message: MessageEntity
|
||||
let onTapbackSelected: (String) -> Void
|
||||
|
||||
@State private var emojiText: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Tap to enter emoji", text: $emojiText)
|
||||
.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: emojiText) { oldValue, newValue in
|
||||
if !newValue.isEmpty, let firstEmoji = extractFirstEmoji(from: newValue) {
|
||||
onTapbackSelected(firstEmoji)
|
||||
emojiText = ""
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Type an emoji to send as a tapback")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Tapback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(150)])
|
||||
}
|
||||
|
||||
private func extractFirstEmoji(from string: String) -> String? {
|
||||
guard !string.isEmpty else { return nil }
|
||||
|
||||
let firstChar = string[string.startIndex]
|
||||
|
||||
if firstChar.isEmoji {
|
||||
var emojiEnd = string.index(after: string.startIndex)
|
||||
|
||||
while emojiEnd < string.endIndex {
|
||||
let nextChar = string[emojiEnd]
|
||||
if let scalar = nextChar.unicodeScalars.first,
|
||||
(scalar.properties.isVariationSelector ||
|
||||
scalar.value == 0xFE0F ||
|
||||
(scalar.value >= 0x1F3FB && scalar.value <= 0x1F3FF) ||
|
||||
scalar.value == 0x200D) {
|
||||
emojiEnd = string.index(after: emojiEnd)
|
||||
} else if nextChar.isEmoji {
|
||||
emojiEnd = string.index(after: emojiEnd)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return String(string[string.startIndex..<emojiEnd])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,67 @@
|
|||
{
|
||||
"originHash" : "a2385deee281bd55bce80722a1f2b020f7b745c02005befa8ccbf58a39ef4002",
|
||||
"originHash" : "dfbb49c0054837d8ee431d028632d3dcd136e6d827e039d8867b1343ec8ca69b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/emqx/CocoaMQTT",
|
||||
"state" : {
|
||||
"revision" : "aff43422925cc30b9af319f4c4dce4f52859baf4",
|
||||
"version" : "2.1.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dd-sdk-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
|
||||
"state" : {
|
||||
"revision" : "8d67e973ff4a958cb536263cb816646ee904c508",
|
||||
"version" : "3.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mqttcocoaasyncsocket",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/leeway1208/MqttCocoaAsyncSocket",
|
||||
"state" : {
|
||||
"revision" : "ce3e18607fd01079495f86ff6195d8a3ca469f73",
|
||||
"version" : "1.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift-packages",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/opentelemetry-swift-packages.git",
|
||||
"state" : {
|
||||
"revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e",
|
||||
"version" : "1.13.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "plcrashreporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/plcrashreporter.git",
|
||||
"state" : {
|
||||
"revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65",
|
||||
"version" : "1.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "starscream",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/daltoniam/Starscream.git",
|
||||
"state" : {
|
||||
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
||||
"version" : "4.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
|
||||
"version" : "1.29.0"
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue