From 6442a2ef098c973025fb5471677e75059ff653b5 Mon Sep 17 00:00:00 2001 From: DocNR Date: Wed, 29 Apr 2026 23:01:37 -0400 Subject: [PATCH 1/4] feat: enrich activity detail with what was signed + njump link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bfgreen flagged in testers chat that the Activity tab doesn't show which connected client signed an event, just a truncated pubkey. While fixing that, two adjacent gaps surfaced: the per-connection Recent Activity rows weren't tappable (so you couldn't drill in from inside a connection), and the detail view itself didn't actually say what was signed. This change: - Adds two optional fields to ActivityEntry: signedEventId (hex) and signedSummary (≤120 char one-liner). Both nil for existing rows; Codable handles missing keys cleanly so no migration needed. - New ActivitySummary helper builds the summary at sign-time from kind + tags. Pure function. Special handling: kind:3 diffs the new follow set against the last-signed snapshot so the summary reads "Followed @bob56789…bbbb" instead of "Followed 712 accounts" on every contact-list update. Snapshot capped at 2000 entries to bound NSE memory; over-cap falls back to "Updated contact list (N follows)". No raw event content or tag data stored — only kind-derived references. - Bech32.encode + Nip19 (encodeNote/encodeNevent) added so the detail view can build "Open on njump.me" links with relay hints from the connection's known relays. Existing Bech32.swift was decode-only. Hidden for kinds njump can't render meaningfully (DMs, kind:22242, etc.) via an explicit allowlist. - ActivityDetailView gets a Signed Event section (event id + Copy + njump link) and a Connection row resolving the client pet name. Pet name lookup uses the existing ClientPermissions.name field which is populated at pair time (nostrconnect URI metadata or bunker clientName) and editable via the rename pencil — no new plumbing. - ClientDetailView "Recent Activity" rows wrapped in NavigationLink to the same shared ActivityDetailView (uniform detail surface across both entry points). Rows now show signedSummary instead of generic kind label when available. - ActivityRowView shows the pet name instead of truncated pubkey when the connection has one. NSE memory: well within budget. Per-event enrichment adds ~120 bytes to the entry; kind:3 diff does ~200KB transient peak for ~700 follow sets, capped at 2000 entries. Foreground-only paths (njump encoding, view rendering) skip NSE entirely. Tests: 41 new unit tests across ActivitySummaryTests (kind coverage, diff cases, length cap), Bech32EncodeTests (round-trip + known npub fixture), Nip19Tests (TLV layout for nevent with relays/author/kind). Full suite passes. Plan: ~/.claude/plans/how-challenging-is-it-glistening-starfish.md Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave.xcodeproj/project.pbxproj | 12 + Clave/Views/Activity/ActivityDetailView.swift | 151 +++++++-- Clave/Views/Activity/ActivityRowView.swift | 12 +- Clave/Views/Home/ClientDetailView.swift | 21 +- ClaveTests/ActivitySummaryTests.swift | 292 ++++++++++++++++++ ClaveTests/Bech32EncodeTests.swift | 64 ++++ ClaveTests/Nip19Tests.swift | 143 +++++++++ Shared/ActivitySummary.swift | 186 +++++++++++ Shared/Bech32.swift | 55 ++++ Shared/LightSigner.swift | 67 +++- Shared/Nip19.swift | 81 +++++ Shared/SharedConstants.swift | 6 + Shared/SharedModels.swift | 49 +++ Shared/SharedStorage.swift | 22 ++ 14 files changed, 1133 insertions(+), 28 deletions(-) create mode 100644 ClaveTests/ActivitySummaryTests.swift create mode 100644 ClaveTests/Bech32EncodeTests.swift create mode 100644 ClaveTests/Nip19Tests.swift create mode 100644 Shared/ActivitySummary.swift create mode 100644 Shared/Nip19.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 2af065f..0859b00 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -40,6 +40,10 @@ EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */; }; + AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; + AE01F2A3B4C5D6E700000003 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; + AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; + AE01F2A3B4C5D6E700000006 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -101,6 +105,8 @@ EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundRelaySubscription.swift; sourceTree = ""; }; + AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySummary.swift; sourceTree = ""; }; + AE01F2A3B4C5D6E700000004 /* Nip19.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip19.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -210,6 +216,7 @@ EF3D7A572F8BD011005A6545 /* Shared */ = { isa = PBXGroup; children = ( + AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */, EF3D7A582F8BD020005A6545 /* Bech32.swift */, 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */, DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */, @@ -221,6 +228,7 @@ EF3D7A5A2F8BD020005A6545 /* LightEvent.swift */, EF3D7A5B2F8BD020005A6545 /* LightRelay.swift */, EF3D7A5C2F8BD020005A6545 /* LightSigner.swift */, + AE01F2A3B4C5D6E700000004 /* Nip19.swift */, EF3D7A5D2F8BD020005A6545 /* SharedConstants.swift */, EF3D7A5E2F8BD020005A6545 /* SharedKeychain.swift */, EFCA55B463A619311A35AF3C /* SharedModels.swift */, @@ -432,6 +440,8 @@ F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */, B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */, DE7E10C2DE7E10C2DE7E10C2 /* LogExporter.swift in Sources */, + AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */, + AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -466,6 +476,8 @@ 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */, DE7E10B3DE7E10B3DE7E10B3 /* DeveloperSettings.swift in Sources */, DE7E10C3DE7E10C3DE7E10C3 /* LogExporter.swift in Sources */, + AE01F2A3B4C5D6E700000003 /* ActivitySummary.swift in Sources */, + AE01F2A3B4C5D6E700000006 /* Nip19.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Clave/Views/Activity/ActivityDetailView.swift b/Clave/Views/Activity/ActivityDetailView.swift index f71c5c8..5254f71 100644 --- a/Clave/Views/Activity/ActivityDetailView.swift +++ b/Clave/Views/Activity/ActivityDetailView.swift @@ -3,6 +3,13 @@ import SwiftUI struct ActivityDetailView: View { let entry: ActivityEntry + /// Kinds njump.me renders meaningfully. Used to gate the "Open on njump.me" + /// button — for kinds outside this set (e.g., kind:22242 relay auth, DMs) + /// the button would just render JSON or 404, so we hide it. + private static let njumpRenderableKinds: Set = [ + 0, 1, 3, 6, 7, 1985, 9734, 9735, 30023, 30311 + ] + private let knownKinds: [Int: String] = [ 0: "Profile Metadata", 1: "Short Note", @@ -11,61 +18,157 @@ struct ActivityDetailView: View { 5: "Deletion", 6: "Repost", 7: "Reaction", + 14: "Sealed DM", + 1059: "Gift Wrap", 1984: "Report", + 1985: "Label", 9734: "Zap Request", 9735: "Zap Receipt", 10002: "Relay List", 22242: "Relay Auth", 30023: "Long-form Article", - 30078: "App-specific Data" + 30078: "App-specific Data", + 30311: "Live Event" ] var body: some View { List { - Section("Request") { - row("Method", value: entry.method) + requestSection + if entry.signedEventId != nil || entry.signedSummary != nil { + signedEventSection + } + clientSection + timingSection + } + .navigationTitle("Activity Detail") + .navigationBarTitleDisplayMode(.inline) + } - if let kind = entry.eventKind { - row("Event Kind", value: "Kind \(kind)") - if let label = knownKinds[kind] { - row("Kind Name", value: label) - } - } + // MARK: - Request - row("Status", value: entry.status.capitalized) + private var requestSection: some View { + Section("Request") { + row("Method", value: entry.method) - if let error = entry.errorMessage, !error.isEmpty { - row("Detail", value: error) + if let kind = entry.eventKind { + row("Event Kind", value: "Kind \(kind)") + if let label = knownKinds[kind] { + row("Kind Name", value: label) } } - Section("Client") { + row("Status", value: entry.status.capitalized) + + if let error = entry.errorMessage, !error.isEmpty { + row("Detail", value: error) + } + } + } + + // MARK: - Signed Event + + private var signedEventSection: some View { + Section("Signed Event") { + if let summary = entry.signedSummary { + Text(summary) + .font(.subheadline) + .textSelection(.enabled) + } + + if let eventId = entry.signedEventId { HStack { - Text("Pubkey") + Text("Event ID") .foregroundStyle(.secondary) Spacer() - Text(entry.clientPubkey) + Text(eventId) .font(.system(.caption2, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) + .textSelection(.enabled) } Button { - UIPasteboard.general.string = entry.clientPubkey + UIPasteboard.general.string = eventId UIImpactFeedbackGenerator(style: .light).impactOccurred() } label: { - Label("Copy Pubkey", systemImage: "doc.on.doc") + Label("Copy Event ID", systemImage: "doc.on.doc") + } + + if shouldShowNjumpLink, let url = njumpURL(for: eventId) { + Link(destination: url) { + Label("Open on njump.me", systemImage: "safari") + } } } + } + } + + private var shouldShowNjumpLink: Bool { + guard let kind = entry.eventKind else { return false } + return Self.njumpRenderableKinds.contains(kind) + } - Section("Timing") { - row("Date", value: formattedDate) - row("Time", value: formattedTime) - row("Relative", value: relativeTime) + private func njumpURL(for eventId: String) -> URL? { + let connection = SharedStorage.getConnectedClients().first { $0.pubkey == entry.clientPubkey } + let relays = connection?.relayUrls ?? [] + let bech32: String + if relays.isEmpty { + guard let note = try? Nip19.encodeNote(eventId: eventId) else { return nil } + bech32 = note + } else { + guard let nevent = try? Nip19.encodeNevent( + eventId: eventId, + relays: relays, + kind: entry.eventKind + ) else { + // Fall back to plain note if nevent encoding fails for any reason + return (try? Nip19.encodeNote(eventId: eventId)).flatMap { URL(string: "https://njump.me/\($0)") } } + bech32 = nevent + } + return URL(string: "https://njump.me/\(bech32)") + } + + // MARK: - Client + + private var clientSection: some View { + Section("Client") { + if let name = clientName, !name.isEmpty { + row("Connection", value: name) + } + + HStack { + Text("Pubkey") + .foregroundStyle(.secondary) + Spacer() + Text(entry.clientPubkey) + .font(.system(.caption2, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Button { + UIPasteboard.general.string = entry.clientPubkey + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Label("Copy Pubkey", systemImage: "doc.on.doc") + } + } + } + + private var clientName: String? { + SharedStorage.getClientPermissions(for: entry.clientPubkey)?.name + } + + // MARK: - Timing + + private var timingSection: some View { + Section("Timing") { + row("Date", value: formattedDate) + row("Time", value: formattedTime) + row("Relative", value: relativeTime) } - .navigationTitle("Activity Detail") - .navigationBarTitleDisplayMode(.inline) } private func row(_ label: String, value: String) -> some View { @@ -74,6 +177,8 @@ struct ActivityDetailView: View { .foregroundStyle(.secondary) Spacer() Text(value) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) } } diff --git a/Clave/Views/Activity/ActivityRowView.swift b/Clave/Views/Activity/ActivityRowView.swift index 8560cdb..43721cc 100644 --- a/Clave/Views/Activity/ActivityRowView.swift +++ b/Clave/Views/Activity/ActivityRowView.swift @@ -22,9 +22,11 @@ struct ActivityRowView: View { } HStack(spacing: 4) { - Text(truncatedPubkey(entry.clientPubkey)) + Text(clientLabel) .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) Text("·") .foregroundStyle(.tertiary) @@ -71,4 +73,12 @@ struct ActivityRowView: View { guard hex.count > 12 else { return hex } return String(hex.prefix(8)) + "..." + String(hex.suffix(4)) } + + private var clientLabel: String { + if let name = SharedStorage.getClientPermissions(for: entry.clientPubkey)?.name, + !name.isEmpty { + return name + } + return truncatedPubkey(entry.clientPubkey) + } } diff --git a/Clave/Views/Home/ClientDetailView.swift b/Clave/Views/Home/ClientDetailView.swift index 0f86896..32cd557 100644 --- a/Clave/Views/Home/ClientDetailView.swift +++ b/Clave/Views/Home/ClientDetailView.swift @@ -347,7 +347,14 @@ struct ClientDetailView: View { } else { VStack(spacing: 0) { ForEach(entries) { entry in - activityRow(entry) + NavigationLink { + ActivityDetailView(entry: entry) + } label: { + activityRow(entry) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + if entry.id != entries.last?.id { Divider() } @@ -373,7 +380,13 @@ struct ClientDetailView: View { VStack(alignment: .leading, spacing: 2) { Text(entry.method) .font(.subheadline.weight(.medium)) - if let kind = entry.eventKind { + .foregroundStyle(.primary) + if let summary = entry.signedSummary { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if let kind = entry.eventKind { Text(KnownKinds.label(for: kind)) .font(.caption) .foregroundStyle(.secondary) @@ -385,6 +398,10 @@ struct ClientDetailView: View { Text(relativeTime(entry.timestamp)) .font(.caption2) .foregroundStyle(.tertiary) + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) } .padding(.vertical, 6) } diff --git a/ClaveTests/ActivitySummaryTests.swift b/ClaveTests/ActivitySummaryTests.swift new file mode 100644 index 0000000..e6c9a1d --- /dev/null +++ b/ClaveTests/ActivitySummaryTests.swift @@ -0,0 +1,292 @@ +import XCTest +@testable import Clave + +final class ActivitySummaryTests: XCTestCase { + + // MARK: - Kind 1 (note) + + func testKind1NewNote() { + let summary = ActivitySummary.signedSummary(kind: 1, tags: []) + XCTAssertEqual(summary, "New note") + } + + func testKind1WithHashtag() { + let summary = ActivitySummary.signedSummary(kind: 1, tags: [["t", "nostr"]]) + XCTAssertEqual(summary, "New note · #nostr") + } + + func testKind1Reply() { + let summary = ActivitySummary.signedSummary( + kind: 1, + tags: [["e", "abc123def456789012345678901234567890123456789012345678901234abcd"]] + ) + XCTAssertEqual(summary, "Reply to e:abc123de…abcd") + } + + func testKind1ReplyWithMention() { + let summary = ActivitySummary.signedSummary( + kind: 1, + tags: [ + ["e", "abc123def456789012345678901234567890123456789012345678901234abcd"], + ["p", "alice5678901234567890123456789012345678901234567890123456789aaaa"] + ] + ) + XCTAssertEqual(summary, "Reply to e:abc123de…abcd · @alice567…aaaa") + } + + func testKind1MultipleMentionsCounted() { + let tags: [[String]] = [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"], + ["p", "bob5678901234567890123456789012345678901234567890123456789012bbbb"], + ["p", "carol6789012345678901234567890123456789012345678901234567890cccc"] + ] + let summary = ActivitySummary.signedSummary(kind: 1, tags: tags) + XCTAssertEqual(summary, "New note · @alice567…aaaa +2") + } + + // MARK: - Kind 0 (profile) + + func testKind0() { + let summary = ActivitySummary.signedSummary(kind: 0, tags: []) + XCTAssertEqual(summary, "Updated profile") + } + + // MARK: - Kind 3 (contacts) — diff handling + + func testKind3FirstEverNoSnapshot() { + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"], + ["p", "bob5678901234567890123456789012345678901234567890123456789012bbbb"] + ], + previousContactSet: nil + ) + XCTAssertEqual(summary, "Set contact list (2 follows)") + } + + func testKind3SingleAdd() { + let prior: Set = [ + "alice56789012345678901234567890123456789012345678901234567890aaaa" + ] + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"], + ["p", "bob5678901234567890123456789012345678901234567890123456789012bbbb"] + ], + previousContactSet: prior + ) + XCTAssertEqual(summary, "Followed @bob56789…bbbb") + } + + func testKind3SingleRemove() { + let prior: Set = [ + "alice56789012345678901234567890123456789012345678901234567890aaaa", + "bob5678901234567890123456789012345678901234567890123456789012bbbb" + ] + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"] + ], + previousContactSet: prior + ) + XCTAssertEqual(summary, "Unfollowed @bob56789…bbbb") + } + + func testKind3SmallMixedDiff() { + let prior: Set = [ + "alice56789012345678901234567890123456789012345678901234567890aaaa", + "bob5678901234567890123456789012345678901234567890123456789012bbbb" + ] + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"], + ["p", "carol6789012345678901234567890123456789012345678901234567890cccc"] + ], + previousContactSet: prior + ) + XCTAssertEqual(summary, "Contacts +1 / -1") + } + + func testKind3LargeDiff() { + var prior: Set = [] + for i in 0..<50 { + prior.insert(String(format: "%064d", i)) + } + var newTags: [[String]] = [] + for i in 25..<75 { + newTags.append(["p", String(format: "%064d", i)]) + } + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: newTags, + previousContactSet: prior + ) + XCTAssertEqual(summary, "Contacts +25 / -25") + } + + func testKind3OverflowSkipsDiff() { + var tags: [[String]] = [] + for i in 0..<2001 { + tags.append(["p", String(format: "%064d", i)]) + } + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: tags, + previousContactSet: ["dummy56789012345678901234567890123456789012345678901234567890dddd"] + ) + XCTAssertEqual(summary, "Updated contact list (2001 follows)") + } + + func testKind3UnchangedSet() { + let prior: Set = [ + "alice56789012345678901234567890123456789012345678901234567890aaaa" + ] + let summary = ActivitySummary.signedSummary( + kind: 3, + tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"] + ], + previousContactSet: prior + ) + XCTAssertEqual(summary, "Republished contact list (1 follow)") + } + + // MARK: - DMs + + func testKind4DM() { + let summary = ActivitySummary.signedSummary( + kind: 4, + tags: [["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"]] + ) + XCTAssertEqual(summary, "DM to @alice567…aaaa") + } + + func testKind14SealedDM() { + let summary = ActivitySummary.signedSummary( + kind: 14, + tags: [["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"]] + ) + XCTAssertEqual(summary, "DM to @alice567…aaaa") + } + + func testKind1059GiftWrap() { + let summary = ActivitySummary.signedSummary( + kind: 1059, + tags: [["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"]] + ) + XCTAssertEqual(summary, "DM to @alice567…aaaa") + } + + // MARK: - Repost / Reaction + + func testKind6Repost() { + let summary = ActivitySummary.signedSummary( + kind: 6, + tags: [["e", "abc123def456789012345678901234567890123456789012345678901234abcd"]] + ) + XCTAssertEqual(summary, "Reposted e:abc123de…abcd") + } + + func testKind7Reaction() { + let summary = ActivitySummary.signedSummary( + kind: 7, + tags: [["e", "abc123def456789012345678901234567890123456789012345678901234abcd"]] + ) + XCTAssertEqual(summary, "Reacted to e:abc123de…abcd") + } + + // MARK: - Zap request / Relay list / Relay auth + + func testKind9734ZapRequest() { + let summary = ActivitySummary.signedSummary( + kind: 9734, + tags: [["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"]] + ) + XCTAssertEqual(summary, "Zap request to @alice567…aaaa") + } + + func testKind10002RelayList() { + let summary = ActivitySummary.signedSummary( + kind: 10002, + tags: [ + ["r", "wss://relay.damus.io"], + ["r", "wss://relay.snort.social", "read"], + ["r", "wss://nos.lol"] + ] + ) + XCTAssertEqual(summary, "Relay list (3 relays)") + } + + func testKind22242RelayAuth() { + let summary = ActivitySummary.signedSummary( + kind: 22242, + tags: [ + ["relay", "wss://relay.damus.io"], + ["challenge", "abc123"] + ] + ) + XCTAssertEqual(summary, "Authed to wss://relay.damus.io") + } + + func testKind22242NoRelay() { + let summary = ActivitySummary.signedSummary( + kind: 22242, + tags: [["challenge", "abc123"]] + ) + XCTAssertEqual(summary, "Relay auth") + } + + // MARK: - Long-form / app data + + func testKind30023WithTitle() { + let summary = ActivitySummary.signedSummary( + kind: 30023, + tags: [["title", "On Building Signers"]] + ) + XCTAssertEqual(summary, "Article: \"On Building Signers\"") + } + + func testKind30023TitleTruncation() { + let longTitle = String(repeating: "x", count: 100) + let summary = ActivitySummary.signedSummary( + kind: 30023, + tags: [["title", longTitle]] + ) + XCTAssertNotNil(summary) + XCTAssertLessThanOrEqual(summary!.count, 80) + XCTAssertTrue(summary!.contains("…")) + } + + func testKind30023NoTitle() { + let summary = ActivitySummary.signedSummary(kind: 30023, tags: []) + XCTAssertEqual(summary, "Article") + } + + func testKind30078AppData() { + let summary = ActivitySummary.signedSummary( + kind: 30078, + tags: [["d", "my-app-key"]] + ) + XCTAssertEqual(summary, "App data (my-app-key)") + } + + // MARK: - Fallback + + func testUnknownKind() { + let summary = ActivitySummary.signedSummary(kind: 99999, tags: []) + XCTAssertEqual(summary, "Kind 99999") + } + + // MARK: - Length cap + + func testSummaryNeverExceedsCap() { + let manyHashtags: [[String]] = (0..<20).map { ["t", "longhashtag\($0)"] } + let summary = ActivitySummary.signedSummary(kind: 1, tags: manyHashtags) + XCTAssertNotNil(summary) + XCTAssertLessThanOrEqual(summary!.count, 120) + } +} diff --git a/ClaveTests/Bech32EncodeTests.swift b/ClaveTests/Bech32EncodeTests.swift new file mode 100644 index 0000000..adca629 --- /dev/null +++ b/ClaveTests/Bech32EncodeTests.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import Clave + +final class Bech32EncodeTests: XCTestCase { + + // MARK: - Known fixtures + + /// Test account npub from project memory: + /// hex pubkey 55127fc9...ed9b21 ↔ npub125f8lj0pcq7xk3v68w4h9ldenhh3v3x97gumm5yl8e0mgq0dnvssjptd2l + func testEncodeNpubMatchesKnownFixture() throws { + let hex = "55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21" + let expected = "npub125f8lj0pcq7xk3v68w4h9ldenhh3v3x97gumm5yl8e0mgq0dnvssjptd2l" + + let data = Data(hexString: hex)! + let encoded = try Bech32.encode(hrp: "npub", data: data) + XCTAssertEqual(encoded, expected) + } + + // MARK: - Round-trip + + func testRoundTripNpub() throws { + let hex = "55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21" + let data = Data(hexString: hex)! + let encoded = try Bech32.encode(hrp: "npub", data: data) + let (hrp, decoded) = try Bech32.decode(encoded) + XCTAssertEqual(hrp, "npub") + XCTAssertEqual(decoded, data) + } + + func testRoundTripNote() throws { + // Arbitrary 32-byte event id + let hex = "abc123def456789012345678901234567890123456789012345678901234abcd" + let data = Data(hexString: hex)! + let encoded = try Bech32.encode(hrp: "note", data: data) + XCTAssertTrue(encoded.hasPrefix("note1")) + let (hrp, decoded) = try Bech32.decode(encoded) + XCTAssertEqual(hrp, "note") + XCTAssertEqual(decoded, data) + } + + func testRoundTripNevent() throws { + // Arbitrary TLV-shaped payload (we'll exercise the full TLV in Nip19Tests; + // this just confirms Bech32 round-trips arbitrary bytes with a long HRP). + let bytes: [UInt8] = [ + 0x00, 0x20, + 0xab, 0xc1, 0x23, 0xde, 0xf4, 0x56, 0x78, 0x90, + 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, + 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, + 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0xab, 0xcd + ] + let data = Data(bytes) + let encoded = try Bech32.encode(hrp: "nevent", data: data) + XCTAssertTrue(encoded.hasPrefix("nevent1")) + let (hrp, decoded) = try Bech32.decode(encoded) + XCTAssertEqual(hrp, "nevent") + XCTAssertEqual(decoded, data) + } + + func testEncodeIsLowercase() throws { + let data = Data([0x01, 0x02, 0x03, 0x04]) + let encoded = try Bech32.encode(hrp: "NPUB", data: data) + XCTAssertEqual(encoded, encoded.lowercased()) + } +} diff --git a/ClaveTests/Nip19Tests.swift b/ClaveTests/Nip19Tests.swift new file mode 100644 index 0000000..cdb6051 --- /dev/null +++ b/ClaveTests/Nip19Tests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import Clave + +final class Nip19Tests: XCTestCase { + + // MARK: - encodeNote + + func testEncodeNoteRoundTrips() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNote(eventId: eventId) + XCTAssertTrue(encoded.hasPrefix("note1")) + + let (hrp, data) = try Bech32.decode(encoded) + XCTAssertEqual(hrp, "note") + XCTAssertEqual(data, Data(hexString: eventId)) + } + + func testEncodeNoteRejectsShortHex() { + XCTAssertThrowsError(try Nip19.encodeNote(eventId: "abc")) + } + + func testEncodeNoteRejectsLongHex() { + let tooLong = String(repeating: "a", count: 100) + XCTAssertThrowsError(try Nip19.encodeNote(eventId: tooLong)) + } + + func testEncodeNoteRejectsNonHex() { + let badHex = String(repeating: "z", count: 64) + XCTAssertThrowsError(try Nip19.encodeNote(eventId: badHex)) + } + + // MARK: - encodeNevent + + func testEncodeNeventIdOnly() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNevent(eventId: eventId) + XCTAssertTrue(encoded.hasPrefix("nevent1")) + + let (hrp, tlv) = try Bech32.decode(encoded) + XCTAssertEqual(hrp, "nevent") + + // Parse the TLV: should be exactly one entry — type 0x00, length 0x20, 32 bytes + let parsed = parseTLV(tlv) + XCTAssertEqual(parsed.count, 1) + XCTAssertEqual(parsed[0].type, 0x00) + XCTAssertEqual(parsed[0].value, Data(hexString: eventId)) + } + + func testEncodeNeventWithRelayHints() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNevent( + eventId: eventId, + relays: ["wss://relay.damus.io", "wss://nos.lol"] + ) + let (_, tlv) = try Bech32.decode(encoded) + let parsed = parseTLV(tlv) + + XCTAssertEqual(parsed.count, 3) + XCTAssertEqual(parsed[0].type, 0x00) + XCTAssertEqual(parsed[1].type, 0x01) + XCTAssertEqual(String(data: parsed[1].value, encoding: .ascii), "wss://relay.damus.io") + XCTAssertEqual(parsed[2].type, 0x01) + XCTAssertEqual(String(data: parsed[2].value, encoding: .ascii), "wss://nos.lol") + } + + func testEncodeNeventCapsRelaysAtTwo() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNevent( + eventId: eventId, + relays: ["wss://r1.test", "wss://r2.test", "wss://r3.test", "wss://r4.test"] + ) + let (_, tlv) = try Bech32.decode(encoded) + let parsed = parseTLV(tlv) + let relayCount = parsed.filter { $0.type == 0x01 }.count + XCTAssertEqual(relayCount, 2) + } + + func testEncodeNeventWithKind() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNevent(eventId: eventId, kind: 1) + + let (_, tlv) = try Bech32.decode(encoded) + let parsed = parseTLV(tlv) + let kindEntry = parsed.first { $0.type == 0x03 } + XCTAssertNotNil(kindEntry) + XCTAssertEqual(kindEntry?.value.count, 4) + + // Big-endian decode + let value = kindEntry!.value + let kind = (UInt32(value[0]) << 24) | (UInt32(value[1]) << 16) | + (UInt32(value[2]) << 8) | UInt32(value[3]) + XCTAssertEqual(kind, 1) + } + + func testEncodeNeventWithLargeKind() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let encoded = try Nip19.encodeNevent(eventId: eventId, kind: 30023) + + let (_, tlv) = try Bech32.decode(encoded) + let parsed = parseTLV(tlv) + let kindEntry = parsed.first { $0.type == 0x03 }! + let value = kindEntry.value + let kind = (UInt32(value[0]) << 24) | (UInt32(value[1]) << 16) | + (UInt32(value[2]) << 8) | UInt32(value[3]) + XCTAssertEqual(kind, 30023) + } + + func testEncodeNeventWithAuthor() throws { + let eventId = "abc123def456789012345678901234567890123456789012345678901234abcd" + let author = "55127fc9e1c03c6b459a3bab72fdb99def1644c5f239bdd09f3e5fb401ed9b21" + let encoded = try Nip19.encodeNevent(eventId: eventId, author: author) + + let (_, tlv) = try Bech32.decode(encoded) + let parsed = parseTLV(tlv) + let authorEntry = parsed.first { $0.type == 0x02 } + XCTAssertNotNil(authorEntry) + XCTAssertEqual(authorEntry?.value, Data(hexString: author)) + } + + // MARK: - TLV parser (test-only) + + private struct TLVEntry { + let type: UInt8 + let value: Data + } + + private func parseTLV(_ data: Data) -> [TLVEntry] { + var entries: [TLVEntry] = [] + var i = data.startIndex + while i < data.endIndex { + let type = data[i] + i = data.index(after: i) + guard i < data.endIndex else { break } + let length = Int(data[i]) + i = data.index(after: i) + let endIndex = data.index(i, offsetBy: length, limitedBy: data.endIndex) ?? data.endIndex + let value = data.subdata(in: i..` for pubkeys, `e:` for event ids); pet +/// names for *connected clients* are resolved at view time using +/// `entry.clientPubkey`, not embedded here. +enum ActivitySummary { + /// Maximum number of `p` tags we'll diff against the prior contact set + /// before falling back to a non-diff summary. Caps NSE memory pressure + /// for pathological accounts (5000+ follows). 99%+ of users sit well + /// under this. + static let kind3DiffCap = 2000 + + /// Hard upper bound on the stored summary string. Most paths produce + /// well under this; cap exists as a backstop against unbounded tag + /// values (e.g., a relay URL containing a query string in kind:22242). + static let maxLength = 120 + + static func signedSummary( + kind: Int, + tags: [[String]], + previousContactSet: Set? = nil + ) -> String? { + let raw: String + switch kind { + case 0: raw = "Updated profile" + case 1: raw = summarizeKind1(tags: tags) + case 3: raw = summarizeKind3(tags: tags, previous: previousContactSet) + case 4, 14, 1059: raw = summarizeDM(tags: tags) + case 6: raw = summarizeRefEvent(prefix: "Reposted", tags: tags) + case 7: raw = summarizeRefEvent(prefix: "Reacted to", tags: tags) + case 9734: raw = summarizeRefUser(prefix: "Zap request to", tags: tags) + case 10002: + let n = countTags(tags, named: "r") + raw = "Relay list (\(n) relay\(n == 1 ? "" : "s"))" + case 22242: raw = summarizeKind22242(tags: tags) + case 30023: raw = summarizeKind30023(tags: tags) + case 30078: raw = summarizeKind30078(tags: tags) + default: raw = "Kind \(kind)" + } + return cap(raw) + } + + // MARK: - Per-kind builders + + private static func summarizeKind1(tags: [[String]]) -> String { + var parts: [String] = [] + if let e = firstTag(tags, named: "e") { + parts.append("Reply to e:\(truncated(e))") + } else { + parts.append("New note") + } + let pTags = tags.compactMap { tag -> String? in + guard tag.first == "p", tag.count >= 2 else { return nil } + return tag[1] + } + if let firstP = pTags.first { + if pTags.count == 1 { + parts.append("@\(truncated(firstP))") + } else { + parts.append("@\(truncated(firstP)) +\(pTags.count - 1)") + } + } + let tTags = tags.compactMap { tag -> String? in + guard tag.first == "t", tag.count >= 2 else { return nil } + return tag[1] + } + if !tTags.isEmpty { + if tTags.count <= 2 { + parts.append(tTags.map { "#\($0)" }.joined(separator: " ")) + } else { + parts.append(tTags.prefix(2).map { "#\($0)" }.joined(separator: " ") + " +\(tTags.count - 2)") + } + } + return parts.joined(separator: " · ") + } + + private static func summarizeKind3(tags: [[String]], previous: Set?) -> String { + let newSet: Set = Set(tags.compactMap { tag -> String? in + guard tag.first == "p", tag.count >= 2 else { return nil } + return tag[1] + }) + let count = newSet.count + + if count > kind3DiffCap { + return "Updated contact list (\(count) follow\(count == 1 ? "" : "s"))" + } + guard let previous else { + return "Set contact list (\(count) follow\(count == 1 ? "" : "s"))" + } + + let added = newSet.subtracting(previous) + let removed = previous.subtracting(newSet) + + if added.isEmpty && removed.isEmpty { + return "Republished contact list (\(count) follow\(count == 1 ? "" : "s"))" + } + + let totalChanges = added.count + removed.count + if totalChanges > 3 { + return "Contacts +\(added.count) / -\(removed.count)" + } + + if added.count == 1 && removed.isEmpty { + return "Followed @\(truncated(added.first!))" + } + if removed.count == 1 && added.isEmpty { + return "Unfollowed @\(truncated(removed.first!))" + } + return "Contacts +\(added.count) / -\(removed.count)" + } + + private static func summarizeDM(tags: [[String]]) -> String { + if let p = firstTag(tags, named: "p") { + return "DM to @\(truncated(p))" + } + return "DM" + } + + private static func summarizeRefEvent(prefix: String, tags: [[String]]) -> String { + if let e = firstTag(tags, named: "e") { + return "\(prefix) e:\(truncated(e))" + } + return prefix + } + + private static func summarizeRefUser(prefix: String, tags: [[String]]) -> String { + if let p = firstTag(tags, named: "p") { + return "\(prefix) @\(truncated(p))" + } + return prefix + } + + private static func summarizeKind22242(tags: [[String]]) -> String { + if let relay = firstTag(tags, named: "relay"), !relay.isEmpty { + return "Authed to \(relay)" + } + return "Relay auth" + } + + private static func summarizeKind30023(tags: [[String]]) -> String { + guard let title = firstTag(tags, named: "title"), !title.isEmpty else { + return "Article" + } + let trimmed: String + if title.count > 60 { + trimmed = String(title.prefix(59)) + "…" + } else { + trimmed = title + } + return "Article: \"\(trimmed)\"" + } + + private static func summarizeKind30078(tags: [[String]]) -> String { + if let d = firstTag(tags, named: "d"), !d.isEmpty { + return "App data (\(d))" + } + return "App data" + } + + // MARK: - Helpers + + private static func firstTag(_ tags: [[String]], named name: String) -> String? { + for tag in tags where tag.first == name && tag.count >= 2 { + return tag[1] + } + return nil + } + + private static func countTags(_ tags: [[String]], named name: String) -> Int { + tags.reduce(0) { $0 + ($1.first == name ? 1 : 0) } + } + + private static func truncated(_ hex: String) -> String { + guard hex.count > 12 else { return hex } + return String(hex.prefix(8)) + "…" + String(hex.suffix(4)) + } + + private static func cap(_ s: String) -> String { + guard s.count > maxLength else { return s } + return String(s.prefix(maxLength - 1)) + "…" + } +} diff --git a/Shared/Bech32.swift b/Shared/Bech32.swift index 6216231..e8d095f 100644 --- a/Shared/Bech32.swift +++ b/Shared/Bech32.swift @@ -34,6 +34,61 @@ enum Bech32 { return data } + /// Encode raw bytes with the given HRP. Inverse of `decode`. + /// Used by NIP-19 to build `npub`, `note`, `nevent`, etc. + static func encode(hrp: String, data: Data) throws -> String { + let lowerHrp = hrp.lowercased() + let bytes5 = try convertBits(data: Array(data), fromBits: 8, toBits: 5, pad: true) + let checksum = createChecksum(hrp: lowerHrp, data: bytes5) + let combined = bytes5 + checksum + var payload = "" + for v in combined { + let idx = charset.index(charset.startIndex, offsetBy: Int(v)) + payload.append(charset[idx]) + } + return "\(lowerHrp)1\(payload)" + } + + // MARK: - Polymod / checksum (BIP-173) + + private static let generator: [UInt32] = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 + ] + + private static func polymod(_ values: [UInt8]) -> UInt32 { + var chk: UInt32 = 1 + for v in values { + let top = chk >> 25 + chk = ((chk & 0x1ffffff) << 5) ^ UInt32(v) + for i in 0..<5 where ((top >> i) & 1) == 1 { + chk ^= generator[i] + } + } + return chk + } + + private static func hrpExpand(_ hrp: String) -> [UInt8] { + var ret: [UInt8] = [] + for c in hrp.unicodeScalars { + ret.append(UInt8(c.value >> 5)) + } + ret.append(0) + for c in hrp.unicodeScalars { + ret.append(UInt8(c.value & 31)) + } + return ret + } + + private static func createChecksum(hrp: String, data: [UInt8]) -> [UInt8] { + let values = hrpExpand(hrp) + data + [0, 0, 0, 0, 0, 0] + let mod = polymod(values) ^ 1 + var ret: [UInt8] = [] + for i in 0..<6 { + ret.append(UInt8((mod >> (5 * (5 - i))) & 31)) + } + return ret + } + private static func convertBits(data: [UInt8], fromBits: Int, toBits: Int, pad: Bool) throws -> [UInt8] { var acc: Int = 0 var bits: Int = 0 diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index da58f6b..f074f36 100644 --- a/Shared/LightSigner.swift +++ b/Shared/LightSigner.swift @@ -15,6 +15,14 @@ enum LightSigner { /// with a stable identifier matching the queued PendingRequest.id. /// nil for all other statuses. var pendingRequestId: String? = nil + /// Hex id of the resulting signed event. Set only for successful + /// `sign_event` results; nil otherwise. Forwarded into ActivityEntry + /// to power the njump deep link in the activity detail view. + var signedEventId: String? = nil + /// One-line summary of what was signed, derived at sign-time from + /// kind + tags via `ActivitySummary.signedSummary`. Forwarded into + /// ActivityEntry verbatim. + var signedSummary: String? = nil } static func handleRequest( @@ -329,12 +337,65 @@ enum LightSigner { logger.error("[LightSigner] Relay did not accept response event") } + // Enrich activity log for successful sign_event with the resulting + // event id and a tag-derived one-liner. Side-effect: kind:3 also + // updates the persisted contact-set snapshot used for diffing. + var signedEventId: String? = nil + var signedSummary: String? = nil + if status == "signed", method == "sign_event", let json = responseResult { + (signedEventId, signedSummary) = extractSignedEventEnrichment(signedEventJSON: json) + } + let result = RequestResult(method: method, eventKind: eventKind, clientPubkey: senderPubkey, - status: status, errorMessage: errorMsg) + status: status, errorMessage: errorMsg, + signedEventId: signedEventId, signedSummary: signedSummary) logAndTrack(result: result, clientName: clientName) return result } + /// Parse the signed-event JSON returned by `processRequest("sign_event", …)` + /// to extract the event id and build the activity summary. For kind:3, also + /// reads + updates the stored contact-set snapshot so the next sign can diff. + /// Failures are best-effort — returns (nil, nil) and lets logging proceed. + private static func extractSignedEventEnrichment(signedEventJSON: String) -> (String?, String?) { + guard let data = signedEventJSON.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, nil) + } + let id = dict["id"] as? String + let kind = dict["kind"] as? Int + let rawTags = dict["tags"] as? [[Any]] ?? [] + let tags: [[String]] = rawTags.map { row in row.compactMap { $0 as? String } } + + guard let kind else { return (id, nil) } + + // Kind:3 uses the prior snapshot to compute follow add/remove diffs. + // Update the snapshot post-summary so the diff reflects what changed + // *with this sign*, not what would have changed against a stale set. + let previous: Set? + if kind == 3 { + previous = SharedStorage.getLastContactSet() + } else { + previous = nil + } + + let summary = ActivitySummary.signedSummary(kind: kind, tags: tags, previousContactSet: previous) + + if kind == 3 { + let newSet = Set(tags.compactMap { tag -> String? in + guard tag.first == "p", tag.count >= 2 else { return nil } + return tag[1] + }) + // Only persist when within the diff cap; over-cap kind:3 doesn't + // benefit from a snapshot (we fall back to "Updated contact list (N follows)"). + if newSet.count <= ActivitySummary.kind3DiffCap { + SharedStorage.saveLastContactSet(newSet) + } + } + + return (id, summary) + } + // MARK: - Request Processing static func processRequest(method: String, params: [String], privateKey: Data) -> (String?, String?) { @@ -449,7 +510,9 @@ enum LightSigner { clientPubkey: result.clientPubkey, timestamp: Date().timeIntervalSince1970, status: result.status, - errorMessage: result.errorMessage + errorMessage: result.errorMessage, + signedEventId: result.signedEventId, + signedSummary: result.signedSummary ) SharedStorage.logActivity(entry) if result.clientPubkey != "unknown" && result.status != "blocked" diff --git a/Shared/Nip19.swift b/Shared/Nip19.swift new file mode 100644 index 0000000..2c7cffa --- /dev/null +++ b/Shared/Nip19.swift @@ -0,0 +1,81 @@ +import Foundation + +/// NIP-19 bech32-encoded entity references. Just the encoders we need — +/// `note` (event id only) and `nevent` (event id + optional relay hints + +/// optional author + optional kind, TLV-encoded). Decoders live in +/// `Bech32.decode`. +/// +/// Used by `ActivityDetailView` to build "Open on njump.me" links. njump +/// accepts either `note1…` or `nevent1…`; `nevent` is preferred when we +/// have relay hints because it makes njump's lookup faster and more +/// reliable. +enum Nip19 { + enum EncodeError: Error { + case invalidHexLength + case invalidHex + } + + // TLV type bytes per NIP-19 + private static let tlvSpecial: UInt8 = 0 + private static let tlvRelay: UInt8 = 1 + private static let tlvAuthor: UInt8 = 2 + private static let tlvKind: UInt8 = 3 + + /// Bech32-encode a 32-byte event id with HRP `note`. Simplest form, + /// no TLV — just the raw 32 bytes. + static func encodeNote(eventId: String) throws -> String { + guard eventId.count == 64, let data = Data(hexString: eventId), data.count == 32 else { + throw EncodeError.invalidHexLength + } + return try Bech32.encode(hrp: "note", data: data) + } + + /// Bech32-encode a `nevent` TLV reference with optional relay hints, + /// author, and kind. Relay hints help njump (and other clients) find + /// the event faster — pass the connection's known relays, capped at 2. + static func encodeNevent( + eventId: String, + relays: [String] = [], + author: String? = nil, + kind: Int? = nil + ) throws -> String { + guard eventId.count == 64, let idBytes = Data(hexString: eventId), idBytes.count == 32 else { + throw EncodeError.invalidHexLength + } + + var tlv = Data() + // 0x00: event id (32 bytes, required) + tlv.append(tlvSpecial) + tlv.append(UInt8(idBytes.count)) + tlv.append(idBytes) + + // 0x01: relay URLs (optional, repeatable, ASCII bytes) + for relay in relays.prefix(2) { + guard let relayBytes = relay.data(using: .ascii), relayBytes.count <= 255 else { continue } + tlv.append(tlvRelay) + tlv.append(UInt8(relayBytes.count)) + tlv.append(relayBytes) + } + + // 0x02: author pubkey (optional, 32 bytes) + if let author, author.count == 64, + let authorBytes = Data(hexString: author), authorBytes.count == 32 { + tlv.append(tlvAuthor) + tlv.append(UInt8(authorBytes.count)) + tlv.append(authorBytes) + } + + // 0x03: kind (optional, 4 bytes big-endian) + if let kind { + tlv.append(tlvKind) + tlv.append(0x04) + let k = UInt32(bitPattern: Int32(kind)) + tlv.append(UInt8((k >> 24) & 0xff)) + tlv.append(UInt8((k >> 16) & 0xff)) + tlv.append(UInt8((k >> 8) & 0xff)) + tlv.append(UInt8(k & 0xff)) + } + + return try Bech32.encode(hrp: "nevent", data: tlv) + } +} diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index 9dc7734..1bcff4f 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -22,6 +22,12 @@ enum SharedConstants { static let pairedClientsKey = "pairedClients" static let clientPermissionsKey = "clientPermissions" static let cachedProfileKey = "cachedProfile" + /// Snapshot of the user's last signed kind:3 contact-list pubkey set, + /// stored as a JSON-encoded `[String]` (sorted hex pubkeys). Used by + /// `ActivitySummary` to compute add/remove diffs on subsequent kind:3 + /// signs so the activity summary reads "Followed @alice" instead of + /// "Followed 712 accounts". Skipped when the new kind:3 has >2000 p tags. + static let lastContactSetKey = "lastContactSet" /// `Date.timeIntervalSince1970` of the most recent successful POST to /// `/register`. Used by `AppState.ensureRegisteredFresh()` to throttle /// opportunistic re-registers to ~30 min while still self-healing from diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index b3dacc5..f3e0652 100644 --- a/Shared/SharedModels.swift +++ b/Shared/SharedModels.swift @@ -8,6 +8,55 @@ struct ActivityEntry: Codable, Identifiable { let timestamp: Double let status: String // "signed", "blocked", "error" let errorMessage: String? + /// Hex id of the resulting signed event. Set only for `sign_event` with + /// status `"signed"`. Powers the njump deep link + Copy ID button in + /// ActivityDetailView. nil for everything else. + let signedEventId: String? + /// One-line characterization of what was signed, built at log-time from + /// kind + tags via `ActivitySummary.signedSummary`. Stored verbatim + /// (≤120 chars). Pet-name substitution for `@` happens at render + /// time so renames apply retroactively. + let signedSummary: String? + + init( + id: String, + method: String, + eventKind: Int?, + clientPubkey: String, + timestamp: Double, + status: String, + errorMessage: String?, + signedEventId: String? = nil, + signedSummary: String? = nil + ) { + self.id = id + self.method = method + self.eventKind = eventKind + self.clientPubkey = clientPubkey + self.timestamp = timestamp + self.status = status + self.errorMessage = errorMessage + self.signedEventId = signedEventId + self.signedSummary = signedSummary + } + + private enum CodingKeys: String, CodingKey { + case id, method, eventKind, clientPubkey, timestamp, status, errorMessage + case signedEventId, signedSummary + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + method = try c.decode(String.self, forKey: .method) + eventKind = try c.decodeIfPresent(Int.self, forKey: .eventKind) + clientPubkey = try c.decode(String.self, forKey: .clientPubkey) + timestamp = try c.decode(Double.self, forKey: .timestamp) + status = try c.decode(String.self, forKey: .status) + errorMessage = try c.decodeIfPresent(String.self, forKey: .errorMessage) + signedEventId = try c.decodeIfPresent(String.self, forKey: .signedEventId) + signedSummary = try c.decodeIfPresent(String.self, forKey: .signedSummary) + } } /// A signing request for a protected kind, queued by the NSE for in-app approval. diff --git a/Shared/SharedStorage.swift b/Shared/SharedStorage.swift index ddae5da..bd08bee 100644 --- a/Shared/SharedStorage.swift +++ b/Shared/SharedStorage.swift @@ -284,6 +284,28 @@ enum SharedStorage { saveClientPermissions(perms) } + // MARK: - Last contact-list snapshot (kind:3 diff) + + /// The most-recently-signed kind:3 contact-list pubkey set, or nil if no + /// kind:3 has been signed yet on this install. Used by `ActivitySummary` + /// to compute add/remove diffs. + static func getLastContactSet() -> Set? { + guard let arr: [String] = load(forKey: SharedConstants.lastContactSetKey) else { + return nil + } + return Set(arr) + } + + /// Replace the snapshot with the given pubkey set. Stored as a sorted + /// array for stable serialization. Pass `nil` to clear. + static func saveLastContactSet(_ set: Set?) { + guard let set else { + defaults.removeObject(forKey: SharedConstants.lastContactSetKey) + return + } + save(Array(set).sorted(), forKey: SharedConstants.lastContactSetKey) + } + // MARK: - Per-event-id dedupe (cross-process via app-group UserDefaults) // // Used by LightSigner.handleRequest to short-circuit duplicate processing From 70ed25da31c9e119ad21d2fb0d7e82eba8041b52 Mon Sep 17 00:00:00 2001 From: DocNR Date: Wed, 29 Apr 2026 23:14:38 -0400 Subject: [PATCH 2/4] build: bump pbxproj to 30 for activity-detail TestFlight --- Clave.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index 0859b00..e9f27bf 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -631,7 +631,7 @@ CODE_SIGN_ENTITLEMENTS = Clave/Clave.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -669,7 +669,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; ENABLE_PREVIEWS = YES; @@ -705,7 +705,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -727,7 +727,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -748,7 +748,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -768,7 +768,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -789,7 +789,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ClaveNSE/Info.plist; @@ -820,7 +820,7 @@ CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; From 4052c0ad2577412cb42651a7bdeac96a2af97857 Mon Sep 17 00:00:00 2001 From: DocNR Date: Thu, 30 Apr 2026 07:54:41 -0400 Subject: [PATCH 3/4] fix: pending-approval missing log entry + njump for wrapper kinds (build 31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced during build 30 internal TestFlight: 1. **kind:3 (and any protected kind) showed pending row but no signed row after approval.** Root cause: `LightSigner.handleRequest` calls `markEventProcessed` unconditionally at the top. When NSE first sees the request and queues it, the event id is marked. When the user approves, `AppState.approvePendingRequest` re-calls `handleRequest` with the same event — within the 60s dedupe window it short-circuits to "skipped-duplicate" without ever calling `logAndTrack`. Latent pre-existing bug exposed by the new summary making the missing row visible. Fix: add `skipDedupe: Bool = false` param; approval path passes true. Approval is a deliberate replay of a known-queued event, not a race with another process. 2. **njump linked to the reaction itself, not the reacted-to note.** For kind:6 (repost), kind:7 (reaction), kind:9734 (zap request), kind:9735 (zap receipt) the wrapper event is meaningless on njump in isolation — the user wants the referenced event. Fix: new `signedReferencedEventId: String?` field on ActivityEntry, populated from the first valid `e` tag (64-char hex) for those kinds. ActivityDetailView's njump button uses the referenced id with label "Open referenced event on njump.me" for wrapper kinds; "Copy Event ID" still copies the user's signed wrapper id. Hidden when no e tag exists. Build 31 ready for re-archive on feat/activity-detail-enrichment. 11 new unit tests in LightSignerEnrichmentTests; full suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave.xcodeproj/project.pbxproj | 28 ++-- Clave/AppState.swift | 1 + Clave/Views/Activity/ActivityDetailView.swift | 41 ++++-- ClaveTests/LightSignerEnrichmentTests.swift | 121 ++++++++++++++++++ Shared/LightSigner.swift | 62 +++++++-- Shared/SharedModels.swift | 16 ++- 6 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 ClaveTests/LightSignerEnrichmentTests.swift diff --git a/Clave.xcodeproj/project.pbxproj b/Clave.xcodeproj/project.pbxproj index e9f27bf..22b7996 100644 --- a/Clave.xcodeproj/project.pbxproj +++ b/Clave.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 006B17A84C114EDF9B129CAE /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; 6D2C503B9EF64C8EA5101CDB /* NostrConnectParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */; }; + AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; + AE01F2A3B4C5D6E700000003 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; + AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; + AE01F2A3B4C5D6E700000006 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; B0EBA01E2F90AB01000A0001 /* PendingApprovalBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */; }; BD247C1AD3A7497E8DF29530 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; DD90DE0C2D8944D08B23C606 /* ClientPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */; }; @@ -40,10 +44,6 @@ EFC7FD75109694C0A7F86D26 /* SharedModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFCA55B463A619311A35AF3C /* SharedModels.swift */; }; EFE12D1EC4CE48622767D427 /* SharedStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */; }; F60FCE012F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */; }; - AE01F2A3B4C5D6E700000002 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; - AE01F2A3B4C5D6E700000003 /* ActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */; }; - AE01F2A3B4C5D6E700000005 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; - AE01F2A3B4C5D6E700000006 /* Nip19.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE01F2A3B4C5D6E700000004 /* Nip19.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,6 +87,8 @@ /* Begin PBXFileReference section */ 34B139A8E147475F9B40B3D5 /* NostrConnectParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnectParser.swift; sourceTree = ""; }; 894FE9FF88CD485ABCD31C05 /* ClientPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientPermissions.swift; sourceTree = ""; }; + AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySummary.swift; sourceTree = ""; }; + AE01F2A3B4C5D6E700000004 /* Nip19.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip19.swift; sourceTree = ""; }; B0EBA01E2F90AB01000A0002 /* PendingApprovalBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingApprovalBanner.swift; sourceTree = ""; }; DE7E10B1DE7E10B1DE7E10B1 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; DE7E10C1DE7E10C1DE7E10C1 /* LogExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogExporter.swift; sourceTree = ""; }; @@ -105,8 +107,6 @@ EFCA55B463A619311A35AF3C /* SharedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedModels.swift; sourceTree = ""; }; EFEE4316AC522CDDA35AFAC1 /* SharedStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStorage.swift; sourceTree = ""; }; F60FCE002F8BCAE3000FAC0A /* ForegroundRelaySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForegroundRelaySubscription.swift; sourceTree = ""; }; - AE01F2A3B4C5D6E700000001 /* ActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySummary.swift; sourceTree = ""; }; - AE01F2A3B4C5D6E700000004 /* Nip19.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nip19.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -631,7 +631,7 @@ CODE_SIGN_ENTITLEMENTS = Clave/Clave.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -669,7 +669,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; ENABLE_PREVIEWS = YES; @@ -705,7 +705,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -727,7 +727,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.4; @@ -748,7 +748,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -768,7 +768,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 0.1.0; @@ -789,7 +789,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ClaveNSE/Info.plist; @@ -820,7 +820,7 @@ CODE_SIGN_ENTITLEMENTS = ClaveNSE/ClaveNSE.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 31; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 944AF56S27; GENERATE_INFOPLIST_FILE = YES; diff --git a/Clave/AppState.swift b/Clave/AppState.swift index 315cde2..0afef04 100644 --- a/Clave/AppState.swift +++ b/Clave/AppState.swift @@ -368,6 +368,7 @@ final class AppState { privateKey: privateKey, requestEvent: requestEvent, skipProtection: true, + skipDedupe: true, responseRelayUrl: request.responseRelayUrl ) SharedStorage.removePendingRequest(id: request.id) diff --git a/Clave/Views/Activity/ActivityDetailView.swift b/Clave/Views/Activity/ActivityDetailView.swift index 5254f71..212d276 100644 --- a/Clave/Views/Activity/ActivityDetailView.swift +++ b/Clave/Views/Activity/ActivityDetailView.swift @@ -10,6 +10,12 @@ struct ActivityDetailView: View { 0, 1, 3, 6, 7, 1985, 9734, 9735, 30023, 30311 ] + /// Wrapper kinds where the user-meaningful target is the *referenced* + /// event (`signedReferencedEventId`), not the wrapper itself. njump-ing + /// to a "❤" reaction is useless; njump-ing to the reacted-to note is + /// what the user wants. For these kinds the button label changes too. + private static let wrapperKinds: Set = [6, 7, 9734, 9735] + private let knownKinds: [Int: String] = [ 0: "Profile Metadata", 1: "Short Note", @@ -94,21 +100,36 @@ struct ActivityDetailView: View { Label("Copy Event ID", systemImage: "doc.on.doc") } - if shouldShowNjumpLink, let url = njumpURL(for: eventId) { - Link(destination: url) { - Label("Open on njump.me", systemImage: "safari") - } - } + njumpButton } } } - private var shouldShowNjumpLink: Bool { - guard let kind = entry.eventKind else { return false } - return Self.njumpRenderableKinds.contains(kind) + /// Build the "Open on njump.me" button if we have a meaningful target. + /// For wrapper kinds (reaction/repost/zap), the target is the referenced + /// event (so njump renders the reacted-to note, not the bare "❤"). + /// For other renderable kinds, the target is the user's own signed event. + /// Returns nil if the kind isn't njump-renderable or we don't have an id. + @ViewBuilder + private var njumpButton: some View { + if let kind = entry.eventKind, Self.njumpRenderableKinds.contains(kind) { + let isWrapper = Self.wrapperKinds.contains(kind) + // For wrapper kinds, the wrapper itself isn't useful on njump; + // require a referenced id and skip the button if absent. + let targetId: String? = isWrapper ? entry.signedReferencedEventId : entry.signedEventId + let label = isWrapper ? "Open referenced event on njump.me" : "Open on njump.me" + if let id = targetId, let url = njumpURL(for: id, kindHint: isWrapper ? nil : kind) { + Link(destination: url) { + Label(label, systemImage: "safari") + } + } + } } - private func njumpURL(for eventId: String) -> URL? { + /// `kindHint` is the kind to embed in the nevent TLV. For wrapper kinds + /// we pass nil because we don't know the referenced event's kind from + /// the activity log alone (could be a kind:1 note, kind:30023 article, etc.). + private func njumpURL(for eventId: String, kindHint: Int?) -> URL? { let connection = SharedStorage.getConnectedClients().first { $0.pubkey == entry.clientPubkey } let relays = connection?.relayUrls ?? [] let bech32: String @@ -119,7 +140,7 @@ struct ActivityDetailView: View { guard let nevent = try? Nip19.encodeNevent( eventId: eventId, relays: relays, - kind: entry.eventKind + kind: kindHint ) else { // Fall back to plain note if nevent encoding fails for any reason return (try? Nip19.encodeNote(eventId: eventId)).flatMap { URL(string: "https://njump.me/\($0)") } diff --git a/ClaveTests/LightSignerEnrichmentTests.swift b/ClaveTests/LightSignerEnrichmentTests.swift new file mode 100644 index 0000000..d84d336 --- /dev/null +++ b/ClaveTests/LightSignerEnrichmentTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import Clave + +/// Tests for `LightSigner.extractSignedEventEnrichment` — the enrichment +/// helper that pulls the resulting event id, builds the activity summary, +/// and (for wrapper kinds) extracts the referenced event id from a +/// successful sign_event response. Pure logic, no I/O outside the kind:3 +/// snapshot path which we exercise via SharedStorage in a separate test. +final class LightSignerEnrichmentTests: XCTestCase { + + // MARK: - Referenced event id (the kind:7 njump fix) + + func testKind1NoReferencedEventId() { + let json = signedEventJSON(kind: 1, tags: [ + ["e", "abc123def456789012345678901234567890123456789012345678901234abcd"] + ]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertNotNil(result.eventId) + XCTAssertNil(result.referencedEventId, "kind:1 is not a wrapper kind — referenced id should be nil even when an e tag exists") + } + + func testKind7ExtractsReferencedEventIdFromETag() { + let target = "abc123def456789012345678901234567890123456789012345678901234abcd" + let json = signedEventJSON(kind: 7, tags: [ + ["e", target], + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"] + ]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertEqual(result.referencedEventId, target) + XCTAssertNotEqual(result.eventId, result.referencedEventId, "signed wrapper id and referenced id must be distinct") + } + + func testKind6RepostExtractsReferencedEventId() { + let target = "def4567890123456789012345678901234567890123456789012345678901234" + let json = signedEventJSON(kind: 6, tags: [["e", target]]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertEqual(result.referencedEventId, target) + } + + func testKind9734ZapRequestExtractsReferencedEventId() { + let target = "1234567890123456789012345678901234567890123456789012345678901234" + let json = signedEventJSON(kind: 9734, tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"], + ["e", target] + ]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertEqual(result.referencedEventId, target) + } + + func testKind7WithoutETagReturnsNilReferencedEventId() { + let json = signedEventJSON(kind: 7, tags: [ + ["p", "alice56789012345678901234567890123456789012345678901234567890aaaa"] + ]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertNil(result.referencedEventId, "no e tag — referenced id should be nil so view can hide the njump button") + } + + func testKind7RejectsMalformedETag() { + // 64-char hex is required — short or non-hex values must be rejected + // so we never feed garbage to Nip19.encodeNote in the view layer. + let json = signedEventJSON(kind: 7, tags: [["e", "abc"]]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertNil(result.referencedEventId) + } + + func testKind7RejectsNonHexETag() { + let nonHex = String(repeating: "z", count: 64) + let json = signedEventJSON(kind: 7, tags: [["e", nonHex]]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertNil(result.referencedEventId) + } + + func testWrapperKindsContainsExpectedSet() { + XCTAssertEqual(LightSigner.wrapperKinds, [6, 7, 9734, 9735]) + } + + // MARK: - Event id + summary basics + + func testReturnsNilForMalformedJSON() { + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: "not json") + XCTAssertNil(result.eventId) + XCTAssertNil(result.summary) + XCTAssertNil(result.referencedEventId) + } + + func testEventIdRoundTripsFromJSON() { + let id = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + let json = signedEventJSON(kind: 1, tags: [], idOverride: id) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertEqual(result.eventId, id) + } + + func testSummaryBuiltFromKindAndTags() { + let json = signedEventJSON(kind: 1, tags: [ + ["t", "nostr"] + ]) + let result = LightSigner.extractSignedEventEnrichment(signedEventJSON: json) + XCTAssertEqual(result.summary, "New note · #nostr") + } + + // MARK: - Helpers + + private func signedEventJSON( + kind: Int, + tags: [[String]], + idOverride: String? = nil + ) -> String { + let id = idOverride ?? "0000000000000000000000000000000000000000000000000000000000000000" + let dict: [String: Any] = [ + "id": id, + "kind": kind, + "tags": tags, + "content": "", + "pubkey": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "created_at": 1714003200, + "sig": "deadbeef" + ] + let data = try! JSONSerialization.data(withJSONObject: dict) + return String(data: data, encoding: .utf8)! + } +} diff --git a/Shared/LightSigner.swift b/Shared/LightSigner.swift index f074f36..5d8b395 100644 --- a/Shared/LightSigner.swift +++ b/Shared/LightSigner.swift @@ -23,12 +23,17 @@ enum LightSigner { /// kind + tags via `ActivitySummary.signedSummary`. Forwarded into /// ActivityEntry verbatim. var signedSummary: String? = nil + /// First `e` tag for wrapper-around-reference kinds (6, 7, 9734, + /// 9735). Forwarded into ActivityEntry to redirect the njump + /// button to the more-meaningful target. + var signedReferencedEventId: String? = nil } static func handleRequest( privateKey: Data, requestEvent: [String: Any], skipProtection: Bool = false, + skipDedupe: Bool = false, responseRelays: [LightRelay]? = nil, responseRelayUrl: String? = nil ) async throws -> RequestResult { @@ -43,11 +48,20 @@ enum LightSigner { // relay subscription (Layer 1) call into this function; whichever // marks first wins, others skip. Cross-process semantics are lossy // by design — see SharedStorage.markEventProcessed. + // + // `skipDedupe` is set by the pending-approval replay path + // (`AppState.approvePendingRequest`): NSE marked the event id as + // processed when it queued the request, so a fast approve (<60s + // window) would otherwise short-circuit here without producing the + // "signed" activity entry. Pending approvals are not new arrivals + // racing other processes — they are a deliberate replay of a + // known-queued event, so dedupe doesn't apply. let eventId = (requestEvent["id"] as? String) ?? "" let createdAt = (requestEvent["created_at"] as? Double) ?? Double(requestEvent["created_at"] as? Int ?? 0) let nowFallback = Date().timeIntervalSince1970 - if !eventId.isEmpty, + if !skipDedupe, + !eventId.isEmpty, SharedStorage.markEventProcessed( eventId: eventId, createdAt: createdAt > 0 ? createdAt : nowFallback @@ -342,32 +356,45 @@ enum LightSigner { // updates the persisted contact-set snapshot used for diffing. var signedEventId: String? = nil var signedSummary: String? = nil + var signedReferencedEventId: String? = nil if status == "signed", method == "sign_event", let json = responseResult { - (signedEventId, signedSummary) = extractSignedEventEnrichment(signedEventJSON: json) + let enrichment = extractSignedEventEnrichment(signedEventJSON: json) + signedEventId = enrichment.eventId + signedSummary = enrichment.summary + signedReferencedEventId = enrichment.referencedEventId } let result = RequestResult(method: method, eventKind: eventKind, clientPubkey: senderPubkey, status: status, errorMessage: errorMsg, - signedEventId: signedEventId, signedSummary: signedSummary) + signedEventId: signedEventId, signedSummary: signedSummary, + signedReferencedEventId: signedReferencedEventId) logAndTrack(result: result, clientName: clientName) return result } + /// Kinds where the signed event is a wrapper around a referenced event + /// (first `e` tag). For these, the activity detail's njump button should + /// link to the referenced event, not the wrapper itself — a "❤" reaction + /// or repost is meaningless on njump in isolation. + static let wrapperKinds: Set = [6, 7, 9734, 9735] + /// Parse the signed-event JSON returned by `processRequest("sign_event", …)` /// to extract the event id and build the activity summary. For kind:3, also /// reads + updates the stored contact-set snapshot so the next sign can diff. - /// Failures are best-effort — returns (nil, nil) and lets logging proceed. - private static func extractSignedEventEnrichment(signedEventJSON: String) -> (String?, String?) { + /// Failures are best-effort — returns (nil, nil, nil) and lets logging proceed. + /// Internal (not private) so unit tests can verify enrichment shape without + /// constructing a full encrypted NIP-46 envelope. + static func extractSignedEventEnrichment(signedEventJSON: String) -> (eventId: String?, summary: String?, referencedEventId: String?) { guard let data = signedEventJSON.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return (nil, nil) + return (nil, nil, nil) } let id = dict["id"] as? String let kind = dict["kind"] as? Int let rawTags = dict["tags"] as? [[Any]] ?? [] let tags: [[String]] = rawTags.map { row in row.compactMap { $0 as? String } } - guard let kind else { return (id, nil) } + guard let kind else { return (id, nil, nil) } // Kind:3 uses the prior snapshot to compute follow add/remove diffs. // Update the snapshot post-summary so the diff reflects what changed @@ -393,7 +420,23 @@ enum LightSigner { } } - return (id, summary) + // Pull the first `e` tag for wrapper kinds so the njump button can + // redirect to the referenced event (e.g., the note that was reacted + // to, not the reaction itself). Validated as 64-char hex to avoid + // crashing the encoder on malformed tags. + var referencedEventId: String? = nil + if wrapperKinds.contains(kind) { + for tag in tags where tag.first == "e" && tag.count >= 2 { + let candidate = tag[1] + if candidate.count == 64, + candidate.allSatisfy({ $0.isHexDigit }) { + referencedEventId = candidate + break + } + } + } + + return (id, summary, referencedEventId) } // MARK: - Request Processing @@ -512,7 +555,8 @@ enum LightSigner { status: result.status, errorMessage: result.errorMessage, signedEventId: result.signedEventId, - signedSummary: result.signedSummary + signedSummary: result.signedSummary, + signedReferencedEventId: result.signedReferencedEventId ) SharedStorage.logActivity(entry) if result.clientPubkey != "unknown" && result.status != "blocked" diff --git a/Shared/SharedModels.swift b/Shared/SharedModels.swift index f3e0652..1c170c8 100644 --- a/Shared/SharedModels.swift +++ b/Shared/SharedModels.swift @@ -17,6 +17,15 @@ struct ActivityEntry: Codable, Identifiable { /// (≤120 chars). Pet-name substitution for `@` happens at render /// time so renames apply retroactively. let signedSummary: String? + /// First `e` tag for kinds where the signed event is a wrapper around a + /// reference (kind:6 repost, kind:7 reaction, kind:9734 zap request, + /// kind:9735 zap receipt). The activity detail view's "Open on njump.me" + /// button uses this id instead of `signedEventId` for those kinds — + /// linking to a "❤" reaction itself is useless; linking to the + /// reacted-to note is what the user actually wants. nil for everything + /// else; `signedEventId` always carries the user's actual signed event + /// for the Copy button. + let signedReferencedEventId: String? init( id: String, @@ -27,7 +36,8 @@ struct ActivityEntry: Codable, Identifiable { status: String, errorMessage: String?, signedEventId: String? = nil, - signedSummary: String? = nil + signedSummary: String? = nil, + signedReferencedEventId: String? = nil ) { self.id = id self.method = method @@ -38,11 +48,12 @@ struct ActivityEntry: Codable, Identifiable { self.errorMessage = errorMessage self.signedEventId = signedEventId self.signedSummary = signedSummary + self.signedReferencedEventId = signedReferencedEventId } private enum CodingKeys: String, CodingKey { case id, method, eventKind, clientPubkey, timestamp, status, errorMessage - case signedEventId, signedSummary + case signedEventId, signedSummary, signedReferencedEventId } init(from decoder: Decoder) throws { @@ -56,6 +67,7 @@ struct ActivityEntry: Codable, Identifiable { errorMessage = try c.decodeIfPresent(String.self, forKey: .errorMessage) signedEventId = try c.decodeIfPresent(String.self, forKey: .signedEventId) signedSummary = try c.decodeIfPresent(String.self, forKey: .signedSummary) + signedReferencedEventId = try c.decodeIfPresent(String.self, forKey: .signedReferencedEventId) } } From 40852a17e7090e5ca869109adb8d6d8c705befe3 Mon Sep 17 00:00:00 2001 From: DocNR Date: Thu, 30 Apr 2026 08:27:52 -0400 Subject: [PATCH 4/4] ux: ActivityDetailView layout audit + unify per-connection rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-archive review of the activity detail screen identified several refinements before re-test. View-layer only — no model changes, no new build (rides on build 31 alongside the dedupe + njump-target fixes). ActivityDetailView changes: - Section reorder: Signed Event → Connection → When → Status. Reading priority is what > who > when > debug. - Combine Event ID + Copy into a single tap-to-copy row with haptic. Truncated hex on its own isn't useful, the row may as well also be the copy affordance. Same pattern for the new Referenced row surfaced for wrapper kinds. - Replace pubkey + Copy in the Client section with a "Damus iPhone ›" disclosure row that presents ConnectionInfoSheet (the existing sheet already shows hex + npub + Copy + relays, no point duplicating). Falls back to truncated pubkey when no pet name set. - Collapse Timing's three rows (Date / Time / Relative) to one calendar-aware humanized row: "5 minutes ago" / "Today at 11:42 PM" / "Yesterday at 9:30 AM" / "Mon at 11:42 PM" / "Apr 28 at 9:30 AM". Long-press copies via .textSelection(.enabled). - Combine Event Kind + Kind Name into a single row: "Kind 1 (Short Note)". - Status section is now conditional: only renders when status != "signed", errorMessage non-empty, or method != "sign_event". For the routine successful sign case the section is hidden entirely. - Rename the error label "Detail" → "Error". ActivityRowView + ClientDetailView: - ActivityRowView gains a `showsClientName: Bool = true` param. When false, omits the pet-name segment of the subtitle (per-connection context already implies the client). Also surfaces signedSummary inline when present. - ClientDetailView's recentActivitySection drops its inline activityRow + statusIcon helpers and uses ActivityRowView(entry:, showsClientName: false) inside the existing NavigationLink. Single source of truth for the row visual; per-connection list now matches the Activity tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- Clave/Views/Activity/ActivityDetailView.swift | 245 ++++++++++++------ Clave/Views/Activity/ActivityRowView.swift | 27 +- Clave/Views/Home/ClientDetailView.swift | 60 +---- 3 files changed, 186 insertions(+), 146 deletions(-) diff --git a/Clave/Views/Activity/ActivityDetailView.swift b/Clave/Views/Activity/ActivityDetailView.swift index 212d276..85e7e60 100644 --- a/Clave/Views/Activity/ActivityDetailView.swift +++ b/Clave/Views/Activity/ActivityDetailView.swift @@ -3,6 +3,8 @@ import SwiftUI struct ActivityDetailView: View { let entry: ActivityEntry + @State private var showConnectionInfo = false + /// Kinds njump.me renders meaningfully. Used to gate the "Open on njump.me" /// button — for kinds outside this set (e.g., kind:22242 relay auth, DMs) /// the button would just render JSON or 404, so we hide it. @@ -39,40 +41,30 @@ struct ActivityDetailView: View { var body: some View { List { - requestSection - if entry.signedEventId != nil || entry.signedSummary != nil { + if hasSignedEvent { signedEventSection } - clientSection - timingSection + connectionSection + whenSection + if showStatusSection { + statusSection + } } .navigationTitle("Activity Detail") .navigationBarTitleDisplayMode(.inline) - } - - // MARK: - Request - - private var requestSection: some View { - Section("Request") { - row("Method", value: entry.method) - - if let kind = entry.eventKind { - row("Event Kind", value: "Kind \(kind)") - if let label = knownKinds[kind] { - row("Kind Name", value: label) - } - } - - row("Status", value: entry.status.capitalized) - - if let error = entry.errorMessage, !error.isEmpty { - row("Detail", value: error) + .sheet(isPresented: $showConnectionInfo) { + if let perms = permissions { + ConnectionInfoSheet(perms: perms) } } } // MARK: - Signed Event + private var hasSignedEvent: Bool { + entry.signedEventId != nil || entry.signedSummary != nil + } + private var signedEventSection: some View { Section("Signed Event") { if let summary = entry.signedSummary { @@ -81,35 +73,51 @@ struct ActivityDetailView: View { .textSelection(.enabled) } + if let kind = entry.eventKind { + row("Kind", value: kindLabel(for: kind)) + } + if let eventId = entry.signedEventId { - HStack { - Text("Event ID") - .foregroundStyle(.secondary) - Spacer() - Text(eventId) - .font(.system(.caption2, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } + copyableEventIdRow(label: "Event ID", value: eventId) + } - Button { - UIPasteboard.general.string = eventId - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } label: { - Label("Copy Event ID", systemImage: "doc.on.doc") - } + if let referenced = entry.signedReferencedEventId { + copyableEventIdRow(label: "Referenced", value: referenced) + } - njumpButton + njumpButton + } + } + + /// Single row that shows a truncated event id (visually) but copies the + /// full hex on tap with a haptic. Replaces the prior pair of separate + /// "Event ID" + "Copy Event ID" rows — truncated hex on its own isn't + /// useful, the row may as well also be the copy affordance. + private func copyableEventIdRow(label: String, value: String) -> some View { + Button { + UIPasteboard.general.string = value + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(truncatedHex(value)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundStyle(.tertiary) } } + .buttonStyle(.plain) + .contentShape(Rectangle()) } /// Build the "Open on njump.me" button if we have a meaningful target. /// For wrapper kinds (reaction/repost/zap), the target is the referenced /// event (so njump renders the reacted-to note, not the bare "❤"). /// For other renderable kinds, the target is the user's own signed event. - /// Returns nil if the kind isn't njump-renderable or we don't have an id. @ViewBuilder private var njumpButton: some View { if let kind = entry.eventKind, Self.njumpRenderableKinds.contains(kind) { @@ -142,7 +150,6 @@ struct ActivityDetailView: View { relays: relays, kind: kindHint ) else { - // Fall back to plain note if nevent encoding fails for any reason return (try? Nip19.encodeNote(eventId: eventId)).flatMap { URL(string: "https://njump.me/\($0)") } } bech32 = nevent @@ -150,48 +157,122 @@ struct ActivityDetailView: View { return URL(string: "https://njump.me/\(bech32)") } - // MARK: - Client - - private var clientSection: some View { - Section("Client") { - if let name = clientName, !name.isEmpty { - row("Connection", value: name) - } - - HStack { - Text("Pubkey") - .foregroundStyle(.secondary) - Spacer() - Text(entry.clientPubkey) - .font(.system(.caption2, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } + // MARK: - Connection + /// Tappable row that opens `ConnectionInfoSheet` for this client. + /// That sheet already shows hex pubkey, npub form, copy buttons, paired + /// relays, etc., so duplicating any of that here would be dead weight. + private var connectionSection: some View { + Section("Connection") { Button { - UIPasteboard.general.string = entry.clientPubkey - UIImpactFeedbackGenerator(style: .light).impactOccurred() + showConnectionInfo = true } label: { - Label("Copy Pubkey", systemImage: "doc.on.doc") + HStack { + Text(connectionLabel) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .disabled(permissions == nil) } } - private var clientName: String? { - SharedStorage.getClientPermissions(for: entry.clientPubkey)?.name + private var permissions: ClientPermissions? { + SharedStorage.getClientPermissions(for: entry.clientPubkey) } - // MARK: - Timing + private var connectionLabel: String { + if let name = permissions?.name, !name.isEmpty { + return name + } + return truncatedHex(entry.clientPubkey) + } - private var timingSection: some View { - Section("Timing") { - row("Date", value: formattedDate) - row("Time", value: formattedTime) - row("Relative", value: relativeTime) + // MARK: - When + + private var whenSection: some View { + Section("When") { + Text(humanizedTimestamp) + .textSelection(.enabled) } } + /// Calendar-aware single-line timestamp. Long-press on the row copies it + /// (via `.textSelection(.enabled)`); power users can grab the exact ISO + /// timestamp if they need it. + private var humanizedTimestamp: String { + let date = Date(timeIntervalSince1970: entry.timestamp) + let now = Date() + let calendar = Calendar.current + + // Within the last hour: relative ("5 minutes ago") + if let secondsAgo = calendar.dateComponents([.second], from: date, to: now).second, + secondsAgo >= 0, secondsAgo < 3600 { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: now) + } + + let timeFormatter = DateFormatter() + timeFormatter.timeStyle = .short + let timeString = timeFormatter.string(from: date) + + if calendar.isDateInToday(date) { + return "Today at \(timeString)" + } + if calendar.isDateInYesterday(date) { + return "Yesterday at \(timeString)" + } + + // Within the last 7 days: short weekday ("Mon at 11:42 AM") + if let daysAgo = calendar.dateComponents([.day], from: date, to: now).day, + daysAgo >= 0, daysAgo < 7 { + let weekdayFormatter = DateFormatter() + weekdayFormatter.dateFormat = "EEE" + return "\(weekdayFormatter.string(from: date)) at \(timeString)" + } + + // Older: "Apr 28 at 9:30 AM" + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "MMM d" + return "\(dayFormatter.string(from: date)) at \(timeString)" + } + + // MARK: - Status (conditional) + + /// Only shown when there's something interesting to report: + /// - non-"signed" status (pending, blocked, error) + /// - a non-empty error message + /// - non-sign_event method (connect, etc.) — for sign_event the Signed + /// Event section already covers it + private var showStatusSection: Bool { + if entry.status != "signed" { return true } + if let error = entry.errorMessage, !error.isEmpty { return true } + if entry.method != "sign_event" { return true } + return false + } + + private var statusSection: some View { + Section("Status") { + if entry.method != "sign_event" { + row("Method", value: entry.method) + } + if entry.status != "signed" { + row("Status", value: entry.status.capitalized) + } + if let error = entry.errorMessage, !error.isEmpty, entry.status != "signed" { + row("Error", value: error) + } + } + } + + // MARK: - Helpers + private func row(_ label: String, value: String) -> some View { HStack { Text(label) @@ -203,21 +284,15 @@ struct ActivityDetailView: View { } } - private var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: Date(timeIntervalSince1970: entry.timestamp)) - } - - private var formattedTime: String { - let formatter = DateFormatter() - formatter.timeStyle = .medium - return formatter.string(from: Date(timeIntervalSince1970: entry.timestamp)) + private func kindLabel(for kind: Int) -> String { + if let name = knownKinds[kind] { + return "Kind \(kind) (\(name))" + } + return "Kind \(kind)" } - private var relativeTime: String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter.localizedString(for: Date(timeIntervalSince1970: entry.timestamp), relativeTo: Date()) + private func truncatedHex(_ hex: String) -> String { + guard hex.count > 12 else { return hex } + return String(hex.prefix(8)) + "…" + String(hex.suffix(4)) } } diff --git a/Clave/Views/Activity/ActivityRowView.swift b/Clave/Views/Activity/ActivityRowView.swift index 43721cc..9f383a5 100644 --- a/Clave/Views/Activity/ActivityRowView.swift +++ b/Clave/Views/Activity/ActivityRowView.swift @@ -2,6 +2,10 @@ import SwiftUI struct ActivityRowView: View { let entry: ActivityEntry + /// When false, omits the pet-name segment from the subtitle. Used by + /// `ClientDetailView`'s "Recent Activity" section where the connection + /// is already implied by the surrounding nav title. + var showsClientName: Bool = true var body: some View { HStack(spacing: 10) { @@ -21,15 +25,24 @@ struct ActivityRowView: View { } } - HStack(spacing: 4) { - Text(clientLabel) - .font(.caption2) + if let summary = entry.signedSummary, !summary.isEmpty { + Text(summary) + .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) + .lineLimit(2) + } - Text("·") - .foregroundStyle(.tertiary) + HStack(spacing: 4) { + if showsClientName { + Text(clientLabel) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + + Text("·") + .foregroundStyle(.tertiary) + } Text(relativeTime) .font(.caption2) diff --git a/Clave/Views/Home/ClientDetailView.swift b/Clave/Views/Home/ClientDetailView.swift index 32cd557..77e670f 100644 --- a/Clave/Views/Home/ClientDetailView.swift +++ b/Clave/Views/Home/ClientDetailView.swift @@ -350,7 +350,12 @@ struct ClientDetailView: View { NavigationLink { ActivityDetailView(entry: entry) } label: { - activityRow(entry) + HStack { + ActivityRowView(entry: entry, showsClientName: false) + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } } .buttonStyle(.plain) .contentShape(Rectangle()) @@ -373,59 +378,6 @@ struct ClientDetailView: View { ) } - private func activityRow(_ entry: ActivityEntry) -> some View { - HStack { - statusIcon(entry.status) - - VStack(alignment: .leading, spacing: 2) { - Text(entry.method) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.primary) - if let summary = entry.signedSummary { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } else if let kind = entry.eventKind { - Text(KnownKinds.label(for: kind)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Text(relativeTime(entry.timestamp)) - .font(.caption2) - .foregroundStyle(.tertiary) - - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 6) - } - - private func statusIcon(_ status: String) -> some View { - Group { - switch status { - case "signed": - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - case "blocked": - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - case "error": - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - default: - Image(systemName: "questionmark.circle") - .foregroundStyle(.secondary) - } - } - .font(.body) - } - // MARK: - Persistence private func saveChanges() {