From e0ba7ca41998c2cb00e951016e9e5653ede0787f Mon Sep 17 00:00:00 2001 From: Julien Dan Date: Fri, 29 May 2026 10:25:17 +0200 Subject: [PATCH 1/8] feat(feedback): Add posthog support feature --- .../src/app/components/header/header.tsx | 22 +- apps/console/src/main.tsx | 5 +- .../_authenticated/organization/route.tsx | 40 +- .../section-links/section-links.tsx | 7 +- libs/shared/assistant/feature/src/index.ts | 2 + .../lib/assistant-panel/assistant-panel.tsx | 5 +- .../conversations-context.tsx | 94 +++ .../conversations-panel-outlet.tsx | 16 + .../conversations-panel.tsx | 582 ++++++++++++++++++ .../devops-copilot-panel/header/header.tsx | 14 +- 10 files changed, 754 insertions(+), 33 deletions(-) create mode 100644 libs/shared/assistant/feature/src/lib/conversations-context/conversations-context.tsx create mode 100644 libs/shared/assistant/feature/src/lib/conversations-panel-outlet/conversations-panel-outlet.tsx create mode 100644 libs/shared/assistant/feature/src/lib/conversations-panel/conversations-panel.tsx diff --git a/apps/console/src/app/components/header/header.tsx b/apps/console/src/app/components/header/header.tsx index 3bc61b8d6f5..fdb2f9fd02d 100644 --- a/apps/console/src/app/components/header/header.tsx +++ b/apps/console/src/app/components/header/header.tsx @@ -1,8 +1,7 @@ import { Link, useParams } from '@tanstack/react-router' -import posthog from 'posthog-js' import { useFeatureFlagVariantKey } from 'posthog-js/react' import { Suspense, useCallback } from 'react' -import { AssistantTrigger } from '@qovery/shared/assistant/feature' +import { AssistantTrigger, useConversationsUnreadCount, useSetConversationsOpen } from '@qovery/shared/assistant/feature' import { DevopsCopilotButton } from '@qovery/shared/devops-copilot/feature' import { SpotlightTrigger } from '@qovery/shared/spotlight/feature' import { Button, LogoIcon } from '@qovery/shared/ui' @@ -25,9 +24,11 @@ export function Separator() { export function Header() { const { organizationId = '' } = useParams({ strict: false }) const isDevopsCopilotEnabled = useFeatureFlagVariantKey('devops-copilot') + const setConversationsOpen = useSetConversationsOpen() + const unreadCount = useConversationsUnreadCount() const handleFeedbackClick = useCallback(() => { - posthog.capture('feedback_button_clicked_new_navigation') - }, []) + setConversationsOpen(true) + }, [setConversationsOpen]) return (
@@ -47,9 +48,16 @@ export function Header() {
- +
+ + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )} +
{isDevopsCopilotEnabled && } diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index 55add569fc3..e6e4e71db91 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -178,10 +178,13 @@ function App() { initSentry() }, []) - // Keep PostHog's identified user in sync once Auth0 resolves the session useEffect(() => { if (!auth.user?.sub) { Sentry.setUser(null) + // Reset PostHog identity on logout so the next user gets a clean + // anonymous ID — prevents PostHog from merging two users' person profiles + // when they share the same device. + posthog.reset() return } diff --git a/apps/console/src/routes/_authenticated/organization/route.tsx b/apps/console/src/routes/_authenticated/organization/route.tsx index e1e1d0ba859..ee5b9d40806 100644 --- a/apps/console/src/routes/_authenticated/organization/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/route.tsx @@ -8,7 +8,12 @@ import { useEnvironment } from '@qovery/domains/environments/feature' import { useProject } from '@qovery/domains/projects/feature' import { type AnyService, isArgoCd, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access' import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature' -import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature' +import { + AssistantPanelOutlet, + AssistantProvider, + ConversationsPanelOutlet, + ConversationsProvider, +} from '@qovery/shared/assistant/feature' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context' import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature' import { ErrorBoundary, Icon, Link, LoaderSpinner, Navbar } from '@qovery/shared/ui' @@ -647,20 +652,22 @@ function OrganizationRoute() { sendMessageRef, }} > - - {isServiceNotFound ? ( - - ) : ( - - )} - - + + + {isServiceNotFound ? ( + + ) : ( + + )} + + + ) } @@ -673,6 +680,7 @@ function OrganizationRoute() { sendMessageRef, }} > +
{/* TODO: Conflicts with body main:not(.h-screen, .layout-onboarding) */} @@ -704,6 +712,7 @@ function OrganizationRoute() { }} > +
@@ -727,6 +736,7 @@ function OrganizationRoute() {
+
) } diff --git a/libs/domains/organizations/feature/src/lib/organization-overview/section-links/section-links.tsx b/libs/domains/organizations/feature/src/lib/organization-overview/section-links/section-links.tsx index 426f84df28a..1a740328be3 100644 --- a/libs/domains/organizations/feature/src/lib/organization-overview/section-links/section-links.tsx +++ b/libs/domains/organizations/feature/src/lib/organization-overview/section-links/section-links.tsx @@ -1,7 +1,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' import clsx from 'clsx' -import posthog from 'posthog-js' import { useCallback } from 'react' +import { useSetConversationsOpen } from '@qovery/shared/assistant/feature' import { Heading, Icon, Section } from '@qovery/shared/ui' import { useSupportChat } from '@qovery/shared/util-hooks' import { twMerge } from '@qovery/shared/util-js' @@ -34,10 +34,11 @@ const LINKS: { export function SectionLinks() { const { showChat } = useSupportChat() + const setConversationsOpen = useSetConversationsOpen() const handleGiveFeedbackClick = useCallback(() => { - posthog.capture('feedback_button_clicked_new_navigation') - }, []) + setConversationsOpen(true) + }, [setConversationsOpen]) return (
diff --git a/libs/shared/assistant/feature/src/index.ts b/libs/shared/assistant/feature/src/index.ts index 2d118549c8c..add0cd373a4 100644 --- a/libs/shared/assistant/feature/src/index.ts +++ b/libs/shared/assistant/feature/src/index.ts @@ -2,4 +2,6 @@ export * from './lib/assistant-trigger/assistant-trigger' export * from './lib/assistant-panel-outlet/assistant-panel-outlet' export * from './lib/need-help/need-help' export * from './lib/assistant-context/assistant-context' +export * from './lib/conversations-context/conversations-context' +export * from './lib/conversations-panel-outlet/conversations-panel-outlet' export * from './lib/hooks/use-contextual-doc-links/use-contextual-doc-links' diff --git a/libs/shared/assistant/feature/src/lib/assistant-panel/assistant-panel.tsx b/libs/shared/assistant/feature/src/lib/assistant-panel/assistant-panel.tsx index 4800740d874..2d69bd57afa 100644 --- a/libs/shared/assistant/feature/src/lib/assistant-panel/assistant-panel.tsx +++ b/libs/shared/assistant/feature/src/lib/assistant-panel/assistant-panel.tsx @@ -1,5 +1,4 @@ import { motion, useReducedMotion } from 'framer-motion' -import posthog from 'posthog-js' import { useEffect, useRef, useState } from 'react' import { match } from 'ts-pattern' import { ExternalLink, Icon, InputSearch, LoaderSpinner } from '@qovery/shared/ui' @@ -11,6 +10,7 @@ import { useContextualDocLinks } from '../hooks/use-contextual-doc-links/use-con import { useSearchDocumentation } from '../hooks/use-mintlify-search/use-mintlify-search' import { useQoveryStatus } from '../hooks/use-qovery-status/use-qovery-status' import { MintlifyHit } from '../mintlify-hit/mintlify-hit' +import { useSetConversationsOpen } from '../conversations-context/conversations-context' export interface AssistantPanelProps { onClose: () => void @@ -19,6 +19,7 @@ export interface AssistantPanelProps { export function AssistantPanel({ onClose }: AssistantPanelProps) { const { data } = useQoveryStatus() const { showChat } = useSupportChat() + const setConversationsOpen = useSetConversationsOpen() const docLinks = useContextualDocLinks() const [searchValue, setSearchValue] = useState('') const shouldReduceMotion = useReducedMotion() @@ -134,7 +135,7 @@ export function AssistantPanel({ onClose }: AssistantPanelProps) { className="flex h-11 items-center justify-center gap-1.5 px-5 font-medium text-neutral transition hover:bg-surface-neutral-subtle" type="button" onClick={() => { - posthog.capture('feedback_button_clicked_new_navigation') + setConversationsOpen(true) onClose() }} > diff --git a/libs/shared/assistant/feature/src/lib/conversations-context/conversations-context.tsx b/libs/shared/assistant/feature/src/lib/conversations-context/conversations-context.tsx new file mode 100644 index 00000000000..1cc01d8fb61 --- /dev/null +++ b/libs/shared/assistant/feature/src/lib/conversations-context/conversations-context.tsx @@ -0,0 +1,94 @@ +import posthog from 'posthog-js' +import { type PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +type ConversationsActionsContextValue = { + setConversationsOpen: (open: boolean) => void + toggleConversationsOpen: () => void +} + +const ConversationsOpenContext = createContext(false) +const ConversationsActionsContext = createContext({ + // eslint-disable-next-line @typescript-eslint/no-empty-function + setConversationsOpen: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + toggleConversationsOpen: () => {}, +}) +const ConversationsUnreadContext = createContext(0) + +export function ConversationsProvider({ children }: PropsWithChildren) { + const [conversationsOpen, setConversationsOpenRaw] = useState(false) + const [unreadCount, setUnreadCount] = useState(0) + + useEffect(() => { + if (conversationsOpen) return + + const poll = () => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const api = (posthog as any).conversations + if (typeof api?.getTickets !== 'function') return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api.getTickets().then((response: any) => { + const tickets: any[] = Array.isArray(response?.results) ? response.results : [] + // PostHog may use different field names for unread state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const count = tickets.filter((t: any) => + t.unread === true || + t.is_unread === true || + (typeof t.unread_count === 'number' && t.unread_count > 0) || + t.has_unread_messages === true + ).length + setUnreadCount(count) + }).catch(() => undefined) + } catch { + // posthog not ready yet + } + } + + const timeout = setTimeout(poll, 2_000) + const interval = setInterval(poll, 15_000) + return () => { + clearTimeout(timeout) + clearInterval(interval) + } + }, [conversationsOpen]) + + const setConversationsOpen = useCallback((open: boolean) => { + if (open) setUnreadCount(0) + setConversationsOpenRaw(open) + }, []) + + const toggleConversationsOpen = useCallback(() => { + setConversationsOpenRaw((prev) => { + if (!prev) setUnreadCount(0) + return !prev + }) + }, []) + + const actionsValue = useMemo( + () => ({ setConversationsOpen, toggleConversationsOpen }), + [setConversationsOpen, toggleConversationsOpen] + ) + + return ( + + + + {children} + + + + ) +} + +export function useConversationsOpen() { + return useContext(ConversationsOpenContext) +} + +export function useSetConversationsOpen() { + return useContext(ConversationsActionsContext).setConversationsOpen +} + +export function useConversationsUnreadCount() { + return useContext(ConversationsUnreadContext) +} diff --git a/libs/shared/assistant/feature/src/lib/conversations-panel-outlet/conversations-panel-outlet.tsx b/libs/shared/assistant/feature/src/lib/conversations-panel-outlet/conversations-panel-outlet.tsx new file mode 100644 index 00000000000..f6cb0817bfc --- /dev/null +++ b/libs/shared/assistant/feature/src/lib/conversations-panel-outlet/conversations-panel-outlet.tsx @@ -0,0 +1,16 @@ +import { AnimatePresence } from 'framer-motion' +import { useConversationsOpen, useSetConversationsOpen } from '../conversations-context/conversations-context' +import { ConversationsPanel } from '../conversations-panel/conversations-panel' + +export function ConversationsPanelOutlet() { + const conversationsOpen = useConversationsOpen() + const setConversationsOpen = useSetConversationsOpen() + + return ( + + {conversationsOpen && setConversationsOpen(false)} />} + + ) +} + +export default ConversationsPanelOutlet diff --git a/libs/shared/assistant/feature/src/lib/conversations-panel/conversations-panel.tsx b/libs/shared/assistant/feature/src/lib/conversations-panel/conversations-panel.tsx new file mode 100644 index 00000000000..bdb83c7d825 --- /dev/null +++ b/libs/shared/assistant/feature/src/lib/conversations-panel/conversations-panel.tsx @@ -0,0 +1,582 @@ +import { useAuth0 } from '@auth0/auth0-react' +import { motion, useReducedMotion } from 'framer-motion' +import posthog from 'posthog-js' +import { useEffect, useRef, useState } from 'react' +import { Badge, Button, Icon, LoaderSpinner, Skeleton } from '@qovery/shared/ui' +import { timeAgo } from '@qovery/shared/util-dates' + +type View = 'list' | 'compose' | 'thread' +type SendState = 'idle' | 'sending' | 'error' +type LoadState = 'idle' | 'loading' | 'error' + +type TicketStatus = 'new' | 'open' | 'pending' | 'on_hold' | 'resolved' +type MessageAuthorType = 'customer' | 'AI' | 'human' + +interface Ticket { + id: string + status: TicketStatus + last_message?: string + last_message_at?: string + message_count: number + created_at: string + unread_count?: number +} + +interface Message { + id: string + content: string + author_type: MessageAuthorType + author_name?: string + created_at: string + is_private: boolean +} + +// Derive a stable UUID from distinct_id so each authenticated user gets their +// own widget session — prevents cross-user ticket leakage on shared devices. +// The SDK caches widget_session_id in memory after first load so we can't +// override it; direct HTTP calls with explicit distinct_id are the only +// reliable way to filter tickets per user. +function distinctIdToUUID(id: string): string { + let h1 = 5381, + h2 = 52711 + for (let i = 0; i < id.length; i++) { + const c = id.charCodeAt(i) + h1 = (Math.imul(h1, 33) + c) >>> 0 + h2 = (Math.imul(h2, 33) ^ c) >>> 0 + } + let h3 = Math.imul(h1 ^ (h1 >>> 16), 0x45d9f3b) >>> 0 + let h4 = Math.imul(h2 ^ (h2 >>> 16), 0x45d9f3b) >>> 0 + h3 = Math.imul(h3 ^ (h3 >>> 16), 0x45d9f3b) >>> 0 + h4 = Math.imul(h4 ^ (h4 >>> 16), 0x45d9f3b) >>> 0 + const p = (n: number) => n.toString(16).padStart(8, '0') + const raw = p(h1) + p(h2) + p(h3) + p(h4) + const ver = '4' + raw.slice(13, 16) + const variant = + ((parseInt(raw.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, '0') + raw.slice(18, 20) + return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${ver}-${variant}-${raw.slice(20, 32)}` +} + +// sub comes from Auth0's user.sub — always synchronous and up-to-date, +// unlike posthog.get_distinct_id() which may lag behind posthog.identify(). +function getApiConfig(sub: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ph = posthog as any + const apiHost = (ph.config?.api_host ?? 'https://us.posthog.com').replace(/\/$/, '') + const phcToken = ph.config?.token ?? '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const conversationsToken = (window as any)._POSTHOG_REMOTE_CONFIG?.[phcToken]?.config?.conversations?.token ?? '' + const widgetSessionId = distinctIdToUUID(sub) + return { apiHost, conversationsToken, distinctId: sub, widgetSessionId } +} + +async function apiGetTickets(sub: string): Promise { + const { apiHost, conversationsToken, distinctId, widgetSessionId } = getApiConfig(sub) + const params = new URLSearchParams({ widget_session_id: widgetSessionId, distinct_id: distinctId, limit: '20', offset: '0' }) + const res = await fetch(`${apiHost}/api/conversations/v1/widget/tickets?${params}`, { + headers: { 'X-Conversations-Token': conversationsToken }, + }) + if (!res.ok) throw new Error(`getTickets failed: ${res.status}`) + const data = await res.json() + return Array.isArray(data?.results) ? (data.results as Ticket[]) : [] +} + +async function apiGetMessages(ticketId: string, sub: string): Promise { + const { apiHost, conversationsToken, widgetSessionId } = getApiConfig(sub) + const params = new URLSearchParams({ widget_session_id: widgetSessionId, limit: '50' }) + const res = await fetch(`${apiHost}/api/conversations/v1/widget/messages/${ticketId}?${params}`, { + headers: { 'X-Conversations-Token': conversationsToken }, + }) + if (!res.ok) throw new Error(`getMessages failed: ${res.status}`) + const data = await res.json() + const messages: Message[] = Array.isArray(data?.messages) ? data.messages : [] + return messages.filter((m) => !m.is_private) +} + +async function apiSendMessage( + message: string, + ticketId: string | null, + sub: string +): Promise<{ ticket_id: string; message_id: string; created_at: string }> { + const { apiHost, conversationsToken, distinctId, widgetSessionId } = getApiConfig(sub) + const res = await fetch(`${apiHost}/api/conversations/v1/widget/message`, { + method: 'POST', + headers: { 'X-Conversations-Token': conversationsToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + widget_session_id: widgetSessionId, + distinct_id: distinctId, + message, + ticket_id: ticketId, + }), + }) + if (!res.ok) throw new Error(`sendMessage failed: ${res.status}`) + return res.json() +} + +async function apiMarkAsRead(ticketId: string, sub: string): Promise { + const { apiHost, conversationsToken, widgetSessionId } = getApiConfig(sub) + await fetch(`${apiHost}/api/conversations/v1/widget/messages/${ticketId}/read`, { + method: 'POST', + headers: { 'X-Conversations-Token': conversationsToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ widget_session_id: widgetSessionId }), + }) +} + +function getTicketTitle(ticket: Ticket, previews: Record): string { + return ticket.last_message?.trim() || previews[ticket.id] || '' +} + +function isTicketUnread(ticket: Ticket): boolean { + return typeof ticket.unread_count === 'number' && ticket.unread_count > 0 +} + +function isTicketActive(ticket: Ticket): boolean { + return ticket.status !== 'resolved' +} + +function MessageContent({ body, isUser }: { body: string; isUser: boolean }) { + const parts = body.split(/(!\[[^\]]*\]\(https?:\/\/[^)]+\))/g) + if (parts.length === 1) { + return

{body}

+ } + return ( + <> + {parts.map((part, i) => { + const imgMatch = part.match(/^!\[([^\]]*)\]\((https?:\/\/[^)]+)\)$/) + if (imgMatch) { + const [, alt, url] = imgMatch + return ( + + {alt + + ) + } + const text = part.trim() + return text ? ( +

+ {text} +

+ ) : null + })} + + ) +} + +export interface ConversationsPanelProps { + onClose: () => void +} + +export function ConversationsPanel({ onClose }: ConversationsPanelProps) { + const { user } = useAuth0() + const [view, setView] = useState('list') + const [tickets, setTickets] = useState([]) + const [loadState, setLoadState] = useState('idle') + const [message, setMessage] = useState('') + const [sendState, setSendState] = useState('idle') + const [selectedTicket, setSelectedTicket] = useState(null) + const [threadMessages, setThreadMessages] = useState([]) + const [threadLoadState, setThreadLoadState] = useState('idle') + const [composeSent, setComposeSent] = useState(false) + const [previews, setPreviews] = useState>({}) + const messagesEndRef = useRef(null) + const shouldReduceMotion = useReducedMotion() + + useEffect(() => { + const down = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [onClose]) + + // Reload tickets whenever the authenticated user changes. + useEffect(() => { + if (!user?.sub) return + const sub = user.sub + setLoadState('loading') + setTickets([]) + setPreviews({}) + + async function init() { + const results = await apiGetTickets(sub) + setTickets(results) + setView(results.length === 0 ? 'compose' : 'list') + setLoadState('idle') + + const needPreview = results.filter((t) => !getTicketTitle(t, {})) + if (needPreview.length === 0) return + + const updates: Record = {} + await Promise.all( + needPreview.map(async (ticket) => { + try { + const msgs = await apiGetMessages(ticket.id, sub) + const first = msgs.find((m) => m.author_type === 'customer') ?? msgs[0] + if (first?.content) updates[ticket.id] = first.content.slice(0, 80) + } catch { + return + } + }) + ) + + if (Object.keys(updates).length > 0) setPreviews(updates) + } + + init().catch(() => { + setLoadState('error') + setView('compose') + }) + }, [user?.sub]) + + // Poll for new messages every 5s while a thread is open. + useEffect(() => { + if (view !== 'thread' || !selectedTicket || !user?.sub) return + const sub = user.sub + + const interval = setInterval(async () => { + const msgs = await apiGetMessages(selectedTicket.id, sub) + setThreadMessages(msgs) + }, 5_000) + + return () => clearInterval(interval) + }, [view, selectedTicket, user?.sub]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [threadMessages]) + + const loadMessages = async (ticket: Ticket) => { + if (!user?.sub) return + setSelectedTicket(ticket) + setView('thread') + setThreadLoadState('loading') + setThreadMessages([]) + try { + apiMarkAsRead(ticket.id, user.sub).catch(() => undefined) + const msgs = await apiGetMessages(ticket.id, user.sub) + setThreadMessages(msgs) + setThreadLoadState('idle') + } catch { + setThreadLoadState('error') + } + } + + const handleSendNew = async () => { + if (!message.trim() || !user?.sub) return + const sub = user.sub + setSendState('sending') + try { + const response = await apiSendMessage(message.trim(), null, sub) + setPreviews((prev) => ({ ...prev, [response.ticket_id]: message.trim().slice(0, 80) })) + setMessage('') + setComposeSent(true) + setSendState('idle') + apiGetTickets(sub) + .then((results) => setTickets(results)) + .catch(() => undefined) + } catch { + setSendState('error') + } + } + + const handleSendReply = async () => { + if (!message.trim() || !selectedTicket || !user?.sub) return + const sub = user.sub + + // Optimistic update so the message appears instantly in the UI. + const optimistic: Message = { + id: `optimistic-${Date.now()}`, + content: message.trim(), + author_type: 'customer', + created_at: new Date().toISOString(), + is_private: false, + } + setThreadMessages((prev) => [...prev, optimistic]) + setMessage('') + setSendState('sending') + + try { + const response = await apiSendMessage(optimistic.content, selectedTicket.id, sub) + setThreadMessages((prev) => + prev.map((m) => + m.id === optimistic.id ? { ...optimistic, id: response.message_id, created_at: response.created_at } : m + ) + ) + setSendState('idle') + } catch { + setThreadMessages((prev) => prev.filter((m) => m.id !== optimistic.id)) + setMessage(optimistic.content) + setSendState('error') + } + } + + const handleBackToList = () => { + setSendState('idle') + setMessage('') + setComposeSent(false) + setSelectedTicket(null) + setThreadMessages([]) + setView('list') + } + + const handleNewMessage = () => { + setSendState('idle') + setMessage('') + setComposeSent(false) + setView('compose') + } + + const transition = shouldReduceMotion + ? { duration: 0 } + : { + x: { type: 'spring' as const, stiffness: 900, damping: 45, mass: 0.5 }, + opacity: { duration: 0.12 }, + } + + const headerTitle = + view === 'compose' + ? 'New message' + : view === 'thread' + ? getTicketTitle(selectedTicket!, previews) || 'Conversation' + : 'Feedback' + + return ( + +
+
+ {(view === 'compose' && tickets.length > 0) || view === 'thread' ? ( + + ) : null} +
+ + + + {headerTitle} +
+
+ +
+ + {view === 'list' && ( +
+
+ {loadState === 'loading' && ( +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))} +
+ )} + {loadState === 'idle' && tickets.length > 0 && ( +
    + {tickets.map((ticket) => { + const title = getTicketTitle(ticket, previews) + const active = isTicketActive(ticket) + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ +
+
+ )} + + {view === 'thread' && ( +
+
+ {threadLoadState === 'loading' && ( +
+ {[0, 1, 2].map((i) => ( +
+ +
+ ))} +
+ )} + {threadLoadState === 'error' &&

Failed to load messages.

} + {threadLoadState === 'idle' && threadMessages.length === 0 && ( +

No messages yet.

+ )} + {threadLoadState === 'idle' && threadMessages.length > 0 && ( +
+ {threadMessages.map((msg) => { + const fromUser = msg.author_type === 'customer' + return ( +
+ {!fromUser && ( + + {msg.author_name ?? 'Support'} + + )} +
+ +

+ {timeAgo(new Date(msg.created_at))} ago +

+
+
+ ) + })} +
+
+ )} +
+
+
+