diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index 28ef46c7d..2151fc947 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -212,7 +212,7 @@ extension NostrNetworkManager { /// Relay filters var relayFilters: RelayFilters { get } - + /// The user's connected NWC wallet var nwcWallet: WalletConnectURL? { get } } diff --git a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift index 05dd60e70..a46c7fd10 100644 --- a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift @@ -35,11 +35,12 @@ extension NostrNetworkManager { } private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] { - let regularRelayDescriptorList = relayList.toRelayDescriptors() + var descriptors = relayList.toRelayDescriptors() + if let nwcWallet = delegate.nwcWallet { - return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)] + descriptors.append(.nwc(url: nwcWallet.relay)) } - return regularRelayDescriptorList + return descriptors } // MARK: - Getting the user's relay list diff --git a/damus/Core/Nostr/NostrRequest.swift b/damus/Core/Nostr/NostrRequest.swift index d5554ad85..54ef9a410 100644 --- a/damus/Core/Nostr/NostrRequest.swift +++ b/damus/Core/Nostr/NostrRequest.swift @@ -33,11 +33,28 @@ enum NostrRequestType { guard case .typical(let req) = self else { return true } - + return req.is_read } } +extension NostrRequestType { + /// Profile-related kinds that should be queried on profiles-only relays + static let profileKinds: Set = [.metadata, .contacts, .relay_list] + + /// Whether this request is for profile-related data only + var isProfileRelated: Bool { + guard case .typical(let req) = self else { return false } + guard case .subscribe(let sub) = req else { return false } + + // Check if ALL filters contain ONLY profile-related kinds + return sub.filters.allSatisfy { filter in + guard let kinds = filter.kinds else { return false } // No kinds specified = could be anything + return kinds.allSatisfy { Self.profileKinds.contains($0) } + } + } +} + /// Models a standard request/message that is sent to a Nostr relay. enum NostrRequest { /// Subscribes to receive information from the relay diff --git a/damus/Core/Nostr/Relay.swift b/damus/Core/Nostr/Relay.swift index f33d9c5e4..79ef09c47 100644 --- a/damus/Core/Nostr/Relay.swift +++ b/damus/Core/Nostr/Relay.swift @@ -32,6 +32,7 @@ enum RelayVariant { case regular case ephemeral case nwc + case profilesOnly // Only used for profile metadata queries (kind 0, 3, 10002) } extension RelayPool { @@ -55,12 +56,22 @@ extension RelayPool { return true case .nwc: return true + case .profilesOnly: + return false // Not ephemeral - filtering is handled separately via isProfileRelated check } } - + + var isProfilesOnly: Bool { + variant == .profilesOnly + } + static func nwc(url: RelayURL) -> RelayDescriptor { return RelayDescriptor(url: url, info: .readWrite, variant: .nwc) } + + static func profilesOnly(url: RelayURL) -> RelayDescriptor { + return RelayDescriptor(url: url, info: .read, variant: .profilesOnly) + } } } diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 88b3e6c5c..58ed52a67 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -425,7 +425,13 @@ actor RelayPool { if relay.descriptor.ephemeral && skip_ephemeral { continue // Do not send requests to ephemeral relays if we want to skip them } - + + // Filter purplepag.es to only receive profile-related requests (kinds 0, 3, 10002) + let isPurplePagesRelay = relay.descriptor.url.absoluteString.contains("purplepag.es") + if (relay.descriptor.isProfilesOnly || isPurplePagesRelay) && !req.isProfileRelated { + continue + } + guard relay.connection.isConnected else { Task { await queue_req(r: req, relay: relay.id, skip_ephemeral: skip_ephemeral) } continue @@ -465,16 +471,13 @@ actor RelayPool { } func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) { - if case .nostr_event(let ev) = event { - if case .event(_, let nev) = ev { - if seen[nev.id]?.contains(relay_id) == true { - return - } - seen[nev.id, default: Set()].insert(relay_id) - counts[relay_id, default: 0] += 1 - notify(.update_stats(note_id: nev.id)) - } - } + guard case .nostr_event(let ev) = event else { return } + guard case .event(_, let nev) = ev else { return } + guard seen[nev.id]?.contains(relay_id) != true else { return } + + seen[nev.id, default: Set()].insert(relay_id) + counts[relay_id, default: 0] += 1 + notify(.update_stats(note_id: nev.id)) } func resubscribeAll(relayId: RelayURL) async { diff --git a/damus/Features/Relays/Models/RelayBootstrap.swift b/damus/Features/Relays/Models/RelayBootstrap.swift index ebfba9a93..dd01f2d1b 100644 --- a/damus/Features/Relays/Models/RelayBootstrap.swift +++ b/damus/Features/Relays/Models/RelayBootstrap.swift @@ -13,6 +13,7 @@ fileprivate let BOOTSTRAP_RELAYS = [ "wss://nostr.land", "wss://nostr.wine", "wss://nos.lol", + "wss://purplepag.es", ] fileprivate let REGION_SPECIFIC_BOOTSTRAP_RELAYS: [Locale.Region: [String]] = [ diff --git a/damus/Features/Relays/Views/RelayConfigView.swift b/damus/Features/Relays/Views/RelayConfigView.swift index cc82ad1b4..b84d776e3 100644 --- a/damus/Features/Relays/Views/RelayConfigView.swift +++ b/damus/Features/Relays/Views/RelayConfigView.swift @@ -37,11 +37,8 @@ struct RelayConfigView: View { } var recommended: [RelayPool.RelayDescriptor] { - let rs: [RelayPool.RelayDescriptor] = [] - let recommended_relay_addresses = get_default_bootstrap_relays() - return recommended_relay_addresses.reduce(into: rs) { xs, x in - xs.append(RelayPool.RelayDescriptor(url: x, info: .readWrite)) - } + // Use default bootstrap relays (includes purplepag.es) + return get_default_bootstrap_relays().map { RelayPool.RelayDescriptor(url: $0, info: .readWrite) } } var body: some View { @@ -134,7 +131,7 @@ struct RelayConfigView: View { ForEach(relayList, id: \.url) { relay in Group { - RelayView(state: state, relay: relay.url, showActionButtons: $showActionButtons, recommended: recommended) + RelayView(state: state, relay: relay.url, showActionButtons: $showActionButtons, recommended: recommended, descriptor: relay) Divider() } } diff --git a/damus/Features/Relays/Views/RelayDetailView.swift b/damus/Features/Relays/Views/RelayDetailView.swift index fd19aaabf..da87261c9 100644 --- a/damus/Features/Relays/Views/RelayDetailView.swift +++ b/damus/Features/Relays/Views/RelayDetailView.swift @@ -20,12 +20,25 @@ struct RelayDetailView: View { self.state = state self.relay = relay self.nip11 = nip11 - log = state.relay_model_cache.model(with_relay_id: relay)?.log ?? RelayLog() } - - func check_connection() -> Bool { - return state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true + + /// Whether relay is connected (in the user's relay list) + var isConnected: Bool { + state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true + } + + /// Check if relay URL is purplepag.es (for showing Profile badge) + static func isPurplePagesRelay(_ relay: RelayURL) -> Bool { + relay.absoluteString.contains("purplepag.es") + } + + /// Whether this relay is profile-only (for showing Profile badge) + var isProfileOnlyRelay: Bool { + if Self.isPurplePagesRelay(relay) { + return true + } + return state.nostrNetwork.getRelay(relay)?.descriptor.isProfilesOnly ?? false } func RemoveRelayButton(_ keypair: FullKeypair) -> some View { @@ -87,10 +100,14 @@ struct RelayDetailView: View { .foregroundColor(.gray) HStack { + if isProfileOnlyRelay { + RelayType(is_paid: false, is_profile_only: true) + } + if nip11?.is_paid ?? false { RelayPaidDetail(payments_url: nip11?.payments_url, fees: nip11?.fees) } - + if let authentication_state: RelayAuthenticationState = relay_object?.authentication_state, authentication_state != .none { RelayAuthenticationDetail(state: authentication_state) @@ -138,7 +155,7 @@ struct RelayDetailView: View { } if let keypair = state.keypair.to_full() { - if check_connection() { + if isConnected { RemoveRelayButton(keypair) .padding(.top) } else { @@ -187,7 +204,6 @@ struct RelayDetailView: View { func removeRelay() async { do { - // TODO: Concurrency problems? try await state.nostrNetwork.userRelayList.remove(relayURL: self.relay) dismiss() } @@ -195,10 +211,9 @@ struct RelayDetailView: View { present_sheet(.error(error.humanReadableError)) } } - + func connectRelay() async { do { - // TODO: Concurrency problems? try await state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: relay, rwConfiguration: .readWrite)) dismiss() } diff --git a/damus/Features/Relays/Views/RelayType.swift b/damus/Features/Relays/Views/RelayType.swift index 328d42db1..535dea7e4 100644 --- a/damus/Features/Relays/Views/RelayType.swift +++ b/damus/Features/Relays/Views/RelayType.swift @@ -9,10 +9,23 @@ import SwiftUI struct RelayType: View { let is_paid: Bool - + var is_profile_only: Bool = false + var body: some View { - if is_paid { - Image("bitcoin-logo") + HStack(spacing: 4) { + if is_paid { + Image("bitcoin-logo") + } + if is_profile_only { + Text("Profile") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(DamusColors.purple) + .cornerRadius(4) + } } } } @@ -22,6 +35,8 @@ struct RelayType_Previews: PreviewProvider { VStack { RelayType(is_paid: false) RelayType(is_paid: true) + RelayType(is_paid: false, is_profile_only: true) + RelayType(is_paid: true, is_profile_only: true) } } } diff --git a/damus/Features/Relays/Views/RelayView.swift b/damus/Features/Relays/Views/RelayView.swift index 85c61d671..3ce5789e8 100644 --- a/damus/Features/Relays/Views/RelayView.swift +++ b/damus/Features/Relays/Views/RelayView.swift @@ -13,24 +13,40 @@ struct RelayView: View { let recommended: Bool /// Disables navigation link let disableNavLink: Bool + /// Optional descriptor for recommended relays (not yet connected) + let descriptor: RelayPool.RelayDescriptor? @ObservedObject private var model_cache: RelayModelCache @State var relay_state: Bool @Binding var showActionButtons: Bool - init(state: DamusState, relay: RelayURL, showActionButtons: Binding, recommended: Bool, disableNavLink: Bool = false) { + init(state: DamusState, relay: RelayURL, showActionButtons: Binding, recommended: Bool, disableNavLink: Bool = false, descriptor: RelayPool.RelayDescriptor? = nil) { self.state = state self.relay = relay self.recommended = recommended self.model_cache = state.relay_model_cache _showActionButtons = showActionButtons - let relay_state = RelayView.get_relay_state(state: state, relay: relay) - self._relay_state = State(initialValue: relay_state) + self._relay_state = State(initialValue: state.nostrNetwork.getRelay(relay) == nil) self.disableNavLink = disableNavLink + self.descriptor = descriptor } - static func get_relay_state(state: DamusState, relay: RelayURL) -> Bool { - return state.nostrNetwork.getRelay(relay) == nil + /// Check if relay URL is purplepag.es (for showing Profile badge) + static func isPurplePagesRelay(_ relay: RelayURL) -> Bool { + relay.absoluteString.contains("purplepag.es") + } + + /// Whether this relay is profile-only (for showing Profile badge) + var isProfileOnly: Bool { + if Self.isPurplePagesRelay(relay) { + return true + } + return state.nostrNetwork.getRelay(relay)?.descriptor.isProfilesOnly ?? descriptor?.isProfilesOnly ?? false + } + + /// Whether relay needs to be added (true = show Add button, false = show Added button) + var needsToBeAdded: Bool { + return relay_state } var body: some View { @@ -52,8 +68,11 @@ struct RelayView: View { .font(.headline) .padding(.bottom, 2) .lineLimit(1) - RelayType(is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false) - + RelayType( + is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false, + is_profile_only: isProfileOnly + ) + if relay.absoluteString.hasSuffix(".onion") { Image("tor") .resizable() @@ -79,7 +98,7 @@ struct RelayView: View { if recommended { if let keypair = state.keypair.to_full() { VStack(alignment: .center) { - if relay_state { + if needsToBeAdded { AddButton(keypair: keypair) } else { Button(action: { @@ -110,7 +129,7 @@ struct RelayView: View { .contentShape(Rectangle()) } .onReceive(handle_notify(.relays_changed)) { _ in - self.relay_state = RelayView.get_relay_state(state: state, relay: self.relay) + self.relay_state = state.nostrNetwork.getRelay(relay) == nil } .onTapGesture { if !disableNavLink { @@ -131,7 +150,7 @@ struct RelayView: View { present_sheet(.error(error.humanReadableError)) } } - + func remove_action(privkey: Privkey) async { do { try await state.nostrNetwork.userRelayList.remove(relayURL: relay) diff --git a/damus/Features/Settings/Models/UserSettingsStore.swift b/damus/Features/Settings/Models/UserSettingsStore.swift index 7e8481531..68ee76a43 100644 --- a/damus/Features/Settings/Models/UserSettingsStore.swift +++ b/damus/Features/Settings/Models/UserSettingsStore.swift @@ -248,7 +248,7 @@ class UserSettingsStore: ObservableObject { /// Whether the app has the experimental local relay model flag that streams data only from the local relay (ndb) @Setting(key: "enable_experimental_local_relay_model", default_value: false) var enable_experimental_local_relay_model: Bool - + /// Whether the app should present the experimental floating "Load new content" button @Setting(key: "enable_experimental_load_new_content_button", default_value: false) var enable_experimental_load_new_content_button: Bool