chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
439
web/components/support-chat/chat-shell.tsx
Normal file
439
web/components/support-chat/chat-shell.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
MessageSquarePlusIcon,
|
||||
RotateCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { SupportChatErrorBoundary } from "@/components/support-chat/chat-error-boundary"
|
||||
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
|
||||
import { SupportChatConversationSwitcher } from "@/components/support-chat/conversation-switcher"
|
||||
import { SupportChatNewRequestDialog } from "@/components/support-chat/new-request-dialog"
|
||||
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
|
||||
import { CSATCard } from "@/components/support-chat/csat-card"
|
||||
import { CustomerMessageEditor } from "@/components/support-chat/customer-message-editor"
|
||||
import { getCustomerSessionToken } from "@/lib/api/im"
|
||||
import {
|
||||
SupportChatMessageList,
|
||||
type SupportChatMessageListHandle,
|
||||
} from "@/components/support-chat/message-list"
|
||||
import {
|
||||
bindSupportHostBridge,
|
||||
requestSupportHostMinimize,
|
||||
} from "@/lib/support-host-bridge"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
const windowActionButtonClass =
|
||||
"size-8 rounded-full border-0 bg-transparent text-muted-foreground shadow-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-primary/20 dark:hover:bg-white/10"
|
||||
|
||||
function WindowActionButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(windowActionButtonClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useSupportChatSystemTheme() {
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
const root = document.documentElement
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const previousDarkClass = root.classList.contains("dark")
|
||||
const previousColorScheme = root.style.colorScheme
|
||||
|
||||
const syncTheme = () => {
|
||||
const isDark = query.matches
|
||||
root.classList.toggle("dark", isDark)
|
||||
root.style.colorScheme = isDark ? "dark" : "light"
|
||||
}
|
||||
|
||||
syncTheme()
|
||||
query.addEventListener("change", syncTheme)
|
||||
|
||||
return () => {
|
||||
query.removeEventListener("change", syncTheme)
|
||||
root.classList.toggle("dark", previousDarkClass)
|
||||
root.style.colorScheme = previousColorScheme
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function isEmbeddedInHost() {
|
||||
if (typeof window === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return window.parent !== window
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat panel without any outer sizing. Both the embedded iframe frame and
|
||||
* the standalone support page render this, so the surrounding chrome decides
|
||||
* how much room it gets.
|
||||
*/
|
||||
function SupportChatPanelContent() {
|
||||
const t = useI18n()
|
||||
useSupportChatSystemTheme()
|
||||
|
||||
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
|
||||
const [isEmbedded, setIsEmbedded] = useState(false)
|
||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
|
||||
const [isClosingConversation, setIsClosingConversation] = useState(false)
|
||||
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
themeColor,
|
||||
conversation,
|
||||
messages,
|
||||
messagesHasMore,
|
||||
messagesLoadingMore,
|
||||
loadOlderMessages,
|
||||
status,
|
||||
error,
|
||||
isOpen,
|
||||
isVisible,
|
||||
setIsOpen,
|
||||
setIsVisible,
|
||||
bootstrap,
|
||||
handleSendMessage,
|
||||
uploadMessageImage,
|
||||
sendAttachment,
|
||||
retry,
|
||||
disconnectSocket,
|
||||
markConversationRead,
|
||||
closeConversation,
|
||||
setConversationSatisfaction,
|
||||
} = useSupportChatStore(
|
||||
useShallow((state) => ({
|
||||
title: state.title,
|
||||
subtitle: state.subtitle,
|
||||
themeColor: state.themeColor,
|
||||
conversation: state.conversation,
|
||||
messages: state.messages,
|
||||
messagesHasMore: state.messagesHasMore,
|
||||
messagesLoadingMore: state.messagesLoadingMore,
|
||||
loadOlderMessages: state.loadOlderMessages,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
isOpen: state.isOpen,
|
||||
isVisible: state.isVisible,
|
||||
setIsOpen: state.setIsOpen,
|
||||
setIsVisible: state.setIsVisible,
|
||||
bootstrap: state.bootstrap,
|
||||
handleSendMessage: state.handleSendMessage,
|
||||
uploadMessageImage: state.uploadMessageImage,
|
||||
sendAttachment: state.sendAttachment,
|
||||
retry: state.retry,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
markConversationRead: state.markConversationRead,
|
||||
closeConversation: state.closeConversation,
|
||||
setConversationSatisfaction: state.setConversationSatisfaction,
|
||||
}))
|
||||
)
|
||||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
useEffect(() => {
|
||||
setIsEmbedded(isEmbeddedInHost())
|
||||
}, [])
|
||||
|
||||
const maybeMarkConversationRead = useCallback(() => {
|
||||
if (!isVisible || !conversation || typeof document === "undefined") {
|
||||
return
|
||||
}
|
||||
if (document.visibilityState !== "visible") {
|
||||
return
|
||||
}
|
||||
void markConversationRead().catch((readError) => {
|
||||
console.error("Failed to mark support chat conversation read", readError)
|
||||
})
|
||||
}, [conversation?.id, isVisible, markConversationRead])
|
||||
|
||||
useEffect(() => {
|
||||
return bindSupportHostBridge({
|
||||
onInit: () => {
|
||||
if (useSupportChatStore.getState().isOpen) {
|
||||
bootstrap()
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
setIsOpen(true)
|
||||
setIsVisible(true)
|
||||
},
|
||||
onMinimize: () => {
|
||||
setIsVisible(false)
|
||||
},
|
||||
})
|
||||
}, [bootstrap, setIsOpen, setIsVisible])
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap()
|
||||
|
||||
return () => {
|
||||
if (!isOpen) {
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
}, [isOpen, bootstrap, disconnectSocket])
|
||||
|
||||
useEffect(() => {
|
||||
maybeMarkConversationRead()
|
||||
}, [maybeMarkConversationRead, safeMessages.length])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
maybeMarkConversationRead()
|
||||
}
|
||||
}
|
||||
const handleFocus = () => {
|
||||
maybeMarkConversationRead()
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
}
|
||||
}, [maybeMarkConversationRead])
|
||||
|
||||
async function handleSend(content: string) {
|
||||
await handleSendMessage(content)
|
||||
messageListRef.current?.scrollToBottom()
|
||||
}
|
||||
|
||||
function handleCloseWindow() {
|
||||
setIsVisible(false)
|
||||
if (isEmbedded) {
|
||||
requestSupportHostMinimize()
|
||||
} else {
|
||||
window.location.replace(getStandaloneClosedUrl())
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseConversation() {
|
||||
if (isClosingConversation) {
|
||||
return
|
||||
}
|
||||
setIsClosingConversation(true)
|
||||
try {
|
||||
if (conversation?.id) {
|
||||
await closeConversation()
|
||||
}
|
||||
setIsCloseDialogOpen(false)
|
||||
} catch (closeError) {
|
||||
window.alert(closeError instanceof Error ? closeError.message : t("supportChat.closeConversationFailed"))
|
||||
} finally {
|
||||
setIsClosingConversation(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloseDialogOpen) {
|
||||
return
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isClosingConversation) {
|
||||
setIsCloseDialogOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isCloseDialogOpen, isClosingConversation])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-card text-card-foreground"
|
||||
style={{ "--primary": themeColor } as CSSProperties}
|
||||
>
|
||||
<header className="shrink-0 border-b border-border/70 bg-card/95 px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,0.02)] backdrop-blur dark:border-border/60 dark:bg-card/90 sm:px-4">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<SupportChatConversationSwitcher
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
status={status}
|
||||
onNewRequest={() => setIsNewRequestDialogOpen(true)}
|
||||
onCloseActiveRequest={() => setIsCloseDialogOpen(true)}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<WindowActionButton
|
||||
onClick={() => setIsNewRequestDialogOpen(true)}
|
||||
aria-label={t("supportChat.newRequest")}
|
||||
title={t("supportChat.newRequest")}
|
||||
>
|
||||
<MessageSquarePlusIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
{status !== "connected" ? (
|
||||
<WindowActionButton
|
||||
onClick={retry}
|
||||
aria-label={t("supportChat.retry")}
|
||||
title={t("supportChat.retry")}
|
||||
>
|
||||
<RotateCwIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
) : null}
|
||||
{conversation && conversation.status !== 4 ? (
|
||||
<WindowActionButton
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
aria-label={t("supportChat.confirmClose")}
|
||||
title={t("supportChat.confirmClose")}
|
||||
className="hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
>
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
</WindowActionButton>
|
||||
) : null}
|
||||
<WindowActionButton
|
||||
onClick={handleCloseWindow}
|
||||
aria-label={t("supportChat.closeWindow")}
|
||||
title={t("supportChat.closeWindow")}
|
||||
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
|
||||
<SupportChatMessageList
|
||||
ref={messageListRef}
|
||||
messages={safeMessages}
|
||||
onNearBottomVisible={maybeMarkConversationRead}
|
||||
hasMoreOlder={messagesHasMore}
|
||||
loadingOlder={messagesLoadingMore}
|
||||
onLoadOlder={loadOlderMessages}
|
||||
/>
|
||||
<div className="shrink-0 border-t border-border/80 bg-card/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_24px_rgba(15,23,42,0.05)] dark:bg-card/90 dark:shadow-none">
|
||||
{conversation && (conversation.status === 4 || conversation.satisfaction) ? (
|
||||
<div className="border-b border-border/60 p-3">
|
||||
<CSATCard
|
||||
key={conversation.id}
|
||||
conversationId={conversation.id}
|
||||
customerToken={getCustomerSessionToken()}
|
||||
initialSatisfaction={conversation.satisfaction}
|
||||
onSubmitted={setConversationSatisfaction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CustomerMessageEditor
|
||||
disabled={!conversation || conversation.status === 4}
|
||||
onSend={handleSend}
|
||||
onUploadImage={uploadMessageImage}
|
||||
onSendAttachment={sendAttachment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="border-t border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={isCloseDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isClosingConversation) {
|
||||
setIsCloseDialogOpen(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("supportChat.closeDialogTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-5">
|
||||
{t("supportChat.closeDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => setIsCloseDialogOpen(false)}
|
||||
>
|
||||
{t("supportChat.continueConversation")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => void confirmCloseConversation()}
|
||||
>
|
||||
{isClosingConversation ? t("supportChat.closing") : t("supportChat.confirmClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SupportChatNewRequestDialog
|
||||
open={isNewRequestDialogOpen}
|
||||
onOpenChange={setIsNewRequestDialogOpen}
|
||||
onCreated={() => {
|
||||
messageListRef.current?.scrollToBottom()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SupportChatPanel() {
|
||||
return (
|
||||
<SupportChatErrorBoundary>
|
||||
<SupportChatPanelContent />
|
||||
</SupportChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded widget frame. The host sizes the iframe, so the panel always
|
||||
* fills the whole document box.
|
||||
*/
|
||||
export function SupportChatShell() {
|
||||
return (
|
||||
<main className="relative flex h-[100dvh] min-h-[100dvh] overflow-hidden bg-muted text-foreground supports-not-[height:100dvh]:h-screen supports-not-[height:100dvh]:min-h-screen">
|
||||
<SupportChatPanel />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user