From 4e4c960c1a1481162b4cf6b197025fc039618569 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Tue, 30 Jun 2026 16:52:21 +0200 Subject: [PATCH 1/6] Unified support: add combined-conversations data layer Add the data layer for the unified support conversations list, backed by the new wordpress-rs `unifiedConversations` endpoint: - UnifiedConversationItem model (combined bot + Happiness Engineer list entry) with isBot branching and server-status mapping. - UnifiedConversationDataProvider protocol + loadUnifiedConversations() on SupportDataProvider. - WpUnifiedConversationDataProvider using api.unifiedConversations.getUnifiedConversationList(), mapping UnifiedConversationSummary to the combined item. - Mock provider + sample data for previews. Conversation detail and replies continue to use the existing per-type data sources, branched on the item type. --- .../Support/InternalDataProvider.swift | 44 +++++++++++ .../Support/Model/UnifiedConversation.swift | 74 +++++++++++++++++++ .../Sources/Support/SupportDataProvider.swift | 16 ++++ .../NewSupport/SupportDataProvider.swift | 35 +++++++++ 4 files changed, 169 insertions(+) create mode 100644 Modules/Sources/Support/Model/UnifiedConversation.swift 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/Model/UnifiedConversation.swift b/Modules/Sources/Support/Model/UnifiedConversation.swift new file mode 100644 index 000000000000..65bafff82b6a --- /dev/null +++ b/Modules/Sources/Support/Model/UnifiedConversation.swift @@ -0,0 +1,74 @@ +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 markdown formatting applied for rich-text display. + public let attributedDescription: AttributedString + + /// 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.attributedDescription = convertMarkdownTextToAttributedString(description) + self.plainTextDescription = NSAttributedString(self.attributedDescription).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 + } + + 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/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index ea4c6df0161f..5ef091dcb1d9 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() @@ -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( From 44886dc4ab67a38da241125d683d5e6272d58a0f Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Tue, 30 Jun 2026 16:56:30 +0200 Subject: [PATCH 2/6] Unified support: combined conversations list + single Get help entry Replace the two separate "Ask the Bots" and "Ask the Happiness Engineers" entries in the support hub with a single "Get help" entry that opens the unified conversations list. - UnifiedConversationListView shows bot chats and HE tickets in one combined list (loaded from the unified endpoint), with bot rows badged and HE rows showing their status, plus pull-to-refresh and a + button to start a new bot chat. - Each row routes into the existing detail experience branched on type: the chat-style bot ConversationView, or the ticket-style SupportConversationView (both reuse the existing per-type detail/reply code unchanged). - RootSupportView now shows one Get help link instead of two. The legacy Zendesk support flow remains gated behind FeatureFlag.newSupport (shown when the flag is off) and is unchanged. --- Modules/Sources/Support/Localization.swift | 13 + .../UnifiedConversationListView.swift | 248 ++++++++++++++++++ .../NewSupport/RootSupportView.swift | 25 +- 3 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift 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/UI/Unified Conversations/UnifiedConversationListView.swift b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift new file mode 100644 index 000000000000..683c1f3ea575 --- /dev/null +++ b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift @@ -0,0 +1,248 @@ +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) { + NavigationLink { + ConversationView(conversation: nil, currentUser: currentUser) + .environmentObject(dataProvider) + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(!currentUser.permissions.contains(.createChatConversation)) + } + } + .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) + } + } + } +} + +extension UnifiedConversationItem { + /// 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 + } +} + +// MARK: - Row + +struct UnifiedConversationRow: View { + + @Environment(\.sizeCategory) + private var sizeCategory + + let conversation: UnifiedConversationItem + + var body: some View { + VStack(alignment: .leading) { + VStack { + header + HStack { + TimelineView(.periodic(from: .now, by: 1.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 { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +#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." ) } } From af6f90762e1dca8ebb274e1f217b956065b24586 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Tue, 30 Jun 2026 16:57:39 +0200 Subject: [PATCH 3/6] Unified support: render text/html attachments as links Port WordPress-Android#23011. Bot replies can include text/html "citation" attachments that are web links to help articles, not downloadable files. Previously these rendered as file cards; tapping one would try to download raw HTML. This is most visible after a conversation is escalated to a Happiness Engineer, where the bot's earlier messages render through the ticket attachment path. - Add Attachment.isLink for text/html content types. - AttachmentListView partitions link attachments out of the file grid and renders them as tappable, underlined links that open in the browser. --- .../Support/Model/SupportConversation.swift | 6 +++ .../AttachmentListView.swift | 37 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) 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/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 From a5c92059ee3e87f7a5ff15de34b31284ad8478ba Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Tue, 30 Jun 2026 17:38:25 +0200 Subject: [PATCH 4/6] Unified support: don't gate new bot chat on support eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + (new conversation) button in the unified list was disabled unless the account had the .createChatConversation permission, which reflects Happiness Engineer / support eligibility. Starting a bot chat shouldn't require that — on accounts without support eligibility the button was permanently disabled, so new conversations couldn't be created at all. Remove the gate so any signed-in user can start a bot chat (matching the Android unified flow); HE eligibility still applies inside the ticket reply flow. --- .../Unified Conversations/UnifiedConversationListView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift index 683c1f3ea575..c8deeb9f207b 100644 --- a/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift +++ b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift @@ -52,13 +52,15 @@ public struct UnifiedConversationListView: View { .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") } - .disabled(!currentUser.permissions.contains(.createChatConversation)) } } .overlay { From f6518377b9deb5be3e37b70061bb6f75647ac638 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Wed, 1 Jul 2026 10:21:43 +0200 Subject: [PATCH 5/6] Unified support: simplify conversation item and list row - Drop unused stored attributedDescription on UnifiedConversationItem; derive plainTextDescription locally in init - Move displayTitle onto the model where it belongs - Share a static RelativeDateTimeFormatter instead of allocating one per call - Refresh the relative timestamp every 60s rather than every second - Flatten a redundant nested VStack in the row Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Support/Model/UnifiedConversation.swift | 12 ++++--- .../UnifiedConversationListView.swift | 32 ++++++++----------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/Modules/Sources/Support/Model/UnifiedConversation.swift b/Modules/Sources/Support/Model/UnifiedConversation.swift index 65bafff82b6a..0982a2bf8816 100644 --- a/Modules/Sources/Support/Model/UnifiedConversation.swift +++ b/Modules/Sources/Support/Model/UnifiedConversation.swift @@ -12,9 +12,6 @@ public struct UnifiedConversationItem: Identifiable, Hashable, Sendable, Codable public let title: String public let description: String - /// The `description` with markdown formatting applied for rich-text display. - public let attributedDescription: AttributedString - /// The `description` with any markdown formatting stripped out. public let plainTextDescription: String @@ -40,8 +37,7 @@ public struct UnifiedConversationItem: Identifiable, Hashable, Sendable, Codable self.id = id self.title = title self.description = description - self.attributedDescription = convertMarkdownTextToAttributedString(description) - self.plainTextDescription = NSAttributedString(self.attributedDescription).string + self.plainTextDescription = NSAttributedString(convertMarkdownTextToAttributedString(description)).string self.rawStatus = rawStatus self.status = ConversationStatus(serverStatus: rawStatus) self.canAcceptReply = canAcceptReply @@ -54,6 +50,12 @@ public struct UnifiedConversationItem: Identifiable, Hashable, Sendable, Codable 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" } diff --git a/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift index c8deeb9f207b..3a23f80315a0 100644 --- a/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift +++ b/Modules/Sources/Support/UI/Unified Conversations/UnifiedConversationListView.swift @@ -161,14 +161,6 @@ public struct UnifiedConversationListView: View { } } -extension UnifiedConversationItem { - /// 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 - } -} - // MARK: - Row struct UnifiedConversationRow: View { @@ -180,16 +172,14 @@ struct UnifiedConversationRow: View { var body: some View { VStack(alignment: .leading) { - VStack { - header - HStack { - TimelineView(.periodic(from: .now, by: 1.0)) { _ in - Text(formatTimestamp(conversation.lastMessageSentAt)) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() + header + HStack { + TimelineView(.periodic(from: .now, by: 60.0)) { _ in + Text(formatTimestamp(conversation.lastMessageSentAt)) + .font(.caption) + .foregroundColor(.secondary) } + Spacer() } .padding(.bottom, 2) @@ -236,10 +226,14 @@ struct UnifiedConversationRow: View { } 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.localizedString(for: date, relativeTo: Date()) - } + return formatter + }() } #Preview { From 069324d5450676b30a73592687785316e0f912b3 Mon Sep 17 00:00:00 2001 From: Adalberto Plaza Date: Wed, 1 Jul 2026 11:24:48 +0200 Subject: [PATCH 6/6] Unified support: update Odie bot ID to jetpack-workflow-chat_mobile_support --- .../Classes/ViewRelated/NewSupport/SupportDataProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift index 5ef091dcb1d9..c79827c2bcbc 100644 --- a/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift +++ b/WordPress/Classes/ViewRelated/NewSupport/SupportDataProvider.swift @@ -154,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