diff --git a/damus/Core/Nostr/RelayURL.swift b/damus/Core/Nostr/RelayURL.swift index 53f6fbe10..9e5e0aa2a 100644 --- a/damus/Core/Nostr/RelayURL.swift +++ b/damus/Core/Nostr/RelayURL.swift @@ -35,12 +35,20 @@ public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Id guard let scheme = url.scheme else { return nil } - - guard scheme == "ws" || scheme == "wss" else { + + let normalizedScheme = scheme.lowercased() + + guard normalizedScheme == "ws" || normalizedScheme == "wss" else { return nil } - self.url = url + if normalizedScheme != scheme { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.scheme = normalizedScheme + self.url = components?.url ?? url + } else { + self.url = url + } } // MARK: - Codable diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index 78209d8d5..4f70d816a 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -30,6 +30,10 @@ struct NoteArtifactsSeparated: Equatable { var links: [URL] { return urls.compactMap { url in url.is_link } } + + var relays: [RelayURL] { + return urls.compactMap { url in url.is_relay } + } static func just_content(_ content: String) -> NoteArtifactsSeparated { let txt = CompatibleText(attributed: AttributedString(stringLiteral: content)) @@ -143,7 +147,7 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide return .loopContinue // We can't classify this, ignore and move on } let url_type = classify_url(url) - if case .link = url_type { + if url_type.isPreviewableLink { end_url_count += 1 // If there is more than one link, do not hide anything because we allow rich rendering of only @@ -240,38 +244,45 @@ func render_blocks(blocks: borrowing NdbBlockGroup, profiles: Profiles, can_hide } } - switch block { - case .mention(let m): - if let typ = m.bech32_type, typ.is_notelike, one_note_ref { - return .loopContinue - } - guard let mention = MentionRef(block: m) else { return .loopContinue } - return .loopReturn(str + mention_str(.any(mention), profiles: profiles)) - case .text(let txt): - var hide_text_index_argument = hide_text_index - blocksList.useItem(at: index+1, { block in - switch block { - case .hashtag(_): - // SPECIAL CASE: - // Do not trim whitespaces from suffix if the following block is a hashtag. - // This is because of the code further up (see "SPECIAL CASE"). - hide_text_index_argument = -1 - default: - break - } - }) - return .loopReturn(str + CompatibleText(stringLiteral: reduce_text_block(ind: index, hide_text_index: hide_text_index_argument, txt: txt.as_str()))) - case .hashtag(let htag): - return .loopReturn(str + hashtag_str(htag.as_str())) - case .invoice(let invoice): - guard let inv = invoice.as_invoice() else { return .loopContinue } - invoices.append(inv) - case .url(let url): - guard let url = URL(string: url.as_str()) else { return .loopContinue } - return .loopReturn(str + url_str(url)) - case .mention_index: + switch block { + case .mention(let m): + if let typ = m.bech32_type, typ.is_notelike, one_note_ref { return .loopContinue } + guard let mention = MentionRef(block: m) else { return .loopContinue } + return .loopReturn(str + mention_str(.any(mention), profiles: profiles)) + case .text(let txt): + var hide_text_index_argument = hide_text_index + blocksList.useItem(at: index + 1) { block in + switch block { + case .hashtag(_): + // SPECIAL CASE: + // Do not trim whitespaces from suffix if the following block is a hashtag. + // This is because of the code further up (see "SPECIAL CASE"). + hide_text_index_argument = -1 + default: + break + } + } + + let reduced_text = reduce_text_block(ind: index, hide_text_index: hide_text_index_argument, txt: txt.as_str()) + if let relayResult = linkifyRelayText(reduced_text) { + urls.append(contentsOf: relayResult.detectedURLs) + return .loopReturn(str + relayResult.text) + } else { + return .loopReturn(str + CompatibleText(stringLiteral: reduced_text)) + } + case .hashtag(let htag): + return .loopReturn(str + hashtag_str(htag.as_str())) + case .invoice(let invoice): + guard let inv = invoice.as_invoice() else { return .loopContinue } + invoices.append(inv) + case .url(let url): + guard let url = URL(string: url.as_str()) else { return .loopContinue } + return .loopReturn(str + url_str(url)) + case .mention_index: + return .loopContinue + } return .loopContinue }) }) @@ -304,6 +315,10 @@ func invoice_str(_ invoice: Invoice) -> CompatibleText { } func url_str(_ url: URL) -> CompatibleText { + if let relay = RelayURL(url.absoluteString) { + return relay_str(relay, original: url) + } + var attributedString = AttributedString(stringLiteral: url.absoluteString) attributedString.link = url attributedString.foregroundColor = DamusColors.purple @@ -311,7 +326,110 @@ func url_str(_ url: URL) -> CompatibleText { return CompatibleText(attributed: attributedString) } +func relay_str(_ relay: RelayURL, original: URL?) -> CompatibleText { + let displayString = original?.absoluteString ?? relay.absoluteString + + var attributedString = AttributedString(stringLiteral: displayString) + + if let encoded = relay.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let deepLink = URL(string: "damus://relay?url=\(encoded)") { + attributedString.link = deepLink + } else { + attributedString.link = relay.id + } + attributedString.foregroundColor = DamusColors.purple + + return CompatibleText(attributed: attributedString) +} + +private struct RelayLinkResult { + let range: Range + let relay: RelayURL + let displayString: String +} + +private struct LinkifiedRelayText { + let text: CompatibleText + let detectedURLs: [UrlType] +} + +private let relayURLPattern: NSRegularExpression = { + // Match ws:// or wss:// followed by any non-whitespace characters. + return try! NSRegularExpression(pattern: "(?i)wss?://[^\\s]+", options: []) +}() + +private let trailingCharactersToTrim: Set = ["!", "?", ".", ",", ":", ";", ")", "]", "\"", "’", "'"] + +private func linkifyRelayText(_ text: String) -> LinkifiedRelayText? { + guard !text.isEmpty else { return nil } + + let matches = findRelayLinkResults(in: text) + guard !matches.isEmpty else { return nil } + + var parts: [CompatibleText] = [] + var detectedURLs: [UrlType] = [] + var currentIndex = text.startIndex + + for match in matches { + if match.range.lowerBound > currentIndex { + let prefix = String(text[currentIndex.. [RelayLinkResult] { + let nsrange = NSRange(text.startIndex.. UrlType { + if let relay = RelayURL(url.absoluteString) { + return .relay(relay) + } + let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last switch fileExtension { @@ -443,6 +561,7 @@ enum NoteArtifacts { enum UrlType { case media(MediaUrl) case link(URL) + case relay(RelayURL) var url: URL { switch self { @@ -455,6 +574,8 @@ enum UrlType { } case .link(let url): return url + case .relay(let relay): + return relay.id } } @@ -469,6 +590,8 @@ enum UrlType { } case .link: return nil + case .relay: + return nil } } @@ -483,6 +606,8 @@ enum UrlType { } case .link: return nil + case .relay: + return nil } } @@ -492,6 +617,8 @@ enum UrlType { return nil case .link(let url): return url + case .relay: + return nil } } @@ -501,6 +628,26 @@ enum UrlType { return murl case .link: return nil + case .relay: + return nil + } + } + + var is_relay: RelayURL? { + switch self { + case .relay(let relay): + return relay + case .media, .link: + return nil + } + } + + var isPreviewableLink: Bool { + switch self { + case .link, .relay: + return true + case .media: + return false } } } diff --git a/damus/Features/Relays/Views/RelayDetailView.swift b/damus/Features/Relays/Views/RelayDetailView.swift index d2474ed79..ce4964c58 100644 --- a/damus/Features/Relays/Views/RelayDetailView.swift +++ b/damus/Features/Relays/Views/RelayDetailView.swift @@ -6,13 +6,14 @@ // import SwiftUI +import Combine struct RelayDetailView: View { let state: DamusState let relay: RelayURL let nip11: RelayMetadata? - @ObservedObject var log: RelayLog + @StateObject private var previewModel: RelayPreviewModel @Environment(\.dismiss) var dismiss @@ -21,154 +22,112 @@ struct RelayDetailView: View { self.relay = relay self.nip11 = nip11 - log = state.relay_model_cache.model(with_relay_id: relay)?.log ?? RelayLog() + _previewModel = StateObject(wrappedValue: RelayPreviewModel(state: state, relay: relay)) } func check_connection() -> Bool { return state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true } - func RemoveRelayButton(_ keypair: FullKeypair) -> some View { - Button(action: { - self.removeRelay() - }) { - HStack { - Text("Disconnect", comment: "Button to disconnect from the relay.") - .fontWeight(.semibold) - } - .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) - } - .buttonStyle(NeutralButtonShape.rounded.style) - } - - func ConnectRelayButton(_ keypair: FullKeypair) -> some View { - Button(action: { - self.connectRelay() - }) { - HStack { - Text("Connect", comment: "Button to connect to the relay.") - .fontWeight(.semibold) - } - .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) - } - .buttonStyle(NeutralButtonShape.rounded.style) - } - - var RelayInfo: some View { - ScrollView(.horizontal) { - Group { - HStack(spacing: 15) { - - RelayAdminDetail(state: state, nip11: nip11) - - Divider().frame(width: 1) - - RelaySoftwareDetail(nip11: nip11) - + @ViewBuilder + private var connectionControl: some View { + if state.keypair.to_full() != nil { + let isConnected = check_connection() + + VStack(alignment: .trailing, spacing: 4) { + Button { + if isConnected { + removeRelay() + } else { + connectRelay() + } + } label: { + Text(isConnected ? NSLocalizedString("Disconnect", comment: "Button to disconnect from the relay preview.") : NSLocalizedString("Add Relay", comment: "Button to add the relay after previewing.")) + .font(.footnote.weight(.semibold)) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(isConnected ? Color(.systemGray5) : Color.accentColor) + .foregroundColor(isConnected ? Color.primary : Color.white) + .clipShape(Capsule()) + } + .accessibilityIdentifier("relay-preview-action-button") + + if isConnected { + Label { + Text(NSLocalizedString("In your relay list", comment: "Subtle status text indicating the relay is already in the user's list")) + } icon: { + Image(systemName: "checkmark.circle") + .imageScale(.small) + } + .font(.caption2) + .foregroundColor(.secondary) + .accessibilityIdentifier("relay-preview-connected-indicator") } } } - .scrollIndicators(.hidden) } - - var RelayHeader: some View { - HStack(alignment: .top, spacing: 15) { - RelayPicView(relay: relay, icon: nip11?.icon, size: 90, highlight: .none, disable_animation: false) - - VStack(alignment: .leading) { - Text(nip11?.name ?? relay.absoluteString) - .font(.title) - .fontWeight(.bold) - .lineLimit(1) - - Text(relay.absoluteString) - .font(.headline) - .fontWeight(.regular) - .foregroundColor(.gray) - - HStack { - 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) - } + + private var previewHeader: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 16) { + RelayPicView(relay: relay, icon: nip11?.icon, size: 72, highlight: .none, disable_animation: false) + + VStack(alignment: .leading, spacing: 4) { + Text(nip11?.name ?? relay.absoluteString) + .font(.title2.weight(.bold)) + .lineLimit(1) + + Text(relay.absoluteString) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) } + + Spacer(minLength: 12) + + connectionControl + } + + if let description = nip11?.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { + Text(description) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(3) + } else { + Text(NSLocalizedString("Glance at what’s happening before you decide to connect.", comment: "Relay preview subtitle explaining the preview experience.")) + .font(.footnote) + .foregroundColor(.secondary) } } } - + var body: some View { NavigationView { - Group { - ScrollView { - Divider() - VStack(alignment: .leading, spacing: 10) { - - RelayHeader - - Divider() - - Text("Description", comment: "Description of the specific Nostr relay server.") - .font(.subheadline) - .foregroundColor(DamusColors.mediumGrey) - - if let description = nip11?.description, !description.isEmpty { - Text(description) - .font(.subheadline) - } else { - Text("N/A", comment: "Text label indicating that there is no NIP-11 relay description information found. In English, N/A stands for not applicable.") - .font(.subheadline) - } - - Divider() - - RelayInfo - - Divider() - - if let nip11 { - if let nips = nip11.supported_nips, nips.count > 0 { - RelayNipList(nips: nips) - Divider() - } - } - - if let keypair = state.keypair.to_full() { - if check_connection() { - RemoveRelayButton(keypair) - .padding(.top) - } else { - ConnectRelayButton(keypair) - .padding(.top) - } - } - - if state.settings.developer_mode { - Text("Relay Logs", comment: "Text label indicating that the text below it are developer mode logs.") - .padding(.top) - Divider() - Text(log.contents ?? NSLocalizedString("No logs to display", comment: "Label to indicate that there are no developer mode logs available to be displayed on the screen")) - .font(.system(size: 13)) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + previewHeader + RelayPreviewSectionView(state: state, model: previewModel) } + .padding(.horizontal) + .padding(.vertical, 24) } + .background(Color(.systemGroupedBackground)) } - .onReceive(handle_notify(.switched_timeline)) { notif in + .onReceive(handle_notify(.switched_timeline)) { _ in dismiss() } - .navigationTitle(nip11?.name ?? relay.absoluteString) + .navigationTitle(NSLocalizedString("Relay Preview", comment: "Navigation title for the relay preview experience")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .navigationBarItems(leading: BackNav()) - .ignoresSafeArea(.all) + .onAppear { + previewModel.start() + } + .onDisappear { + previewModel.stop() + } .toolbar { if let relay_connection { RelayStatusView(connection: relay_connection) @@ -205,6 +164,389 @@ struct RelayDetailView: View { } } +@MainActor +final class RelayPreviewModel: ObservableObject { + enum LoadingState { + case idle + case loading + case loaded + case empty + case error(String) + } + + struct KnownAuthor: Identifiable { + enum Relationship { + case you + case friend + case friendOfFriend + } + + let pubkey: Pubkey + let displayName: String + let relationship: Relationship + + var id: String { + "\(relationshipTag)-\(pubkey.hex())" + } + + private var relationshipTag: String { + switch relationship { + case .you: + return "self" + case .friend: + return "friend" + case .friendOfFriend: + return "friendOfFriend" + } + } + + var relationshipPriority: Int { + switch relationship { + case .you: return 0 + case .friend: return 1 + case .friendOfFriend: return 2 + } + } + } + + @Published private(set) var loadingState: LoadingState = .idle + @Published private(set) var sampleEvents: [NostrEvent] = [] + @Published private(set) var knownAuthors: [KnownAuthor] = [] + + private let state: DamusState + private let relay: RelayURL + private let subscriptionId = "relay-preview-" + UUID().uuidString + private var observedNoteIds: Set = [] + private var hasAddedEphemeralRelay = false + private var isActive = false + private var hasReceivedTerminalEvent = false + + private let sampleLimit = 32 + private let lookbackWindow: TimeInterval = 60 * 60 * 24 + + init(state: DamusState, relay: RelayURL) { + self.state = state + self.relay = relay + } + + deinit { + guard hasAddedEphemeralRelay else { return } + let state = state + let relay = relay + Task { @MainActor in + let pool = state.nostrNetwork.pool + if let relayObject = pool.get_relay(relay), relayObject.descriptor.ephemeral { + pool.remove_relay(relay) + } + } + } + + var directConnections: [KnownAuthor] { + knownAuthors.filter { $0.relationship == .you || $0.relationship == .friend } + } + + var extendedConnections: [KnownAuthor] { + knownAuthors.filter { $0.relationship == .friendOfFriend } + } + + func start() { + guard !isActive else { return } + isActive = true + hasReceivedTerminalEvent = false + observedNoteIds.removeAll() + sampleEvents.removeAll() + knownAuthors.removeAll() + loadingState = .loading + + guard ensureRelayConnection() else { + isActive = false + return + } + + subscribe() + } + + func stop() { + if isActive { + unsubscribe() + } else { + cleanupRelayIfNeeded() + } + } + + private func ensureRelayConnection() -> Bool { + let pool = state.nostrNetwork.pool + if let existing = pool.get_relay(relay) { + if !existing.connection.isConnected && !existing.connection.isConnecting { + pool.connect(to: [relay]) + } + return true + } + + do { + let descriptor = RelayPool.RelayDescriptor(url: relay, info: .read, variant: .ephemeral) + try pool.add_relay(descriptor) + hasAddedEphemeralRelay = true + pool.connect(to: [relay]) + return true + } catch { + loadingState = .error(NSLocalizedString("We couldn't preview this relay right now.", comment: "Relay preview error message when relay connection fails")) + return false + } + } + + private func subscribe() { + let pool = state.nostrNetwork.pool + pool.register_handler(sub_id: subscriptionId) { [weak self] relayURL, event in + guard let self else { return } + Task { @MainActor in + self.handleConnectionEvent(relayURL: relayURL, event: event) + } + } + let request = NostrRequest.subscribe(NostrSubscribe(filters: [makeFilter()], sub_id: subscriptionId)) + pool.send(request, to: [relay], skip_ephemeral: false) + } + + private func handleConnectionEvent(relayURL: RelayURL, event: NostrConnectionEvent) { + guard isActive else { return } + + switch event { + case .ws_connection_event(let wsEvent): + if case .error = wsEvent { + if case .loading = loadingState { + loadingState = .error(NSLocalizedString("We lost the preview connection to this relay.", comment: "Relay preview connection lost error message")) + } + unsubscribe() + } + case .nostr_event(let response): + guard response.subid == subscriptionId else { return } + switch response { + case .event(_, let nostrEvent): + ingest(event: nostrEvent) + case .eose: + hasReceivedTerminalEvent = true + finalizeLoadingIfNeeded() + case .notice: + break + case .ok: + break + case .auth: + break + } + } + } + + private func ingest(event: NostrEvent) { + guard isActive else { return } + guard event.known_kind == .text else { return } + guard observedNoteIds.insert(event.id).inserted else { return } + + sampleEvents.append(event) + sampleEvents.sort { $0.created_at > $1.created_at } + preload_events(state: state, events: [event]) + recomputeDerivedData() + + if sampleEvents.count >= sampleLimit { + finalizeLoadingIfNeeded() + } + } + + private func finalizeLoadingIfNeeded() { + guard isActive else { return } + if sampleEvents.count >= sampleLimit || hasReceivedTerminalEvent { + loadingState = sampleEvents.isEmpty ? .empty : .loaded + unsubscribe() + } + } + + private func unsubscribe() { + let pool = state.nostrNetwork.pool + pool.send(.unsubscribe(subscriptionId), to: [relay], skip_ephemeral: false) + pool.remove_handler(sub_id: subscriptionId) + cleanupRelayIfNeeded() + isActive = false + } + + private func cleanupRelayIfNeeded() { + guard hasAddedEphemeralRelay else { return } + let pool = state.nostrNetwork.pool + if let relayObject = pool.get_relay(relay), relayObject.descriptor.ephemeral { + pool.remove_relay(relay) + } + hasAddedEphemeralRelay = false + } + + private func makeFilter() -> NostrFilter { + let since = max(0, Int(Date().addingTimeInterval(-lookbackWindow).timeIntervalSince1970)) + return NostrFilter(kinds: [.text], since: UInt32(since), limit: UInt32(sampleLimit * 3)) + } + + private func recomputeDerivedData() { + var seenAuthors = Set() + var authors: [KnownAuthor] = [] + + for event in sampleEvents { + let pubkey = event.pubkey + if seenAuthors.contains(pubkey) { + continue + } + seenAuthors.insert(pubkey) + + let relationship: KnownAuthor.Relationship + if pubkey == state.pubkey { + relationship = .you + } else if state.contacts.is_friend(pubkey) { + relationship = .friend + } else if state.contacts.is_friend_of_friend(pubkey) { + relationship = .friendOfFriend + } else { + continue + } + + let display = displayName(for: pubkey) + authors.append(KnownAuthor(pubkey: pubkey, displayName: display, relationship: relationship)) + } + + knownAuthors = authors.sorted { lhs, rhs in + if lhs.relationshipPriority != rhs.relationshipPriority { + return lhs.relationshipPriority < rhs.relationshipPriority + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + private func displayName(for pubkey: Pubkey) -> String { + + if let txn = state.profiles.lookup(id: pubkey, txn_name: "relay-preview"), + let profile = txn.unsafeUnownedValue { + let display = Profile.displayName(profile: profile, pubkey: pubkey) + let preferred = display.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + if !preferred.isEmpty { + return preferred + } + let username = display.username.trimmingCharacters(in: .whitespacesAndNewlines) + if !username.isEmpty { + return username + } + } + + return abbrev_identifier(pubkey.hex()) + } +} + +struct RelayPreviewSectionView: View { + let state: DamusState + @ObservedObject var model: RelayPreviewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(NSLocalizedString("See what’s happening here", comment: "Headline for relay preview section")) + .font(.headline) + + statusView + + if hasTrustIndicators { + RelayPreviewTrustIndicator( + state: state, + extendedConnections: model.extendedConnections + ) + } + + if !sampleEventsPreview.isEmpty { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(sampleEventsPreview.enumerated()), id: \.element.id) { index, event in + EventView(damus: state, event: event, options: [.embedded, .no_translate, .no_show_more]) + if index != sampleEventsPreview.count - 1 { + Divider() + } + } + } + } + + // Intentionally omit additional people list below the preview feed to keep focus on the relay timeline. + } + } + + private var sampleEventsPreview: [NostrEvent] { + Array(model.sampleEvents.prefix(10)) + } + + private var hasTrustIndicators: Bool { + !model.extendedConnections.isEmpty + } + + @ViewBuilder + private var statusView: some View { + switch model.loadingState { + case .idle: + EmptyView() + case .loading: + HStack(spacing: 8) { + ProgressView() + Text(NSLocalizedString("Fetching a glimpse from this relay…", comment: "Relay preview loading message")) + .font(.footnote) + .foregroundColor(.secondary) + } + case .loaded: + if sampleEventsPreview.isEmpty && !hasTrustIndicators { + Text(NSLocalizedString("No recent notes were returned from this relay.", comment: "Relay preview empty state message")) + .font(.footnote) + .foregroundColor(.secondary) + } + case .empty: + Text(NSLocalizedString("No recent notes were returned from this relay.", comment: "Relay preview empty state message")) + .font(.footnote) + .foregroundColor(.secondary) + case .error(let message): + Text(message) + .font(.footnote) + .foregroundColor(.red) + } + } +} + +struct RelayPreviewTrustIndicator: View { + let state: DamusState + let extendedConnections: [RelayPreviewModel.KnownAuthor] + + private var displayedPubkeys: [Pubkey] { + Array(extendedConnections.map { $0.pubkey }.prefix(24)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Who’s posting here", comment: "Relay preview section title for web-of-trust indicator")) + .font(.subheadline) + .foregroundColor(.secondary) + + if !displayedPubkeys.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 12) { + ForEach(displayedPubkeys, id: \.self) { pubkey in + ProfilePicView( + pubkey: pubkey, + size: 36, + highlight: .none, + profiles: state.profiles, + disable_animation: state.settings.disable_animation + ) + .onTapGesture { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + notify(.present_sheet(Sheets.profile_action(pubkey))) + } + .onLongPressGesture(minimumDuration: 0.1) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + } + } + } + .padding(.vertical, 4) + } + } + } + } +} + struct RelayDetailView_Previews: PreviewProvider { static var previews: some View { let admission = Admission(amount: 1000000, unit: "msats") diff --git a/damus/Shared/Utilities/URLHandler.swift b/damus/Shared/Utilities/URLHandler.swift index 90d8ca62c..fd35ffa41 100644 --- a/damus/Shared/Utilities/URLHandler.swift +++ b/damus/Shared/Utilities/URLHandler.swift @@ -27,6 +27,9 @@ struct DamusURLHandler { switch parsed_url_info { case .profile(let pubkey): return .route(.ProfileByKey(pubkey: pubkey)) + case .relay(let relayURL): + let metadata = damus_state.relay_model_cache.model(with_relay_id: relayURL)?.metadata + return .route(.RelayDetail(relay: relayURL, metadata: metadata)) case .filter(let nostrFilter): let search = SearchModel(state: damus_state, search: nostrFilter) return .route(.Search(search: search)) @@ -76,6 +79,10 @@ struct DamusURLHandler { if let nwc = WalletConnectURL(str: url.absoluteString) { return .wallet_connect(nwc) } + + if let parsedRelay = parse_relay_url(url: url) { + return parsedRelay + } guard let link = decode_nostr_uri(url.absoluteString) else { return nil @@ -111,6 +118,7 @@ struct DamusURLHandler { enum ParsedURLInfo { case profile(Pubkey) + case relay(RelayURL) case filter(NostrFilter) case event(NostrEvent) case event_reference(LoadableNostrEventViewModel.NoteReference) @@ -119,4 +127,50 @@ struct DamusURLHandler { case purple(DamusPurpleURL) case invoice(Invoice) } + + private static func parse_relay_url(url: URL) -> ParsedURLInfo? { + guard let scheme = url.scheme?.lowercased() else { + return nil + } + + switch scheme { + case "damus": + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + let host = components.host?.lowercased() + var potentialRelayString: String? + + if host == "relay" { + potentialRelayString = components.queryItems?.first(where: { $0.name == "url" })?.value + if potentialRelayString == nil { + let trimmedPath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + potentialRelayString = trimmedPath.isEmpty ? nil : trimmedPath + } + } else { + let trimmedPath = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if trimmedPath.lowercased().hasPrefix("relay:") { + potentialRelayString = String(trimmedPath.dropFirst("relay:".count)) + } + } + + guard let rawRelay = potentialRelayString else { + return nil + } + + let decodedRelay = rawRelay.removingPercentEncoding ?? rawRelay + guard let relay = RelayURL(decodedRelay) else { + return nil + } + + return .relay(relay) + + case "ws", "wss": + return RelayURL(url.absoluteString).map { .relay($0) } + + default: + return nil + } + } } diff --git a/damusTests/NoteContentViewTests.swift b/damusTests/NoteContentViewTests.swift index 1939470cd..c63759a48 100644 --- a/damusTests/NoteContentViewTests.swift +++ b/damusTests/NoteContentViewTests.swift @@ -89,6 +89,56 @@ class NoteContentViewTests: XCTestCase { XCTAssertTrue(runArray[2].description.contains(" Pura Vida")) } + func testRenderBlocksWithRelayLinkIsDetected() throws { + let relayString = "wss://relay.damus.io" + let content = "Relay: \(relayString)" + let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair)) + let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair)) + + let testState = test_damus_state + + let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles) + let attributedText: AttributedString = noteArtifactsSeparated.content.attributed + + let runs: AttributedString.Runs = attributedText.runs + let runArray: [AttributedString.Runs.Run] = Array(runs) + + XCTAssertEqual(runArray.count, 2) + XCTAssertTrue(runArray[1].description.contains(relayString)) + + let encodedRelay = relayString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? relayString + let expectedDeepLink = "damus://relay?url=\(encodedRelay)" + XCTAssertEqual(runArray[1].link?.absoluteString, expectedDeepLink) + XCTAssertEqual(noteArtifactsSeparated.relays.count, 1) + XCTAssertEqual(noteArtifactsSeparated.relays.first?.absoluteString, relayString) + XCTAssertTrue(noteArtifactsSeparated.links.isEmpty) + } + + func testRenderBlocksWithRelayLinkTrailingPunctuation() throws { + let relayString = "wss://relay.damus.io" + let content = "Relay: \(relayString)! Wow" + let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair)) + let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair)) + + let testState = test_damus_state + + let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles) + let attributedText: AttributedString = noteArtifactsSeparated.content.attributed + + let runs: AttributedString.Runs = attributedText.runs + let runArray: [AttributedString.Runs.Run] = Array(runs) + + XCTAssertEqual(runArray.count, 3) + XCTAssertTrue(runArray[1].description.contains(relayString)) + + let encodedRelay = relayString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? relayString + let expectedDeepLink = "damus://relay?url=\(encodedRelay)" + XCTAssertEqual(runArray[1].link?.absoluteString, expectedDeepLink) + XCTAssertEqual(noteArtifactsSeparated.relays.count, 1) + XCTAssertEqual(noteArtifactsSeparated.relays.first?.absoluteString, relayString) + XCTAssertTrue(noteArtifactsSeparated.links.isEmpty) + } + func testRenderBlocksWithNoteIdInMiddleAreRendered() throws { let noteId = test_note.id.bech32 let content = " Check this out: nostr:\(noteId) Pura Vida " diff --git a/damusTests/URLHandlerTests.swift b/damusTests/URLHandlerTests.swift new file mode 100644 index 000000000..998c7e7c3 --- /dev/null +++ b/damusTests/URLHandlerTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import damus + +final class URLHandlerTests: XCTestCase { + func testParseRelayURLDirectWsScheme() throws { + let relayURLString = "wss://relay.damus.io" + let url = try XCTUnwrap(URL(string: relayURLString)) + + let parsed = DamusURLHandler.parse_url(url: url) + + switch parsed { + case .relay(let relay): + XCTAssertEqual(relay.absoluteString, relayURLString) + default: + XCTFail("Expected relay info for \(relayURLString) but got \(String(describing: parsed))") + } + } + + func testParseRelayURLFromDamusDeepLink() throws { + let relayURLString = "wss://relay.damus.io" + let encoded = relayURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? relayURLString + let deepLinkString = "damus://relay?url=\(encoded)" + let url = try XCTUnwrap(URL(string: deepLinkString)) + + let parsed = DamusURLHandler.parse_url(url: url) + + switch parsed { + case .relay(let relay): + XCTAssertEqual(relay.absoluteString, relayURLString) + default: + XCTFail("Expected relay info for deep link but got \(String(describing: parsed))") + } + } + + func testParseRelayURLFromDamusRelayPath() throws { + let relayURLString = "wss://relay.damus.io" + let deepLinkString = "damus://relay/\(relayURLString)" + let url = try XCTUnwrap(URL(string: deepLinkString)) + + let parsed = DamusURLHandler.parse_url(url: url) + + switch parsed { + case .relay(let relay): + XCTAssertEqual(relay.absoluteString, relayURLString) + default: + XCTFail("Expected relay info for relay path but got \(String(describing: parsed))") + } + } +}