| 1 | import Foundation |
| 2 | |
| 3 | // Per-viewer names (#577): what *I* call people, independent of the name they |
| 4 | // publish. The map is one blob on the server, sealed to the member's OWN |
| 5 | // public key (ECIES) — other members can't open it; the member's devices, |
| 6 | // which share the keypair, can. The two RULES here are pure twins of |
| 7 | // web/static/ui.js displayNameFor / renameNotice — the single source both |
| 8 | // platforms mirror; drift is a bug (NicknamesTests pins them). |
| 9 | |
| 10 | public struct NicknameEntry: Codable, Equatable, Sendable { |
| 11 | /// What the viewer calls them. |
| 12 | public var name: String |
| 13 | /// The published label the viewer last acknowledged — rename detection |
| 14 | /// compares against it, so each rename asks exactly once. |
| 15 | public var seen: String |
| 16 | |
| 17 | public init(name: String, seen: String) { |
| 18 | self.name = name |
| 19 | self.seen = seen |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | public enum Nicknames { |
| 24 | /// The name to show: the viewer's own name for someone wins over the name |
| 25 | /// they publish. "" when neither exists (callers pick their own fallback). |
| 26 | public static func displayName(_ nick: NicknameEntry?, label: String?) -> String { |
| 27 | if let nick, !nick.name.isEmpty { return nick.name } |
| 28 | return label ?? "" |
| 29 | } |
| 30 | |
| 31 | /// When the published name has moved on from what the viewer last |
| 32 | /// acknowledged, they get a choice (keep my name / use theirs) — nil when |
| 33 | /// there's nothing to ask: no nickname, no published name yet, the change |
| 34 | /// is already acknowledged, or they renamed to exactly what I call them. |
| 35 | public static func renameNotice(_ nick: NicknameEntry?, label: String?) -> (from: String, to: String)? { |
| 36 | guard let nick, !nick.name.isEmpty, let label, !label.isEmpty else { return nil } |
| 37 | if label == nick.seen || label == nick.name { return nil } |
| 38 | return (from: nick.name, to: label) |
| 39 | } |
| 40 | |
| 41 | // The blob's JSON shape — { v: 1, names: { uid: { name, seen } } }, |
| 42 | // exactly what the web seals (shell.js saveNicknamesNow). |
| 43 | private struct Blob: Codable { |
| 44 | var v: Int |
| 45 | var names: [String: NicknameEntry] |
| 46 | } |
| 47 | |
| 48 | /// Opens a sealed nicknames blob with the member's PKCS#8 private key. |
| 49 | public static func open(blobB64: String, privPkcs8B64: String) throws -> [String: NicknameEntry] { |
| 50 | let plain = try ECIES.open(sealedB64: blobB64, privPkcs8B64: privPkcs8B64) |
| 51 | return (try JSONDecoder().decode(Blob.self, from: plain)).names |
| 52 | } |
| 53 | |
| 54 | /// Seals a nicknames map to the member's own public key. |
| 55 | public static func seal(_ names: [String: NicknameEntry], recipientPubB64: String) throws -> String { |
| 56 | let plain = try JSONEncoder().encode(Blob(v: 1, names: names)) |
| 57 | return try ECIES.seal(bytes: plain, recipientPubB64: recipientPubB64) |
| 58 | } |
| 59 | } |
| 60 | |