Skip to content
Draft
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
44 changes: 44 additions & 0 deletions Modules/Sources/Support/InternalDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension SupportDataProvider {
botConversationDataProvider: InternalBotConversationDataProvider(),
userDataProvider: InternalUserDataProvider(),
supportConversationDataProvider: InternalSupportConversationDataProvider(),
unifiedConversationDataProvider: InternalUnifiedConversationDataProvider(),
diagnosticsDataProvider: InternalDiagnosticsDataProvider(),
mediaHost: InternalMediaHost()
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<SupportUser> {
UncachedResult {
Expand Down
13 changes: 13 additions & 0 deletions Modules/Sources/Support/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
6 changes: 6 additions & 0 deletions Modules/Sources/Support/Model/SupportConversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions Modules/Sources/Support/Model/UnifiedConversation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 16 additions & 0 deletions Modules/Sources/Support/SupportDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading