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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Release Notes
- Fixed: crash in Event.trackDelete(on:context:).
- Added: Display which client app was used to post notes with "via [client name]" in the UI
- Added: Interactive client name that navigates to client publisher's profile when clicked

### Internal Changes
- Fixed: Contact Support event fires too often.
- Added: Implement NIP-89 application metadata support for fetching client details
- Added: Support extracting client info from event tags with client metadata
- Performance improvements for RepliesLabel, AuthorLabel, NoteCardHeader, Date+Elapsed

## [1.2.1] - 2025-02-19Z
Expand Down
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
82 changes: 82 additions & 0 deletions Nos/Models/CoreData/Event+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -640,4 +640,86 @@ class Event: NosManagedObject, VerifiableEvent {
shouldBePublishedTo = Set()
}
}
extension Event {
var clientName: String? {
guard let tags = allTags as? [[String]] else {
return nil
}

for tag in tags where tag.count >= 2 && tag[0] == "client" {
return tag[1] // Return the client identifier (e.g., "nos.social")
}
return nil
}

struct ClientInfo {
let name: String // e.g. "nos.social"
let identifier: String? // e.g. "31990:0f22c06eac1002684efcc68f568540e8342d1609d508bcd4312c038e6194f8b6:nos-ios"
let relayHint: String?
var metadataLoaded = false
var metadataEvent: Event?

var displayName: String {
name
}

// Parse the identifier to extract key components
var parsedIdentifier: (kind: Int?, pubkey: String?)? {
guard let identifier = identifier else { return nil }

let components = identifier.split(separator: ":").map(String.init)
guard components.count >= 3 else { return nil }

return (
kind: Int(components[0]),
pubkey: components[1]
)
}
}

var clientInfo: ClientInfo? {
guard let tags = allTags as? [[String]] else {
return nil
}

for tag in tags where tag.count >= 2 && tag[0] == "client" {
return ClientInfo(
name: tag[1],
identifier: tag.count >= 3 ? tag[2] : nil,
relayHint: tag.count >= 4 ? tag[3] : nil,
metadataLoaded: false,
metadataEvent: nil
)
}
return nil
}

// Load client metadata if available
@MainActor func loadClientMetadata() async {
guard let clientInfo = clientInfo,
let parsed = clientInfo.parsedIdentifier,
let pubkey = parsed.pubkey,
let identifier = clientInfo.identifier,
let components = identifier.split(separator: ":").map(String.init).last else {
return
}

@Dependency(\.relayService) var relayService
@Dependency(\.persistenceController) var persistenceController

let filter = Filter(
authorKeys: [pubkey],
kinds: [.appMetadata],
dTags: [components],
limit: 1
)

let cancellable = await relayService.fetchEvents(matching: filter)

// Give it a moment to fetch
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
cancellable.cancel()
}
}

// swiftlint:enable file_length
3 changes: 3 additions & 0 deletions Nos/Models/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public enum EventKind: Int64, CaseIterable, Hashable {

/// Long-form Content
case longFormContent = 30023

/// Application Metadata (NIP-89)
case appMetadata = 31990

// swiftlint:enable number_separator
}
4 changes: 3 additions & 1 deletion Nos/Views/Note/NoteCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ struct NoteCard: View {
authorSafeName: author.safeName,
authorProfilePhotoURL: author.profilePhotoURL,
noteExpirationDate: note.expirationDate,
noteCreatedDate: note.createdAt
noteCreatedDate: note.createdAt,
clientName: note.clientName,
note: note
)
}
}
Expand Down
58 changes: 52 additions & 6 deletions Nos/Views/Note/NoteCardHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@ struct NoteCardHeader: View {
let authorProfilePhotoURL: URL?
let noteExpirationDate: Date?
let noteCreatedDate: Date?
let clientName: String?
let note: Event?

@State private var currentTime = Date.now
@State private var expirationDateDistanceString: String?
@State private var createdDateDistanceString: String?
@State private var isLoadingClientInfo = false

/// A timer used to cause a periodic refresh of the date strings.
@State private var timer: Timer.TimerPublisher?
@State private var timerCancellable: AnyCancellable?

@EnvironmentObject private var router: Router

var body: some View {
HStack(alignment: .center, spacing: 8) {
AuthorLabel(name: authorSafeName, profilePhotoURL: authorProfilePhotoURL)

if let expirationDateDistanceString {
Image.disappearingMessages
.resizable()
Expand All @@ -29,10 +35,44 @@ struct NoteCardHeader: View {
.font(.clarity(.medium))
.foregroundColor(.secondaryTxt)
} else if let createdDateDistanceString {
Text(createdDateDistanceString)
.lineLimit(1)
.font(.clarity(.medium))
.foregroundColor(.secondaryTxt)
HStack(spacing: 4) {
Text(createdDateDistanceString)
.lineLimit(1)
.font(.clarity(.medium))
.foregroundColor(.secondaryTxt)

// Display client name if available
if let clientName = clientName {
Button {
Task {
isLoadingClientInfo = true
await note?.loadClientMetadata()
isLoadingClientInfo = false

// If we find a client app in author's profile, go to their profile
if let clientInfo = note?.clientInfo,
let parsed = clientInfo.parsedIdentifier,
let pubkey = parsed.pubkey {
Task { @MainActor in
router.push(.author(pubkey))
}
}
}
} label: {
HStack(spacing: 2) {
Text("• via \(clientName)")
.lineLimit(1)
.font(.clarity(.medium, size: 12, textStyle: .caption))
.foregroundColor(.secondaryTxt)

if isLoadingClientInfo {
ProgressView()
.scaleEffect(0.5)
}
}
}
}
}
}

Spacer()
Expand Down Expand Up @@ -79,16 +119,22 @@ struct NoteCardHeader_Previews: PreviewProvider {
authorSafeName: previewData.previewAuthor.safeName,
authorProfilePhotoURL: previewData.previewAuthor.profilePhotoURL,
noteExpirationDate: previewData.imageNote.expirationDate,
noteCreatedDate: previewData.imageNote.createdAt
noteCreatedDate: previewData.imageNote.createdAt,
clientName: "nos.social",
note: previewData.imageNote
)
.inject(previewData: previewData)
.environmentObject(previewData.router)

NoteCardHeader(
authorSafeName: previewData.previewAuthor.safeName,
authorProfilePhotoURL: previewData.previewAuthor.profilePhotoURL,
noteExpirationDate: previewData.expiringNote.expirationDate,
noteCreatedDate: previewData.expiringNote.createdAt
noteCreatedDate: previewData.expiringNote.createdAt,
clientName: nil,
note: nil
)
.inject(previewData: previewData)
.environmentObject(previewData.router)
}
}