Merge pull request #501 from meshtastic/channelkey_editor

Messaging Updates
This commit is contained in:
Garth Vander Houwen 2024-02-17 15:08:25 -08:00 committed by GitHub
commit 43d7314192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 440 additions and 418 deletions

View file

@ -14,6 +14,10 @@
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */; };
C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; };
D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */; };
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; };
D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; };
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; };
D9BC22DB2B7DE8E2006A37D5 /* TileDownloadStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */; };
D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */; };
D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */; };
@ -235,6 +239,10 @@
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = "<group>"; };
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = "<group>"; };
D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = "<group>"; };
D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = "<group>"; };
D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileDownloadStatus.swift; sourceTree = "<group>"; };
D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageSize.swift; sourceTree = "<group>"; };
D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
@ -693,6 +701,7 @@
DD1925B828CDA93900720036 /* SerialConfigEnums.swift */,
DD994B68295F88B60013760A /* IntervalEnums.swift */,
DD5E5239298EFA5300D21B61 /* TelemetryWeather.swift */,
D93068D42B812B700066FBC8 /* MessageDestination.swift */,
);
path = Enums;
sourceTree = "<group>";
@ -850,6 +859,9 @@
DDB8F4112A9EE5DD00230ECE /* UserList.swift */,
DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */,
B399E8A32B6F486400E4488E /* RetryButton.swift */,
D93068D62B8146690066FBC8 /* MessageText.swift */,
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */,
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */,
);
path = Messages;
sourceTree = "<group>";
@ -1242,6 +1254,7 @@
DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */,
DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */,
6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */,
D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */,
DD5E520A298EE33B00D21B61 /* channel.pb.swift in Sources */,
DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */,
DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */,
@ -1281,6 +1294,7 @@
DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */,
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */,
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
DD5E5212298EE33B00D21B61 /* apponly.pb.swift in Sources */,
@ -1298,6 +1312,7 @@
DD5E523A298EFA5300D21B61 /* TelemetryWeather.swift in Sources */,
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */,
C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */,
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */,
DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */,
DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */,
DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */,
@ -1316,6 +1331,7 @@
DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */,
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */,
DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */,
D93068D72B8146690066FBC8 /* MessageText.swift in Sources */,
DD5E5204298EE33B00D21B61 /* xmodem.pb.swift in Sources */,
DDE5B4062B227E3200FCDD05 /* TraceRouteEntityExtension.swift in Sources */,
DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */,

View file

@ -0,0 +1,19 @@
/// Helper abstraction for sharing functionality between channel and direct messaging.
enum MessageDestination {
case user(UserEntity)
case channel(ChannelEntity)
var userNum: Int64 {
switch self {
case let .user(user): return user.num
case .channel: return 0
}
}
var channelNum: Int32 {
switch self {
case .user: return 0
case let .channel(channel): return channel.index
}
}
}

View file

@ -17,4 +17,8 @@ extension MessageEntity {
let time = messageTimestamp <= 0 ? receivedTimestamp : messageTimestamp
return Date(timeIntervalSince1970: TimeInterval(time))
}
var canRetry: Bool {
return ackError == 9 || ackError == 5 || ackError == 3
}
}

View file

@ -25,3 +25,20 @@
Image(systemName: "qrcode")
}
}
@available(iOS 17.0, macOS 14.0, *)
struct CreateChannelsTip: Tip {
var id: String {
return "tip.channels.create"
}
var title: Text {
Text("tip.channels.create.title")
}
var message: Text? {
Text("tip.channels.create.message")
}
var image: Image? {
Image(systemName: "fibrechannel")
}
}

View file

@ -38,7 +38,7 @@ struct ContactsTip: Tip {
}
var message: Text? {
//Text("tip.messages.contacts.message")
Text("Each node shows as an available contact. Nodes with recent messages and favorites show up at the top of the list. Select a node to send or view messages. Long press to favorite or mute the node, send a trace route or delete the conversation.")
Text("Each node is an available contact. Contacts with recent messages or marked as favorites show up at the top of the list. Select a contact to send or view messages. Long press to favorite or mute the contact or delete the conversation.")
}
var image: Image? {
Image(systemName: "person.circle")

View file

@ -21,6 +21,8 @@ struct ChannelList: View {
@State private var isPresentingTraceRouteSentAlert = false
var restrictedChannels = ["admin", "gpio", "mqtt", "serial"]
var body: some View {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
@ -29,7 +31,7 @@ struct ChannelList: View {
// Display Contacts for the rest of the non admin channels
if node != nil && node!.myInfo != nil && node!.myInfo!.channels != nil {
List(node!.myInfo!.channels!.array as! [ChannelEntity], id: \.self, selection: $channelSelection) { (channel: ChannelEntity) in
if channel.name?.lowercased() ?? "" != "admin" && channel.name?.lowercased() ?? "" != "gpio" && channel.name?.lowercased() ?? "" != "serial" {
if !restrictedChannels.contains(channel.name?.lowercased() ?? "") {
NavigationLink(destination: ChannelMessageList(myInfo: node!.myInfo!, channel: channel)) {
@ -85,9 +87,6 @@ struct ChannelList: View {
.foregroundColor(.secondary)
}
}
// Image(systemName: "chevron.forward")
// .font(.caption)
// .foregroundColor(.secondary)
}
if channel.allPrivateMessages.count > 0 {

View file

@ -18,15 +18,11 @@ struct ChannelMessageList: View {
@ObservedObject var myInfo: MyInfoEntity
@ObservedObject var channel: ChannelEntity
@State var showDeleteMessageAlert = false
@State private var deleteMessageId: Int64 = 0
@State private var replyMessageId: Int64 = 0
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
var body: some View {
VStack {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmssa", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss a")
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
@ -55,155 +51,28 @@ struct ChannelMessageList: View {
.offset(y: -5)
}
VStack(alignment: currentUser ? .trailing : .leading) {
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
Text(markdownText)
.tint(linkBlue)
.padding(10)
.foregroundColor(.white)
.background(currentUser ? .accentColor : Color(.gray))
.cornerRadius(15)
.overlay(
VStack {
if #available(iOS 17.0, macOS 14.0, *) {
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.symbolRenderingMode(.multicolor)
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
.offset(x: 20, y: -20)
: nil
} else {
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.offset(x: 20, y: -20)
: nil
}
}
)
.contextMenu {
VStack {
Text("channel")+Text(": \(message.channel)")
}
Menu("tapback") {
ForEach(Tapbacks.allCases) { tb in
Button(action: {
if bleManager.sendMessage(message: tb.emojiString, toUserNum: 0, channel: channel.index, isEmoji: true, replyID: message.messageId) {
print("Sent \(tb.emojiString) Tapback")
self.context.refresh(channel, mergeChanges: true)
} else { print("\(tb.emojiString) Tapback Failed") }
}) {
Text(tb.description)
let image = tb.emojiString.image()
Image(uiImage: image!)
}
}
}
Button(action: {
self.replyMessageId = message.messageId
self.messageFieldFocused = true
print("I want to reply to \(message.messageId)")
}) {
Text("reply")
Image(systemName: "arrowshape.turn.up.left.2.fill")
}
Button(action: {
UIPasteboard.general.string = message.messagePayload
}) {
Text("copy")
Image(systemName: "doc.on.doc")
}
Menu("message.details") {
VStack {
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
Text(" \(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray)
}
if !currentUser {
VStack {
Text("SNR \(String(format: "%.2f", message.snr)) dB")
}
}
if currentUser && message.receivedACK {
VStack {
Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")")
}
} else if currentUser && message.ackError == 0 {
// Empty Error
Text("waiting")
} else if currentUser && message.ackError > 0 {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
}
if currentUser {
VStack {
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
if ackDate >= sixMonthsAgo! {
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss a"))").foregroundColor(.gray)
} else {
Text("unknown.age").foregroundColor(.gray)
}
}
}
if message.ackSNR != 0 {
VStack {
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
.foregroundColor(.gray)
}
}
}
Divider()
Button(role: .destructive, action: {
self.showDeleteMessageAlert = true
self.deleteMessageId = message.messageId
print(deleteMessageId)
}) {
Text("delete")
Image(systemName: "trash")
}
HStack {
MessageText(
message: message,
tapBackDestination: .channel(channel),
isCurrentUser: currentUser
) {
self.replyMessageId = message.messageId
self.messageFieldFocused = true
}
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
if tapbacks.count > 0 {
VStack(alignment: .trailing) {
HStack {
ForEach( tapbacks ) { (tapback: MessageEntity) in
VStack {
let image = tapback.messagePayload!.image(fontSize: 20)
Image(uiImage: image!).font(.caption)
Text("\(tapback.fromUser?.shortName ?? "?")")
.font(.caption2)
.foregroundColor(.gray)
.fixedSize()
.padding(.bottom, 1)
}
.onAppear {
if !tapback.read {
tapback.read = true
do {
try context.save()
print("📖 Read message \(message.messageId) ")
appState.unreadChannelMessages = myInfo.unreadMessages
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
context.refresh(myInfo, mergeChanges: true)
} catch {
print("Failed to read tapback \(tapback.messageId)")
}
}
}
}
}
.padding(10)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color.gray, lineWidth: 1)
)
if currentUser && message.canRetry {
RetryButton(message: message)
}
}
TapbackResponses(message: message) {
appState.unreadChannelMessages = myInfo.unreadMessages
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
context.refresh(myInfo, mergeChanges: true)
}
HStack {
if currentUser && message.receivedACK {
@ -218,17 +87,13 @@ struct ChannelMessageList: View {
.font(.caption2).foregroundColor(.red)
} else if isDetectionSensorMessage {
let messageDate = message.timestamp
Text(" \(messageDate.formattedDate(format: dateFormatString))").font(.caption2).foregroundColor(.gray)
Text(" \(messageDate.formattedDate(format: MessageText.dateFormatString))").font(.caption2).foregroundColor(.gray)
}
}
}
.padding(.bottom)
.id(channel.allPrivateMessages.firstIndex(of: message))
if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) {
RetryButton(message: message)
}
if !currentUser {
Spacer(minLength: 50)
}
@ -236,21 +101,6 @@ struct ChannelMessageList: View {
.padding([.leading, .trailing])
.frame(maxWidth: .infinity)
.id(message.messageId)
.alert(isPresented: $showDeleteMessageAlert) {
Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) {
print("OK button tapped")
if deleteMessageId > 0 {
let message = channel.allPrivateMessages.first(where: { $0.messageId == deleteMessageId })
context.delete(message!)
do {
try context.save()
deleteMessageId = 0
} catch {
print("Failed to delete message \(deleteMessageId)")
}
}
}, secondaryButton: .cancel())
}
.onAppear {
if !message.read {
message.read = true
@ -286,7 +136,7 @@ struct ChannelMessageList: View {
}
TextMessageField(
destination: .channel(channel.index),
destination: .channel(channel),
replyMessageId: $replyMessageId,
isFocused: $messageFieldFocused
) {

View file

@ -0,0 +1,115 @@
import SwiftUI
import CoreData
struct MessageContextMenuItems: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
let message: MessageEntity
let tapBackDestination: MessageDestination
let isCurrentUser: Bool
@Binding var isShowingDeleteConfirmation: Bool
let onReply: () -> Void
var body: some View {
VStack {
Text("channel") + Text(": \(message.channel)")
}
Menu("tapback") {
ForEach(Tapbacks.allCases) { tb in
Button {
let sentMessage = bleManager.sendMessage(
message: tb.emojiString,
toUserNum: tapBackDestination.userNum,
channel: tapBackDestination.channelNum,
isEmoji: true,
replyID: message.messageId
)
if sentMessage {
self.context.refresh(tapBackDestination.managedObject, mergeChanges: true)
}
} label: {
Text(tb.description)
Image(uiImage: tb.emojiString.image()!)
}
}
}
Button(action: onReply) {
Text("reply")
Image(systemName: "arrowshape.turn.up.left")
}
Button {
UIPasteboard.general.string = message.messagePayload
} label: {
Text("copy")
Image(systemName: "doc.on.doc")
}
Menu("message.details") {
VStack {
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray)
}
if !isCurrentUser {
VStack {
Text("SNR \(String(format: "%.2f", message.snr)) dB")
}
}
if isCurrentUser && message.receivedACK {
VStack {
Text("received.ack") + Text(": \(message.receivedACK ? "✔️" : "")")
Text("received.ack.real") + Text(": \(message.realACK ? "✔️" : "")")
}
} else if isCurrentUser && message.ackError == 0 {
// Empty Error
Text("waiting")
} else if isCurrentUser && message.ackError > 0 {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
Text("\(ackErrorVal?.display ?? "Empty Ack Error")")
.fixedSize(horizontal: false, vertical: true)
}
if isCurrentUser {
VStack {
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
if ackDate >= sixMonthsAgo! {
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))")
.foregroundColor(.gray)
} else {
Text("unknown.age")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
if message.ackSNR != 0 {
VStack {
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
Divider()
Button(role: .destructive) {
isShowingDeleteConfirmation = true
} label: {
Text("delete")
Image(systemName: "trash")
}
}
}
private extension MessageDestination {
var managedObject: NSManagedObject {
switch self {
case let .user(user): return user
case let .channel(channel): return channel
}
}
}

View file

@ -0,0 +1,89 @@
import SwiftUI
struct MessageText: View {
static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
static let localeDateFormat = DateFormatter.dateFormat(
fromTemplate: "yyMMddjmmssa",
options: 0,
locale: Locale.current
)
static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
@Environment(\.managedObjectContext) var context
let message: MessageEntity
let tapBackDestination: MessageDestination
let isCurrentUser: Bool
let onReply: () -> Void
@State private var isShowingDeleteConfirmation = false
var body: some View {
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
return Text(markdownText)
.tint(Self.linkBlue)
.padding(10)
.foregroundColor(.white)
.background(isCurrentUser ? .accentColor : Color(.gray))
.cornerRadius(15)
.overlay {
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
if tapBackDestination.overlaySensorMessage {
VStack {
if #available(iOS 17.0, macOS 14.0, *) {
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.symbolRenderingMode(.multicolor)
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
.offset(x: 20, y: -20)
: nil
} else {
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.foregroundStyle(Color.orange)
.offset(x: 20, y: -20)
: nil
}
}
} else {
EmptyView()
}
}
.contextMenu {
MessageContextMenuItems(
message: message,
tapBackDestination: tapBackDestination,
isCurrentUser: isCurrentUser,
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
onReply: onReply
)
}
.confirmationDialog(
"Are you sure you want to delete this message?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Message", role: .destructive) {
context.delete(message)
do {
try context.save()
} catch {
print("Failed to delete message \(message.messageId)")
}
}
Button("Cancel", role: .cancel) {}
}
}
}
private extension MessageDestination {
var overlaySensorMessage: Bool {
switch self {
case .user: return false
case .channel: return true
}
}
}

View file

@ -0,0 +1,49 @@
import SwiftUI
struct TapbackResponses: View {
@Environment(\.managedObjectContext) var context
let message: MessageEntity
let onRead: () -> Void
@ViewBuilder
var body: some View {
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
if !tapbacks.isEmpty {
VStack(alignment: .trailing) {
HStack {
ForEach( tapbacks ) { (tapback: MessageEntity) in
VStack {
let image = tapback.messagePayload!.image(fontSize: 20)
Image(uiImage: image!).font(.caption)
Text("\(tapback.fromUser?.shortName ?? "?")")
.font(.caption2)
.foregroundColor(.gray)
.fixedSize()
.padding(.bottom, 1)
}
.onAppear {
guard !tapback.read else {
return
}
tapback.read = true
do {
try context.save()
print("📖 Read tapback \(tapback.messageId) ")
onRead()
} catch {
print("Failed to read tapback \(tapback.messageId)")
}
}
}
}
.padding(10)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color.gray, lineWidth: 1)
)
}
}
}
}

View file

@ -4,15 +4,10 @@ struct TextMessageField: View {
static let maxbytes = 228
@EnvironmentObject var bleManager: BLEManager
let destination: Destination
let destination: MessageDestination
@Binding var replyMessageId: Int64
@FocusState.Binding var isFocused: Bool
let onSubmit: () -> Void
enum Destination {
case user(Int64)
case channel(Int32)
}
@State private var typingMessage: String = ""
@State private var totalBytes = 0
@ -125,7 +120,7 @@ struct TextMessageField: View {
}
}
private extension TextMessageField.Destination {
private extension MessageDestination {
var positionShareMessage: String {
switch self {
case .user: return "has shared their position and requested a response with your position"
@ -133,23 +128,9 @@ private extension TextMessageField.Destination {
}
}
var userNum: Int64 {
switch self {
case let .user(num): return num
case .channel: return 0
}
}
var channelNum: Int32 {
switch self {
case .user: return 0
case let .channel(num): return num
}
}
var positionDestNum: Int64 {
switch self {
case let .user(num): return num
case let .user(user): return user.num
case .channel: return Int64(BLEManager.emptyNodeNum)
}
}

View file

@ -87,9 +87,6 @@ struct UserList: View {
.foregroundColor(.secondary)
}
}
// Image(systemName: "chevron.forward")
// .font(.caption)
// .foregroundColor(.secondary)
}
if user.messageList.count > 0 {

View file

@ -17,14 +17,10 @@ struct UserMessageList: View {
@FocusState var messageFieldFocused: Bool
// View State Items
@ObservedObject var user: UserEntity
@State var showDeleteMessageAlert = false
@State private var deleteMessageId: Int64 = 0
@State private var replyMessageId: Int64 = 0
var body: some View {
VStack {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmss", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
@ -50,138 +46,26 @@ struct UserMessageList: View {
HStack(alignment: .top) {
if currentUser { Spacer(minLength: 50) }
VStack(alignment: currentUser ? .trailing : .leading) {
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
Text(markdownText)
.tint(linkBlue)
.padding(10)
.foregroundColor(.white)
.background(currentUser ? .accentColor : Color(.gray))
.cornerRadius(15)
.contextMenu {
VStack {
Text("channel")+Text(": \(message.channel)")
}
Menu("tapback") {
ForEach(Tapbacks.allCases) { tb in
Button(action: {
if bleManager.sendMessage(message: tb.emojiString, toUserNum: user.num, channel: 0, isEmoji: true, replyID: message.messageId) {
print("Sent \(tb.emojiString) Tapback")
self.context.refresh(user, mergeChanges: true)
} else { print("\(tb.emojiString) Tapback Failed") }
}) {
Text(tb.description)
let image = tb.emojiString.image()
Image(uiImage: image!)
}
}
}
Button(action: {
self.replyMessageId = message.messageId
self.messageFieldFocused = true
print("I want to reply to \(message.messageId)")
}) {
Text("reply")
Image(systemName: "arrowshape.turn.up.left.2.fill")
}
Button(action: {
UIPasteboard.general.string = message.messagePayload
}) {
Text("copy")
Image(systemName: "doc.on.doc")
}
Menu("message.details") {
VStack {
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
Text("\(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray)
}
if !currentUser {
VStack {
Text("SNR \(String(format: "%.2f", message.snr)) dB")
}
}
if currentUser && message.receivedACK {
VStack {
Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")")
Text("received.ack.real")+Text(" \(message.realACK ? "✔️" : "")")
}
} else if currentUser && message.ackError == 0 {
// Empty Error
Text("waiting")
} else if currentUser && message.ackError > 0 {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
}
if currentUser {
VStack {
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
if ackDate >= sixMonthsAgo! {
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))").foregroundColor(.gray)
} else {
Text("unknown.age").font(.caption2).foregroundColor(.gray)
}
}
}
if message.ackSNR != 0 {
VStack {
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
Divider()
Button(role: .destructive, action: {
self.showDeleteMessageAlert = true
self.deleteMessageId = message.messageId
print(deleteMessageId)
}) {
Text("delete")
Image(systemName: "trash")
}
HStack {
MessageText(
message: message,
tapBackDestination: .user(user),
isCurrentUser: currentUser
) {
self.replyMessageId = message.messageId
self.messageFieldFocused = true
}
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
if tapbacks.count > 0 {
VStack(alignment: .trailing) {
HStack {
ForEach( tapbacks ) { (tapback: MessageEntity) in
VStack {
let image = tapback.messagePayload!.image(fontSize: 20)
Image(uiImage: image!).font(.caption)
Text("\(tapback.fromUser?.shortName ?? "?")")
.font(.caption2)
.foregroundColor(.gray)
.fixedSize()
.padding(.bottom, 1)
}
.onAppear {
if !tapback.read {
tapback.read = true
do {
try context.save()
print("📖 Read tapback \(tapback.messageId) ")
appState.unreadDirectMessages = user.unreadMessages
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
} catch {
print("Failed to read tapback \(tapback.messageId)")
}
}
}
}
}
.padding(10)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color.gray, lineWidth: 1)
)
if currentUser && message.canRetry || (message.receivedACK && !message.realACK) {
RetryButton(message: message)
}
}
TapbackResponses(message: message) {
appState.unreadDirectMessages = user.unreadMessages
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
}
HStack {
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
if currentUser && message.receivedACK {
@ -203,10 +87,6 @@ struct UserMessageList: View {
.padding(.bottom)
.id(user.messageList.firstIndex(of: message))
if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) || (message.receivedACK && !message.realACK) {
RetryButton(message: message)
}
if !currentUser {
Spacer(minLength: 50)
}
@ -214,20 +94,6 @@ struct UserMessageList: View {
.padding([.leading, .trailing])
.frame(maxWidth: .infinity)
.id(message.messageId)
.alert(isPresented: $showDeleteMessageAlert) {
Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) {
if deleteMessageId > 0 {
let message = user.messageList.first(where: { $0.messageId == deleteMessageId })
context.delete(message!)
do {
try context.save()
deleteMessageId = 0
} catch {
print("Failed to delete message \(deleteMessageId)")
}
}
}, secondaryButton: .cancel())
}
.onAppear {
if !message.read {
message.read = true
@ -264,7 +130,7 @@ struct UserMessageList: View {
}
TextMessageField(
destination: .user(user.num),
destination: .user(user),
replyMessageId: $replyMessageId,
isFocused: $messageFieldFocused
) {

View file

@ -124,7 +124,7 @@ struct NodeListItem: View {
.frame(width: 30)
Text("Channel: \(node.channel)")
.foregroundColor(.gray)
.font(.caption)
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
}
if node.viaMqtt && connectedNode != node.num {
Image(systemName: "network")
@ -133,7 +133,7 @@ struct NodeListItem: View {
.frame(width: 30)
Text("Via MQTT")
.foregroundColor(.gray)
.font(.caption)
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
}
}
if node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes {
@ -144,7 +144,7 @@ struct NodeListItem: View {
.frame(width: 30)
Text("Logs:")
.foregroundColor(.gray)
.font(.callout)
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
if node.hasDeviceMetrics {
Image(systemName: "flipphone")
.symbolRenderingMode(.hierarchical)
@ -183,7 +183,6 @@ struct NodeListItem: View {
LoRaSignalStrengthMeter(snr: node.snr, rssi: node.rssi, preset: preset ?? ModemPresets.longFast, compact: true)
}
.padding(.top)
}
}
.frame(maxWidth: .infinity, alignment: .leading)

View file

@ -19,7 +19,28 @@ struct AppSettings: View {
var body: some View {
VStack {
Form {
Section(header: Text("options")) {
Section(header: Text("Location Settings")) {
Toggle(isOn: $provideLocation) {
Label("appsettings.provide.location", systemImage: "location.circle.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if provideLocation {
Toggle(isOn: $enableSmartPosition) {
Label("appsettings.smartposition", systemImage: "brain.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
VStack {
Picker("update.interval", selection: $provideLocationInterval) {
ForEach(LocationUpdateInterval.allCases) { lu in
Text(lu.description)
}
}
.pickerStyle(DefaultPickerStyle())
Text("phone.gps.interval.description")
.font(.caption2)
.foregroundColor(.gray)
}
}
Toggle(isOn: $useLegacyMap) {
Label("map.use.legacy", systemImage: "map")
}
@ -63,35 +84,6 @@ struct AppSettings: View {
}
}
}
Section(header: Text("Location Settings")) {
Toggle(isOn: $provideLocation) {
Label("appsettings.provide.location", systemImage: "location.circle.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if provideLocation {
Toggle(isOn: $enableSmartPosition) {
Label("appsettings.smartposition", systemImage: "brain.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.onChange(of: (enableSmartPosition)) { newEnableSmartPosition in
UserDefaults.enableSmartPosition = newEnableSmartPosition
}
VStack {
Picker("update.interval", selection: $provideLocationInterval) {
ForEach(LocationUpdateInterval.allCases) { lu in
Text(lu.description)
}
}
.pickerStyle(DefaultPickerStyle())
.onChange(of: (provideLocationInterval)) { newProvideLocationInterval in
UserDefaults.provideLocationInterval = newProvideLocationInterval
}
Text("phone.gps.interval.description")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
Section(header: Text("App Data")) {
Button {
isPresentingCoreDataResetConfirm = true
@ -158,6 +150,12 @@ struct AppSettings: View {
self.bleManager.sendWantConfig()
}
}
.onChange(of: enableSmartPosition) { newEnableSmartPosition in
UserDefaults.enableSmartPosition = newEnableSmartPosition
}
.onChange(of: (provideLocationInterval)) { newProvideLocationInterval in
UserDefaults.provideLocationInterval = newProvideLocationInterval
}
.onChange(of: useLegacyMap) { newMapUseLegacy in
UserDefaults.mapUseLegacy = newMapUseLegacy
}

View file

@ -7,6 +7,9 @@
import SwiftUI
import CoreData
#if canImport(TipKit)
import TipKit
#endif
func generateChannelKey(size: Int) -> String {
var keyData = Data(count: size)
@ -40,7 +43,11 @@ struct Channels: View {
var body: some View {
VStack {
List {
if #available(iOS 17.0, macOS 14.0, *) {
TipView(CreateChannelsTip(), arrowEdge: .bottom)
}
if node != nil && node?.myInfo != nil {
ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in
Button(action: {
@ -91,7 +98,9 @@ struct Channels: View {
if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil {
Button {
let key = generateChannelKey(size: 32)
channelKeySize = 16
let key = generateChannelKey(size: channelKeySize)
channelName = ""
channelIndex = Int32(node!.myInfo!.channels!.array.count)
channelRole = 2
@ -201,18 +210,21 @@ struct Channels: View {
.disabled(channelKeySize <= 0)
}
HStack {
Text("Role")
Spacer()
Picker("Channel Role", selection: $channelRole) {
if channelRole == 1 {
if channelRole == 1 {
Picker("Channel Role", selection: $channelRole) {
Text("Primary").tag(1)
} else {
Text("Disabled").tag(0)
Text("Secondary").tag(2)
}
.pickerStyle(.automatic)
.disabled(true)
} else {
Text("Channel Role")
Spacer()
Picker("Channel Role", selection: $channelRole) {
Text("Disabled").tag(0)
Text("Secondary").tag(2)
}
.pickerStyle(.segmented)
}
.pickerStyle(.segmented)
.disabled(channelRole == 1)
}
Toggle("Uplink Enabled", isOn: $uplink)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))

View file

@ -91,7 +91,7 @@ struct StoreForwardConfig: View {
}
if isRouter {
Section(header: Text("options")) {
Section(header: Text("Router Options")) {
Toggle(isOn: $heartbeat) {
Label("storeforward.heartbeat", systemImage: "waveform.path.ecg")
}

View file

@ -117,27 +117,10 @@ struct Settings: View {
}
}
} else {
Text("Configuring Node \(node?.user?.longName ?? "unknown".localized)")
Text("Connected Node \(node?.user?.longName ?? "unknown".localized)")
}
}
Section("radio.configuration") {
NavigationLink {
ShareChannels(node: nodes.first(where: { $0.num == preferredNodeNum }))
} label: {
Image(systemName: "qrcode")
.symbolRenderingMode(.hierarchical)
Text("share.channels")
}
.tag(SettingsSidebar.shareChannels)
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
NavigationLink {
UserConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {
Image(systemName: "person.crop.rectangle.fill")
.symbolRenderingMode(.hierarchical)
Text("user")
}
.tag(SettingsSidebar.userConfig)
NavigationLink {
LoRaConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {
@ -155,6 +138,25 @@ struct Settings: View {
}
.tag(SettingsSidebar.channelConfig)
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
NavigationLink {
ShareChannels(node: nodes.first(where: { $0.num == preferredNodeNum }))
} label: {
Image(systemName: "qrcode")
.symbolRenderingMode(.hierarchical)
Text("share.channels")
}
.tag(SettingsSidebar.shareChannels)
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
}
Section("device.configuration") {
NavigationLink {
UserConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {
Image(systemName: "person.crop.rectangle.fill")
.symbolRenderingMode(.hierarchical)
Text("user")
}
.tag(SettingsSidebar.userConfig)
NavigationLink {
BluetoothConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {

View file

@ -291,6 +291,8 @@
"timestamp"="Timestamp";
"tip.bluetooth.connect.title"="Connected LoRa Radio";
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
"tip.channels.create.title"="Manage Channels";
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
"tip.channels.share.title"="Sharing Meshtastic Channels";
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
"tip.messages.title"="Messages";

View file

@ -81,7 +81,7 @@
"device.role.routerclient"="Combination of both ROUTER and CLIENT. Not for mobile devices.";
"direct.messages"="Direct Messages";
"dismiss.keyboard"="Dismiss";
"display"="Display (Device Screen)";
"display"="Display";
"display.config"="Display Config";
"distance"="Distance";
"disconnect"="Disconnect";
@ -270,7 +270,7 @@
"serial.mode.txtmsg"="Text Message";
"serial.mode.nmea"="NMEA Positions";
"settings"="Settings";
"share.channels"="Share Channels QR Code";
"share.channels"="Share QR Code";
"share.position"="Share Position";
"subscribed"="Subscribed to mesh";
"select.contact"="Select a Contact";
@ -297,9 +297,11 @@
"timeout"="Timeout";
"timestamp"="Timestamp";
"tip.bluetooth.connect.title"="Connected Radio";
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
"tip.bluetooth.connect.message"="Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
"tip.channels.create.title"="Manage Channels";
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/tips/)";
"tip.channels.share.title"="Sharing Meshtastic Channels";
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
"tip.channels.share.message"="A Meshtastic QR code contains the LoRa config and channel values needed to communicate. Most mesh activity takes place on the required Primary channel. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. Other channels are for private groups, each with its own key.";
"tip.messages.title"="Messages";
"tip.messages.message"="You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details.";
"twitter"="Twitter";

View file

@ -297,8 +297,9 @@
"timeout"="זמן קצוב";
"timestamp"="שעה/תאריך";
"tip.bluetooth.connect.title"="מכשיר מחובר";
מראה מידע אודות מכשיר המשטסטיק המחובר כעת לבלוטוס. ניתן לגרור שמאלה להתנתקות או לחיצה ארוכה לראות סטטיסטיקה או להתחיל פעילות.
"tip.bluetooth.connect.message"="מראה מידע אודות מכשיר המשטסטיק המחובר כעת לבלוטוס. ניתן לגרור שמאלה להתנתקות או לחיצה ארוכה לראות סטטיסטיקה או להתחיל פעילות.";
"tip.channels.create.title"="Manage Channels";
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
"tip.channels.share.title"="משתף ערוצי משטסטיק";
"tip.channels.share.message"="במשטסטיק יש עד 8 ערוצים. הראשון הינו הראשי והינו היכן שרוב הפעילות מתבצעת והכרחי. אם לא תשתף את הערוץ הראשי שלך הערוץ הראשון שלך נהיה הערוץ הראשי ברשת השניה. הוא מדבר בערוץ הראשי שלו במשני שלך. ערוץ בעל השם 'admin' הינו לשליטה מרחוק. ערוצים נוספים הינם לקבוצות פרטיות, כל אחת עם מפתח הצפנה משלה.";
"tip.messages.title"="הודעות";

View file

@ -292,6 +292,8 @@
"timestamp"="Znacznik czasu";
"tip.bluetooth.connect.title"="Connected LoRa Radio";
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
"tip.channels.create.title"="Manage Channels";
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
"tip.channels.share.title"="Sharing Meshtastic Channels";
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
"tip.messages.title"="Messages";

View file

@ -291,6 +291,8 @@
"timestamp"="时间戳";
"tip.bluetooth.connect.title"="连接到 LoRa 电台";
"tip.bluetooth.connect.message"="显示当前通过蓝牙连接的 Lora 电台的信息。您可以向左滑动断开电台,长按查看统计信息或开始实时活动。";
"tip.channels.create.title"="Manage Channels";
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
"tip.channels.share.title"="共享 Meshtastic 频道";
"tip.channels.share.message"="在 Meshtastic 网络中最多有 8 个频道。第一个频道是主频道,大多数活动都发生在这里,也是必需的。如果您不共享主频道,您的第一个共享频道就会成为其他网络的主频道。它会在其主频道和您的辅助频道上对话。名称为 admin 的频道可远程控制节点。其他频道用于私人群组,每个群组都有自己的密钥。";
"tip.messages.title"="消息";