Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Release Notes
- Fixed: crash in Event.trackDelete(on:context:).
- Added: client identification tags to published events to improve analytics and ecosystem insights.

### Internal Changes
- Fixed: Contact Support event fires too often.
- Performance improvements for RepliesLabel, AuthorLabel, NoteCardHeader, Date+Elapsed
- Added: NIP-89 client identifier tags to events, helping relays and other clients identify Nos as the source.

## [1.2.1] - 2025-02-19Z

Expand Down
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build and Test Commands
- Build project: `xcodebuild -project Nos.xcodeproj -scheme Nos`
- Run unit tests: `xcodebuild test -project Nos.xcodeproj -scheme NosTests`
- Run performance tests: `xcodebuild test -project Nos.xcodeproj -scheme "NosPerformance Tests"`
- Run single test: `xcodebuild test -project Nos.xcodeproj -scheme NosTests -testPlan UnitTests -only-testing:NosTests/<TestClassName>/<testMethodName>`
- Clean build folder: `xcodebuild clean -project Nos.xcodeproj -scheme Nos`

## Code Style Guidelines
- **Architecture**: Follow MVC pattern with single source of truth (ADR-0004)
- **Types**: Prefer protocol conformance (Sendable, Identifiable, Equatable, Codable, Hashable)
- **Imports**: Group imports with Foundation/SwiftUI first, then alphabetically
- **Naming**: Use descriptive camelCase for variables/methods, PascalCase for types
- **Error Handling**: Use specific error types (e.g., EventError, AuthorListError)
- **Testing**: Test files should mirror app structure in NosTests directory
- **Extensions**: Use extensions to organize functionality by protocol/feature
- **Comments**: Provide documentation comments for public APIs
- **Async**: Use Swift concurrency (async/await) and Task for asynchronous code

## Git Workflow
- Protected main branch (ADR-0005)
- Enabled GitHub merge queue (ADR-0006)
- Create feature branches for all changes
24 changes: 10 additions & 14 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3031,12 +3031,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/NosStaging.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Verse Communications, Inc. (GZCZBKH7MY)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3069,7 +3068,6 @@
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME) Staging";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.verse.Nos-staging";
SCHEME_PREFIX = "nos-staging";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down Expand Up @@ -3486,11 +3484,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/Nos.entitlements;
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3522,7 +3520,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.verse.Nos;
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME)";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.verse.Nos";
PROVISIONING_PROFILE_SPECIFIER = "";
SCHEME_PREFIX = nos;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand All @@ -3543,12 +3541,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/Nos.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Verse Communications, Inc. (GZCZBKH7MY)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3580,7 +3577,6 @@
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.verse.Nos";
SCHEME_PREFIX = nos;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down
4 changes: 1 addition & 3 deletions Nos/Models/JSONEvent+Kinds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ extension JSONEvent {
/// - Returns: The ``JSONEvent`` of the contact list.
static func contactList(pubKey: String, tags: [[String]], relayAddresses: [String]) -> JSONEvent {
var tags = tags
// Append client tag so we can detect if Nos overwrites a user's contact list.
// https://github.com/planetary-social/cleanstr/issues/51
tags.append(["client", "nos", "https://nos.social"])
// Note: We don't need to add client tag here as it will be added in the JSONEvent initializer

let relayStrings = relayAddresses.map { "\"\($0)\":{\"write\":true,\"read\":true}" }
let content = "{" + relayStrings.joined(separator: ",") + "}"
Expand Down
24 changes: 22 additions & 2 deletions Nos/Models/JSONEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,27 @@ struct JSONEvent: Codable, Hashable, VerifiableEvent {
self.pubKey = pubKey
self.createdAt = Int64(createdAt.timeIntervalSince1970)
self.kind = kind.rawValue
self.tags = tags

// Add NIP-89 client tag for relevant event kinds
var updatedTags = tags
// App metadata tag for NIP-89 client identification
let clientId = "31990:0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6:nos-ios"
let clientTag = ["client", "nos.social", clientId]

// Add client tag for kinds: text, metaData, contactList, mute, followSet
let taggableKinds: [EventKind] = [.text, .metaData, .contactList, .mute, .followSet]
if taggableKinds.contains(kind) {
// Only add the tag if it doesn't already exist
let hasClientTag = updatedTags.contains { tag in
tag.count >= 1 && tag[0] == "client"
}

if !hasClientTag {
updatedTags.append(clientTag)
}
}

self.tags = updatedTags
self.content = content
self.signature = ""
}
Expand Down Expand Up @@ -92,7 +112,7 @@ struct JSONEvent: Codable, Hashable, VerifiableEvent {
tags.append(["e", replyToNoteID, "", EventReferenceMarker.root.rawValue])
}
}

self.init(pubKey: keyPair.publicKeyHex, kind: .text, tags: tags, content: content)
}

Expand Down
107 changes: 105 additions & 2 deletions NosTests/Models/JSONEventTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import XCTest

class JSONEventTests: XCTestCase {
// NIP-89 client tag that should be included in supported event kinds
let clientId = "31990:0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6:nos-ios"
let expectedClientTag = ["client", "nos.social", clientId]

// Taggable event kinds that should include client tag
let taggableKinds: [EventKind] = [.text, .metaData, .contactList, .mute, .followSet]

// Non-taggable event kinds that should NOT include client tag
let nonTaggableKinds: [EventKind] = [.directMessage, .delete, .repost, .like, .zapRequest, .longFormContent]

func test_replaceableID() throws {
// Arrange
let replaceableID = "TGnBRh9-b1jrqSJ-ByWQx"
Expand All @@ -21,7 +31,8 @@ class JSONEventTests: XCTestCase {
["p", "14aeb8dad4", "wss://bobrelay.com/nostr", "bob"],
["p", "612aee610f", "ws://carolrelay.com/ws", "carol"]
]
let expectedTags = pTags + [["client", "nos", "https://nos.social"]]
// Use the new client tag format
let expectedTags = pTags + [expectedClientTag]
let relayAddresses = [
"wss://relay1.lol",
"wss://relay2.lol"
Expand All @@ -48,7 +59,8 @@ class JSONEventTests: XCTestCase {
["p", "14aeb8dad4", "wss://bobrelay.com/nostr", "bob"],
["p", "612aee610f", "ws://carolrelay.com/ws", "carol"]
]
let expectedTags = pTags + [["client", "nos", "https://nos.social"]]
// Use the new client tag format
let expectedTags = pTags + [expectedClientTag]

let event = JSONEvent.contactList(
pubKey: "",
Expand Down Expand Up @@ -99,4 +111,95 @@ class JSONEventTests: XCTestCase {
XCTAssertEqual(event.tags, expectedTags)
XCTAssertEqual(event.content, "I'm done with this.")
}

// MARK: - Client Tag Tests

func test_clientTag_addedToTaggableEventKinds() {
// Test each taggable event kind to verify client tag is added
for kind in taggableKinds {
let event = JSONEvent(
pubKey: "test_pubkey",
kind: kind,
tags: [],
content: "Test content"
)

// Check that the client tag exists in the tags array
XCTAssertTrue(
event.tags.contains(expectedClientTag),
"Client tag should be added to event kind \(kind)"
)

// Check the tag count to ensure only one tag was added
XCTAssertEqual(event.tags.count, 1, "Only one tag should be added for event kind \(kind)")
}
}

func test_clientTag_notAddedToNonTaggableEventKinds() {
// Test each non-taggable event kind to verify client tag is NOT added
for kind in nonTaggableKinds {
let event = JSONEvent(
pubKey: "test_pubkey",
kind: kind,
tags: [],
content: "Test content"
)

// Check that the client tag does NOT exist in the tags array
XCTAssertFalse(
event.tags.contains(expectedClientTag),
"Client tag should NOT be added to event kind \(kind)"
)

// Check that no tags were added
XCTAssertTrue(event.tags.isEmpty, "No tags should be added for event kind \(kind)")
}
}

func test_clientTag_notDuplicated() {
// Arrange: Create event with existing client tag
let existingClientTag = ["client", "old-client", "some-identifier"]

for kind in taggableKinds {
let event = JSONEvent(
pubKey: "test_pubkey",
kind: kind,
tags: [existingClientTag],
content: "Test content"
)

// Assert: The existing client tag should remain, and no new one should be added
XCTAssertTrue(event.tags.contains(existingClientTag), "Existing client tag should remain")
XCTAssertFalse(event.tags.contains(expectedClientTag), "New client tag should not be added")
XCTAssertEqual(event.tags.count, 1, "Only one client tag should exist")
}
}

func test_contactList_usesCorrectClientTag() {
// The static contactList constructor used to add its own client tag
// Now it should use the one added by the JSONEvent initializer
let pTags = [["p", "pubkey123"]]

let event = JSONEvent.contactList(
pubKey: "test_pubkey",
tags: pTags,
relayAddresses: ["wss://relay.example.com"]
)

// Verify the new client tag is present (not the old one)
XCTAssertTrue(
event.tags.contains(expectedClientTag),
"Contact list should use the new client tag format"
)

// Verify the old client tag is not present
let oldClientTag = ["client", "nos", "https://nos.social"]
XCTAssertFalse(
event.tags.contains(oldClientTag),
"Contact list should not contain the old client tag format"
)

// Verify tags include the p-tag and client tag
XCTAssertEqual(event.tags.count, pTags.count + 1, "Should have p-tags plus client tag")
}
}
Loading