diff --git a/Modules/Sources/Support/InternalDataProvider.swift b/Modules/Sources/Support/InternalDataProvider.swift index 9bb8bd80f75d..f3e9be0b1b42 100644 --- a/Modules/Sources/Support/InternalDataProvider.swift +++ b/Modules/Sources/Support/InternalDataProvider.swift @@ -11,6 +11,7 @@ extension SupportDataProvider { botConversationDataProvider: InternalBotConversationDataProvider(), userDataProvider: InternalUserDataProvider(), supportConversationDataProvider: InternalSupportConversationDataProvider(), + unifiedConversationDataProvider: InternalUnifiedConversationDataProvider(), diagnosticsDataProvider: InternalDiagnosticsDataProvider(), mediaHost: InternalMediaHost() ) @@ -108,6 +109,41 @@ extension SupportDataProvider { ]) } + static let unifiedConversationItems: [UnifiedConversationItem] = [ + UnifiedConversationItem( + id: 1234, + title: "App Crashing on Launch", + description: "Hi, I'm having trouble with the app. It keeps crashing when I try to open it.", + rawStatus: UnifiedConversationItem.botStatus, + canAcceptReply: true, + lastMessageSentAt: Date().addingTimeInterval(-120) // 2 minutes ago + ), + UnifiedConversationItem( + id: 1, + title: "Login Issues with Two-Factor Authentication", + description: "I'm having trouble logging into my account. The two-factor authentication code isn't working.", + rawStatus: "open", + canAcceptReply: true, + lastMessageSentAt: Date().addingTimeInterval(-300) // 5 minutes ago + ), + UnifiedConversationItem( + id: 2, + title: "Billing Question - Duplicate Charges", + description: "I noticed duplicate charges on my credit card statement for this month's subscription.", + rawStatus: "pending", + canAcceptReply: true, + lastMessageSentAt: Date().addingTimeInterval(-3600) // 1 hour ago + ), + UnifiedConversationItem( + id: 6, + title: "API Rate Limiting Questions", + description: "Our application is hitting rate limits frequently. Can we discuss increasing our API quota?", + rawStatus: "closed", + canAcceptReply: false, + lastMessageSentAt: Date().addingTimeInterval(-604800) // 1 week ago + ) + ] + static let supportConversationSummaries: [ConversationSummary] = [ ConversationSummary( id: 1, @@ -334,6 +370,14 @@ actor InternalBotConversationDataProvider: BotConversationDataProvider { } } +actor InternalUnifiedConversationDataProvider: UnifiedConversationDataProvider { + nonisolated func loadUnifiedConversations() throws -> any CachedAndFetchedResult<[UnifiedConversationItem]> { + UncachedResult { + await SupportDataProvider.unifiedConversationItems + } + } +} + actor InternalUserDataProvider: CurrentUserDataProvider { nonisolated func fetchCurrentSupportUser() throws -> any CachedAndFetchedResult { UncachedResult { diff --git a/Modules/Sources/Support/Localization.swift b/Modules/Sources/Support/Localization.swift index 7be05f80b714..23d2e16db9aa 100644 --- a/Modules/Sources/Support/Localization.swift +++ b/Modules/Sources/Support/Localization.swift @@ -553,4 +553,17 @@ enum Localization { value: "Loading latest content", comment: "Progress message shown in overlay while refreshing content" ) + + // MARK: - UnifiedConversationListView.swift + + static let getHelp = NSLocalizedString( + "com.jetpack.support.getHelp", + value: "Get help", + comment: "Navigation title for the unified support conversations list, and the menu entry that opens it" + ) + static let botBadge = NSLocalizedString( + "com.jetpack.support.botBadge", + value: "Bot", + comment: "Badge shown on a conversation row to indicate it is an AI bot chat rather than a Happiness Engineer ticket" + ) } diff --git a/Modules/Sources/Support/Model/SupportConversation.swift b/Modules/Sources/Support/Model/SupportConversation.swift index f9a09b0fc655..7cb0dbd7fd53 100644 --- a/Modules/Sources/Support/Model/SupportConversation.swift +++ b/Modules/Sources/Support/Model/SupportConversation.swift @@ -183,6 +183,12 @@ public struct Attachment: Identifiable, Sendable, Codable, Equatable { contentType == "application/pdf" } + /// `text/html` "citation" attachments are web links (e.g. help articles) + /// rather than downloadable files. Handles `text/html; charset=utf-8`. + var isLink: Bool { + contentType.lowercased().hasPrefix("text/html") + } + var icon: String { if isVideo { return "film" diff --git a/Modules/Sources/Support/Model/UnifiedConversation.swift b/Modules/Sources/Support/Model/UnifiedConversation.swift new file mode 100644 index 000000000000..0982a2bf8816 --- /dev/null +++ b/Modules/Sources/Support/Model/UnifiedConversation.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A single entry in the unified support conversations list. +/// +/// The unified endpoint returns both AI bot chats and Happiness Engineer +/// tickets in one combined list. ``isBot`` decides which detail experience the +/// row opens into: a chat-style bot conversation, or a ticket-style HE +/// conversation. +public struct UnifiedConversationItem: Identifiable, Hashable, Sendable, Codable, Equatable { + + public let id: UInt64 + public let title: String + public let description: String + + /// The `description` with any markdown formatting stripped out. + public let plainTextDescription: String + + /// Raw server status string (e.g. `"bot"`, `"open"`, `"closed"`, `"pending"`, `"solved"`). + public let rawStatus: String + + /// The status mapped for badge rendering. Only meaningful for HE conversations. + public let status: ConversationStatus + + /// Will the server accept a reply to this conversation? + public let canAcceptReply: Bool + + public let lastMessageSentAt: Date + + public init( + id: UInt64, + title: String, + description: String, + rawStatus: String, + canAcceptReply: Bool, + lastMessageSentAt: Date + ) { + self.id = id + self.title = title + self.description = description + self.plainTextDescription = NSAttributedString(convertMarkdownTextToAttributedString(description)).string + self.rawStatus = rawStatus + self.status = ConversationStatus(serverStatus: rawStatus) + self.canAcceptReply = canAcceptReply + self.lastMessageSentAt = lastMessageSentAt + } + + /// `true` when this conversation is an AI bot chat rather than a Happiness + /// Engineer ticket. + public var isBot: Bool { + rawStatus.caseInsensitiveCompare(Self.botStatus) == .orderedSame + } + + /// A non-empty title for display. Bot conversations can have an empty + /// server title, so fall back to the (markdown-stripped) description. + var displayTitle: String { + title.isEmpty ? plainTextDescription : title + } + + public static let botStatus = "bot" +} + +public extension ConversationStatus { + /// Maps a raw server status string to a ``ConversationStatus``. + /// + /// Mirrors the Android `ConversationStatus.fromStatus` mapping so badges are + /// consistent across platforms. + init(serverStatus: String) { + switch serverStatus.lowercased() { + case "open", "new", "hold": self = .waitingForSupport + case "pending": self = .waitingForUser + case "solved": self = .resolved + case "closed": self = .closed + default: self = .unknown + } + } +} diff --git a/Modules/Sources/Support/SupportDataProvider.swift b/Modules/Sources/Support/SupportDataProvider.swift index a41f86deb686..e35c84fea1ec 100644 --- a/Modules/Sources/Support/SupportDataProvider.swift +++ b/Modules/Sources/Support/SupportDataProvider.swift @@ -34,6 +34,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { private let botConversationDataProvider: BotConversationDataProvider private let userDataProvider: CurrentUserDataProvider private let supportConversationDataProvider: SupportConversationDataProvider + private let unifiedConversationDataProvider: UnifiedConversationDataProvider private let diagnosticsDataProvider: DiagnosticsDataProvider let mediaHost: MediaHostProtocol @@ -44,6 +45,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { botConversationDataProvider: BotConversationDataProvider, userDataProvider: CurrentUserDataProvider, supportConversationDataProvider: SupportConversationDataProvider, + unifiedConversationDataProvider: UnifiedConversationDataProvider, diagnosticsDataProvider: DiagnosticsDataProvider, mediaHost: MediaHostProtocol, delegate: SupportDelegate? = nil @@ -52,6 +54,7 @@ public final class SupportDataProvider: ObservableObject, Sendable { self.botConversationDataProvider = botConversationDataProvider self.userDataProvider = userDataProvider self.supportConversationDataProvider = supportConversationDataProvider + self.unifiedConversationDataProvider = unifiedConversationDataProvider self.diagnosticsDataProvider = diagnosticsDataProvider self.mediaHost = mediaHost self.supportDelegate = delegate @@ -104,6 +107,15 @@ public final class SupportDataProvider: ObservableObject, Sendable { } } + // Unified Conversations Data Source + + /// Loads the combined list of bot and Happiness Engineer conversations from + /// the unified support endpoint. Detail and replies continue to use the + /// per-type data sources, branched on `UnifiedConversationItem.isBot`. + public func loadUnifiedConversations() throws -> any CachedAndFetchedResult<[UnifiedConversationItem]> { + try self.unifiedConversationDataProvider.loadUnifiedConversations() + } + // Support Conversations Data Source public func loadSupportConversations() throws -> any CachedAndFetchedResult<[ConversationSummary]> { try self.supportConversationDataProvider.loadSupportConversations() @@ -272,6 +284,10 @@ public protocol BotConversationDataProvider: Actor { func delete(conversationIds: [UInt64]) async throws } +public protocol UnifiedConversationDataProvider: Actor { + nonisolated func loadUnifiedConversations() throws -> any CachedAndFetchedResult<[UnifiedConversationItem]> +} + public protocol SupportConversationDataProvider: Actor { nonisolated var maximumUploadSize: UInt64 { get } diff --git a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift index 5270a0ba2f2e..b6b99c7802c7 100644 --- a/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift +++ b/Modules/Sources/Support/UI/Support Conversations/AttachmentListView.swift @@ -111,11 +111,19 @@ struct AttachmentListView: View { } private var otherAttachments: [Attachment] { - attachments.filter { !$0.isImage && !$0.isVideo } + attachments.filter { !$0.isImage && !$0.isVideo && !$0.isLink } + } + + private var linkAttachments: [Attachment] { + attachments.filter { $0.isLink } } var body: some View { VStack(alignment: .leading) { + ForEach(linkAttachments) { attachment in + AttachmentLinkView(attachment: attachment) + } + LazyVGrid(columns: columns, spacing: 16) { ForEach(imageAttachments) { attachment in AttachmentThumbnailView(attachment: attachment) @@ -129,6 +137,33 @@ struct AttachmentListView: View { } } +/// Renders a `text/html` citation attachment as a tappable link that opens in +/// the browser, rather than a file card that downloads raw HTML (PR 23011). +struct AttachmentLinkView: View { + + let attachment: Attachment + + var body: some View { + Link(destination: attachment.url) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "arrow.up.right.square") + .foregroundColor(.accentColor) + .font(.body) + .frame(width: 40, height: 40) + Text(attachment.filename) + .font(.body) + .foregroundColor(.accentColor) + .underline() + .lineLimit(1) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(.bottom, 4) + } + .accessibilityAddTraits(.isLink) + } +} + struct AttachmentThumbnailView: View { @EnvironmentObject diff --git a/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift new file mode 100644 index 000000000000..3a23f80315a0 --- /dev/null +++ b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift @@ -0,0 +1,244 @@ +import SwiftUI + +/// The unified support conversations list. +/// +/// Shows AI bot chats and Happiness Engineer tickets in a single combined list +/// (backed by the `unifiedConversations` endpoint) and routes each row into the +/// matching detail experience, branched on ``UnifiedConversationItem/isBot``: +/// the chat-style bot conversation, or the ticket-style HE conversation. +@MainActor +public struct UnifiedConversationListView: View { + + enum ViewState: Equatable { + case start + case loading(Task) + case partiallyLoaded([UnifiedConversationItem], Task) + case loaded([UnifiedConversationItem]) + case error(String) + + var isPartiallyLoaded: Bool { + guard case .partiallyLoaded = self else { return false } + return true + } + } + + @EnvironmentObject + private var dataProvider: SupportDataProvider + + @State + private var state: ViewState = .start + + private let currentUser: SupportUser + + public init(currentUser: SupportUser) { + self.currentUser = currentUser + } + + public var body: some View { + Group { + switch self.state { + case .start, .loading: + FullScreenProgressView(Localization.loadingConversations) + case .partiallyLoaded(let conversations, _), .loaded(let conversations): + self.conversationsList(conversations) + case .error(let error): + FullScreenErrorView( + title: Localization.unableToLoadConversations, + message: error + ) + } + } + .navigationTitle(Localization.getHelp) + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + // Starting a bot chat is available to any signed-in user; HE/support + // eligibility only gates replying to / creating Happiness Engineer + // tickets, which happens inside the detail flow. + NavigationLink { + ConversationView(conversation: nil, currentUser: currentUser) + .environmentObject(dataProvider) + } label: { + Image(systemName: "square.and.pencil") + } + } + } + .overlay { + OverlayProgressView(shouldBeVisible: self.state.isPartiallyLoaded) + } + .task(self.loadConversations) + .refreshable(action: self.reloadConversations) + } + + @ViewBuilder + private func conversationsList(_ conversations: [UnifiedConversationItem]) -> some View { + if case .loaded = self.state, conversations.isEmpty { + ContentUnavailableView { + Label(Localization.noConversations, systemImage: "message") + } description: { + Text(Localization.startNewConversation) + } + } else { + List { + ForEach(conversations) { conversation in + NavigationLink { + destination(for: conversation) + } label: { + UnifiedConversationRow(conversation: conversation) + } + } + } + .listStyle(PlainListStyle()) + .listRowInsets(.zero) + .listRowSeparator(.hidden) + } + } + + @ViewBuilder + private func destination(for conversation: UnifiedConversationItem) -> some View { + if conversation.isBot { + ConversationView( + conversation: BotConversation( + id: conversation.id, + title: conversation.displayTitle, + createdAt: conversation.lastMessageSentAt, + messages: [] + ), + currentUser: currentUser + ) + .environmentObject(dataProvider) + } else { + SupportConversationView( + conversation: ConversationSummary( + id: conversation.id, + title: conversation.displayTitle, + description: conversation.description, + status: conversation.status, + lastMessageSentAt: conversation.lastMessageSentAt + ), + currentUser: currentUser + ) + .environmentObject(dataProvider) + } + } + + @Sendable + private func loadConversations() async { + guard case .start = self.state else { return } + self.state = .loading(self.cacheTask) + } + + @Sendable + private func reloadConversations() async { + guard case .loaded(let conversations) = state else { return } + self.state = .partiallyLoaded(conversations, self.fetchTask) + } + + private var cacheTask: Task { + Task { + do { + let fetch = try dataProvider.loadUnifiedConversations() + if let cachedResults = try await fetch.cachedResult() { + self.state = .partiallyLoaded(cachedResults, self.fetchTask) + } + let fetchedResults = try await fetch.fetchedResult() + self.state = .loaded(fetchedResults) + } catch { + self.state = .error(error.localizedDescription) + } + } + } + + private var fetchTask: Task { + Task { + do { + let conversations = try await dataProvider.loadUnifiedConversations().fetchedResult() + self.state = .loaded(conversations) + } catch { + self.state = .error(error.localizedDescription) + } + } + } +} + +// MARK: - Row + +struct UnifiedConversationRow: View { + + @Environment(\.sizeCategory) + private var sizeCategory + + let conversation: UnifiedConversationItem + + var body: some View { + VStack(alignment: .leading) { + header + HStack { + TimelineView(.periodic(from: .now, by: 60.0)) { _ in + Text(formatTimestamp(conversation.lastMessageSentAt)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.bottom, 2) + + Text(conversation.plainTextDescription) + .font(.body) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + + @ViewBuilder + private var header: some View { + if sizeCategory.isAccessibilityCategory { + VStack(alignment: .leading) { + titleText + badge + } + } else { + HStack { + titleText + Spacer() + badge + } + } + } + + private var titleText: some View { + Text(conversation.displayTitle) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(sizeCategory.isAccessibilityCategory ? 2 : 1) + } + + @ViewBuilder + private var badge: some View { + if conversation.isBot { + ChipView(string: Localization.botBadge, color: .blue) + .controlSize(.mini) + } else { + ChipView(string: conversation.status.title, color: conversation.status.color) + .controlSize(.mini) + } + } + + private func formatTimestamp(_ date: Date) -> String { + Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date()) + } + + private static let relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() +} + +#Preview { + NavigationStack { + UnifiedConversationListView(currentUser: SupportDataProvider.supportUser) + } + .environmentObject(SupportDataProvider.testing) +} diff --git a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift index ea9775498df7..393dfb98fb19 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/RootSupportView.swift @@ -59,8 +59,7 @@ struct RootSupportView: View { Section("How can we help?") { communitySupportLink if let identity { - botSupportLink(for: identity) - humanSupportLink(for: identity) + getHelpLink(for: identity) } } @@ -89,29 +88,15 @@ struct RootSupportView: View { } @ViewBuilder - private func botSupportLink(for identity: SupportUser) -> some View { + private func getHelpLink(for identity: SupportUser) -> some View { NavigationLink { - ConversationListView(currentUser: identity) + UnifiedConversationListView(currentUser: identity) .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller } label: { SupportAreaRow( imageName: "bubble.left.and.text.bubble.right", - title: "Ask the Bots", - detail: "Get quick answers to common questions." - ) - } - } - - @ViewBuilder - private func humanSupportLink(for identity: SupportUser) -> some View { - NavigationLink { - SupportConversationListView(currentUser: identity) - .environmentObject(self.dataProvider) // Required until SwiftUI owns the nav controller - } label: { - SupportAreaRow( - imageName: "envelope.badge", - title: "Ask the Happiness Engineers", - detail: "For your tough questions. We'll reply via email." + title: "Get help", + detail: "Chat with our bots or reach a Happiness Engineer." ) } } diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index ea4c6df0161f..c79827c2bcbc 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -26,6 +26,9 @@ extension SupportDataProvider { supportConversationDataProvider: WpSupportConversationDataProvider( wpcomClient: WordPressDotComClient() ), + unifiedConversationDataProvider: WpUnifiedConversationDataProvider( + wpcomClient: WordPressDotComClient() + ), diagnosticsDataProvider: WpDiagnosticsDataProvider(), mediaHost: WordPressDotComClient(), delegate: WpSupportDelegate() @@ -151,7 +154,7 @@ class WpSupportDelegate: NSObject, SupportDelegate { actor WpBotConversationDataProvider: BotConversationDataProvider { - private let botId = "jetpack-chat-mobile" + private let botId = "jetpack-workflow-chat_mobile_support" private let wpcomClient: WordPressDotComClient @@ -336,6 +339,25 @@ actor WpSupportConversationDataProvider: SupportConversationDataProvider { } } +actor WpUnifiedConversationDataProvider: UnifiedConversationDataProvider { + + private let wpcomClient: WordPressDotComClient + + init(wpcomClient: WordPressDotComClient) { + self.wpcomClient = wpcomClient + } + + nonisolated func loadUnifiedConversations() throws -> any CachedAndFetchedResult<[UnifiedConversationItem]> { + DiskCachedAndFetchedResult(fetchedResult: { + try await self.wpcomClient.api + .unifiedConversations + .getUnifiedConversationList() + .data + .map { $0.asUnifiedConversationItem() } + }, cacheKey: "unified-conversation-list") + } +} + actor WpDiagnosticsDataProvider: DiagnosticsDataProvider { func fetchDiskCacheUsage() async throws -> WordPressCoreProtocols.DiskCacheUsage { try await DiskCache.shared.diskUsage() @@ -448,6 +470,19 @@ extension WordPressAPIInternal.BotMessage { } } +extension UnifiedConversationSummary { + func asUnifiedConversationItem() -> Support.UnifiedConversationItem { + Support.UnifiedConversationItem( + id: self.id, + title: self.title, + description: self.description, + rawStatus: self.status, + canAcceptReply: self.canAcceptReply, + lastMessageSentAt: self.updatedAt + ) + } +} + extension SupportConversationSummary { func asConversationSummary() -> Support.ConversationSummary { Support.ConversationSummary(