This commit is contained in:
Benjamin Faershtein 2026-02-22 18:16:20 -08:00
parent d9e169142e
commit 27a9546c46
8 changed files with 1462 additions and 182 deletions

View file

@ -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"
}
}

View file

@ -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 */

View file

@ -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"
}
}
],

View file

@ -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",

View file

@ -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
}
}
}
}
}
}

View 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
)
}
}
}

View file

@ -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
}
}

View file

@ -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"
}
}
],