feat: local display names for nodes

- Add NodeDisplayNameStore (UserDefaults) keyed by node number
- Add displayLongName/displayShortName on UserEntity
- Show custom name in node list, detail, messages, relay text
- Add EditNodeDisplayNameView sheet and 'Set display name' in list/detail
- Notify UI on change via NodeDisplayNameStore.didChangeNotification

Made-with: Cursor
This commit is contained in:
Meshtastic Contributor 2026-03-08 20:44:00 +00:00
parent d9e169142e
commit 59ff5ba96a
11 changed files with 221 additions and 44 deletions

View file

@ -149,6 +149,8 @@
DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0EA2C601795008C0C70 /* CLLocation.swift */; };
DD1BD0EE2C603C91008C0C70 /* CustomFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0ED2C603C91008C0C70 /* CustomFormatters.swift */; };
DD1BD0F32C63C65E008C0C70 /* SecurityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */; };
DD0A10022E0292340090CE24 /* NodeDisplayNameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0A10012E0292330090CE24 /* NodeDisplayNameStore.swift */; };
DD0A10042E0292360090CE24 /* EditNodeDisplayNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0A10032E0292350090CE24 /* EditNodeDisplayNameView.swift */; };
DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF492E0292220090CE24 /* KeychainHelper.swift */; };
DD1BEF4C2E030D310090CE24 /* KeyBackupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */; };
DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */; };
@ -487,6 +489,8 @@
DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 42.xcdatamodel"; sourceTree = "<group>"; };
DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityConfig.swift; sourceTree = "<group>"; };
DD1BEF462DFF284C0090CE24 /* MeshtasticDataModelV 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 53.xcdatamodel"; sourceTree = "<group>"; };
DD0A10012E0292330090CE24 /* NodeDisplayNameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDisplayNameStore.swift; sourceTree = "<group>"; };
DD0A10032E0292350090CE24 /* EditNodeDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditNodeDisplayNameView.swift; sourceTree = "<group>"; };
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
DD1BEF4B2E030D240090CE24 /* KeyBackupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBackupStatus.swift; sourceTree = "<group>"; };
DD1BEF4D2E0391620090CE24 /* ChannelsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelsHelp.swift; sourceTree = "<group>"; };
@ -1340,6 +1344,7 @@
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
DD0A10012E0292330090CE24 /* NodeDisplayNameStore.swift */,
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */,
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
@ -1391,6 +1396,7 @@
children = (
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */,
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */,
DD0A10032E0292350090CE24 /* EditNodeDisplayNameView.swift */,
231B3F232D087C020069A07D /* Metrics Columns */,
DDAD49EB2AFAE82500B4425D /* Map */,
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
@ -1791,6 +1797,8 @@
BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */,
DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */,
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */,
DD0A10022E0292340090CE24 /* NodeDisplayNameStore.swift in Sources */,
DD0A10042E0292360090CE24 /* EditNodeDisplayNameView.swift in Sources */,
DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */,
DDDB26422AABF655003AFCB7 /* NodeListItem.swift in Sources */,
DDDB444629F8A96500EE2349 /* Character.swift in Sources */,

View file

@ -59,9 +59,9 @@ extension MessageEntity {
let users = try context.fetch(request)
// If exactly one match is found, return its name
if users.count == 1, let name = users.first?.longName, !name.isEmpty
{
return "\(name)"
if users.count == 1 {
let name = users.first!.displayLongName
if !name.isEmpty { return name }
}
// If no exact match, find the node with the smallest hopsAway
@ -72,8 +72,9 @@ extension MessageEntity {
return false
}
return lhsHops < rhsHops
}), let name = closestNode.longName, !name.isEmpty {
return "\(name)"
}) {
let name = closestNode.displayLongName
if !name.isEmpty { return name }
}
// Fallback to hex node number if no matches

View file

@ -8,7 +8,8 @@
import Foundation
import CoreData
extension NodeInfoEntity {
extension NodeInfoEntity: Identifiable {
public var id: NSManagedObjectID { objectID }
var latestPosition: PositionEntity? {
return self.positions?.lastObject as? PositionEntity

View file

@ -65,6 +65,24 @@ extension UserEntity {
// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }
/// Local display name for this node (if set), otherwise the device longName.
var displayLongName: String {
if let custom = NodeDisplayNameStore.displayName(for: num) {
return custom
}
return longName ?? "Unknown".localized
}
/// Short label for this node: first 4 characters of display name if set, otherwise device shortName.
var displayShortName: String {
if let custom = NodeDisplayNameStore.displayName(for: num) {
let trimmed = custom.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return shortName ?? "?" }
return String(trimmed.prefix(4))
}
return shortName ?? "?"
}
/// SVG Images for Vendors who are signed project backers
var hardwareImage: String? {
guard let hwModel else { return nil }

View file

@ -0,0 +1,51 @@
//
// NodeDisplayNameStore.swift
// Meshtastic
//
// Local display names for nodes (keyed by node num). Used only for UI; device identity unchanged.
//
import Foundation
enum NodeDisplayNameStore {
private static let key = "nodeDisplayNames"
/// Posted when any display name is set or cleared so UI can refresh.
static let didChangeNotification = Notification.Name("NodeDisplayNameStoreDidChange")
/// Returns the local display name for a node, or nil if none is set.
static func displayName(for nodeNum: Int64) -> String? {
let all = load()
return all[storageKey(nodeNum)]
}
/// Sets the local display name for a node. Pass nil to clear.
static func setDisplayName(_ name: String?, for nodeNum: Int64) {
var all = load()
let key = storageKey(nodeNum)
if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
all[key] = name
} else {
all.removeValue(forKey: key)
}
save(all)
NotificationCenter.default.post(name: didChangeNotification, object: nil)
}
private static func storageKey(_ nodeNum: Int64) -> String {
String(nodeNum)
}
private static func load() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
return decoded
}
private static func save(_ dict: [String: String]) {
guard let data = try? JSONEncoder().encode(dict) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}

View file

@ -114,7 +114,7 @@ fileprivate struct FilteredUserList: View {
.brightness(0.2)
}
CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num))))
CircleText(text: user.displayShortName, color: Color(UIColor(hex: UInt32(user.num))))
VStack(alignment: .leading) {
HStack {
@ -131,7 +131,7 @@ fileprivate struct FilteredUserList: View {
Image(systemName: "lock.open.fill")
.foregroundColor(.yellow)
}
Text(user.longName ?? "Unknown".localized)
Text(user.displayLongName)
.font(.headline)
.allowsTightening(true)
Spacer()

View file

@ -0,0 +1,60 @@
//
// EditNodeDisplayNameView.swift
// Meshtastic
//
// Sheet to set or clear a local display name for a node.
//
import SwiftUI
import CoreData
struct EditNodeDisplayNameView: View {
@Environment(\.dismiss) private var dismiss
let node: NodeInfoEntity
@State private var displayName: String = ""
@State private var hasChanges: Bool = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Display name", text: $displayName)
.autocorrectionDisabled(true)
.onChange(of: displayName) { _, _ in hasChanges = true }
} footer: {
Text("This name is only shown on this device. The nodes real name is unchanged for sharing and export.")
}
if NodeDisplayNameStore.displayName(for: node.num) != nil {
Section {
Button(role: .destructive) {
displayName = ""
hasChanges = true
} label: {
Label("Remove custom name", systemImage: "trash")
}
}
}
}
.navigationTitle("Display name")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
NodeDisplayNameStore.setDisplayName(trimmed.isEmpty ? nil : trimmed, for: node.num)
dismiss()
}
.disabled(!hasChanges)
}
}
.onAppear {
displayName = NodeDisplayNameStore.displayName(for: node.num) ?? ""
}
}
}
}

View file

@ -28,7 +28,9 @@ struct NodeDetail: View {
@ObservedObject var node: NodeInfoEntity
@State private var environmentSectionHeight: CGFloat = 0
@State var showingCompassSheet = false
@State private var showingDisplayNameSheet = false
@State private var displayNameRefresh = 0
var body: some View {
NavigationStack {
ScrollViewReader { scrollView in
@ -49,11 +51,11 @@ struct NodeDetail: View {
Section("Node") { // Node
HStack(alignment: .center) {
Spacer()
CircleText(
text: node.user?.shortName ?? "?",
color: Color(UIColor(hex: UInt32(node.num))),
circleSize: 75
)
CircleText(
text: node.user?.displayShortName ?? "?",
color: Color(UIColor(hex: UInt32(node.num))),
circleSize: 75
)
if node.snr != 0 && !node.viaMqtt && node.hopsAway == 0 {
Spacer()
VStack {
@ -120,6 +122,23 @@ struct NodeDetail: View {
.textSelection(.enabled)
}
.accessibilityElement(children: .combine)
Button {
showingDisplayNameSheet = true
} label: {
HStack {
Label {
Text("Display name")
} icon: {
Image(systemName: "pencil.circle")
.symbolRenderingMode(.hierarchical)
}
Spacer()
Text(node.user?.displayLongName ?? "")
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.accessibilityElement(children: .combine)
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
if let user = node.user, user.keyMatch {
let publicKey = node.num == connectedNode?.num
@ -575,14 +594,22 @@ struct NodeDetail: View {
}
}
}
.sheet(isPresented: $showingCompassSheet) {
CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointName: node.user?.longName ?? nil, color: Color(UIColor(hex: UInt32(node.num))))
}
.sheet(isPresented: $showingCompassSheet) {
CompassView(waypointLocation: node.latestPosition?.nodeCoordinate ?? nil, waypointName: node.user?.displayLongName, color: Color(UIColor(hex: UInt32(node.num))))
}
.sheet(isPresented: $showingDisplayNameSheet) {
EditNodeDisplayNameView(node: node)
.onDisappear { displayNameRefresh += 1 }
}
.onReceive(NotificationCenter.default.publisher(for: NodeDisplayNameStore.didChangeNotification)) { _ in
displayNameRefresh += 1
}
.onAppear {
scrollView.scrollTo("topOfList", anchor: .top)
}
.listStyle(.insetGrouped)
.navigationTitle(String(node.user?.longName?.addingVariationSelectors ?? "Unknown".localized))
.navigationTitle(String((node.user?.displayLongName ?? "Unknown".localized).addingVariationSelectors))
.id(displayNameRefresh)
.navigationBarTitleDisplayMode(.inline)
}
}

View file

@ -13,13 +13,11 @@ struct NodeListItem: View {
private var accessibilityDescription: String {
var desc = ""
if let shortName = node.user?.shortName {
desc = shortName.formatNodeNameForVoiceOver()
} else if let longName = node.user?.longName {
desc = longName
} else {
desc = "Unknown".localized + " " + "Node".localized
}
let shortName = node.user?.displayShortName ?? "?"
let longName = node.user?.displayLongName ?? "Unknown".localized
desc = shortName.formatNodeNameForVoiceOver()
if desc.isEmpty { desc = longName }
if desc.isEmpty { desc = "Unknown".localized + " " + "Node".localized }
if isDirectlyConnected {
desc += ", currently connected"
}
@ -128,7 +126,7 @@ struct NodeListItem: View {
LazyVStack(alignment: .leading) {
HStack {
VStack(alignment: .center) {
CircleText(text: node.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node.num))), circleSize: 70)
CircleText(text: node.user?.displayShortName ?? "?", color: Color(UIColor(hex: UInt32(node.num))), circleSize: 70)
.padding(.trailing, 5)
if node.latestDeviceMetrics != nil {
BatteryCompact(batteryLevel: node.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor)
@ -138,10 +136,10 @@ struct NodeListItem: View {
VStack(alignment: .leading) {
HStack {
let (image, color) = userKeyStatus
IconAndText(systemName: image,
imageColor: color,
text: node.user?.longName?.addingVariationSelectors ?? "Unknown".localized,
textColor: .primary)
IconAndText(systemName: image,
imageColor: color,
text: (node.user?.displayLongName ?? "Unknown".localized).addingVariationSelectors,
textColor: .primary)
if node.favorite {
Spacer()
Image(systemName: "star.fill")

View file

@ -21,6 +21,7 @@ struct NodeList: View {
@State private var isPresentingDeleteNodeAlert = false
@State private var deleteNodeId: Int64 = 0
@State private var shareContactNode: NodeInfoEntity?
@State private var nodeForDisplayNameEdit: NodeInfoEntity?
@StateObject var filters = NodeFilterParameters()
@State var isEditingFilters = false
@SceneStorage("selectedDetailView") var selectedDetailView: String?
@ -40,7 +41,8 @@ struct NodeList: View {
connectedNode: connectedNode,
isPresentingDeleteNodeAlert: $isPresentingDeleteNodeAlert,
deleteNodeId: $deleteNodeId,
shareContactNode: $shareContactNode
shareContactNode: $shareContactNode,
nodeForDisplayNameEdit: $nodeForDisplayNameEdit
)
.sheet(isPresented: $isEditingFilters) {
NodeListFilter(
@ -93,16 +95,19 @@ struct NodeList: View {
do {
try await accessoryManager.removeNode(node: node, connectedNodeNum: Int64(accessoryManager.activeDeviceNum ?? -1))
} catch {
Logger.data.error("Failed to delete node \(node.user?.longName ?? "Unknown".localized, privacy: .public)")
Logger.data.error("Failed to delete node \(node.user?.displayLongName ?? "Unknown".localized, privacy: .public)")
}
}
}
}
}
}
.sheet(item: $shareContactNode) { selectedNode in
ShareContactQRDialog(node: selectedNode.toProto())
}
.sheet(item: $shareContactNode) { selectedNode in
ShareContactQRDialog(node: selectedNode.toProto())
}
.sheet(item: $nodeForDisplayNameEdit) { node in
EditNodeDisplayNameView(node: node)
}
.navigationSplitViewColumnWidth(min: 100, ideal: 300, max: .infinity)
.navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack {
ConnectedDevice(
@ -160,6 +165,7 @@ fileprivate struct FilteredNodeList: View {
@Binding var isPresentingDeleteNodeAlert: Bool
@Binding var deleteNodeId: Int64
@Binding var shareContactNode: NodeInfoEntity?
@Binding var nodeForDisplayNameEdit: NodeInfoEntity?
// The initializer for the FetchRequest
init(
@ -168,7 +174,8 @@ fileprivate struct FilteredNodeList: View {
connectedNode: NodeInfoEntity?,
isPresentingDeleteNodeAlert: Binding<Bool>,
deleteNodeId: Binding<Int64>,
shareContactNode: Binding<NodeInfoEntity?>
shareContactNode: Binding<NodeInfoEntity?>,
nodeForDisplayNameEdit: Binding<NodeInfoEntity?>
) {
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
request.sortDescriptors = [
@ -185,6 +192,7 @@ fileprivate struct FilteredNodeList: View {
self._isPresentingDeleteNodeAlert = isPresentingDeleteNodeAlert
self._deleteNodeId = deleteNodeId
self._shareContactNode = shareContactNode
self._nodeForDisplayNameEdit = nodeForDisplayNameEdit
}
// The body of the view
@ -213,6 +221,11 @@ fileprivate struct FilteredNodeList: View {
node: NodeInfoEntity,
connectedNode: NodeInfoEntity?
) -> some View {
Button {
nodeForDisplayNameEdit = node
} label: {
Label("Set display name", systemImage: "pencil.circle")
}
if let user = node.user {
NodeAlertsButton(context: context, node: node, user: user)
if !user.unmessagable && user.num == UserDefaults.preferredPeripheralNum {

View file

@ -9,16 +9,16 @@ struct NodeRow: View {
HStack {
CircleText(text: node.user?.shortName ?? "???", color: Color.accentColor).offset(y: 1).padding(.trailing, 5)
CircleText(text: node.user?.displayShortName ?? "???", color: Color.accentColor).offset(y: 1).padding(.trailing, 5)
.offset(x: -15)
if UIDevice.current.userInterfaceIdiom == .pad {
Text(node.user?.longName ?? "Unknown").font(.headline)
.offset(x: -15)
} else {
Text(node.user?.longName ?? "Unknown").font(.title)
.offset(x: -15)
}
if UIDevice.current.userInterfaceIdiom == .pad {
Text(node.user?.displayLongName ?? "Unknown").font(.headline)
.offset(x: -15)
} else {
Text(node.user?.displayLongName ?? "Unknown").font(.title)
.offset(x: -15)
}
}
.padding(.bottom, 10)