Files
agent-desk-ng/web/components/support-chat/chat-shell.tsx
mono 63e0457886 fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
2026-10-16 12:15:20 +00:00

479 lines
15 KiB
TypeScript

// Modified in the independent hardening fork; see docs/hardening/README.md.
"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 mountedRef = useRef(false)
const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const {
identityScope,
title,
subtitle,
themeColor,
conversation,
messages,
messagesHasMore,
messagesLoadingMore,
loadOlderMessages,
status,
error,
sending,
uploadingAsset,
isOpen,
isVisible,
setIsOpen,
setIsVisible,
bootstrap,
handleSendMessage,
uploadMessageImage,
sendAttachment,
retry,
disconnectSocket,
markConversationRead,
closeConversation,
setConversationSatisfaction,
} = useSupportChatStore(
useShallow((state) => ({
identityScope: state.identityScope,
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,
sending: state.sending,
uploadingAsset: state.uploadingAsset,
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
}
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError)
})
}, [conversation?.id, isVisible, markConversationRead])
useEffect(() => {
return bindSupportHostBridge({
onInit: () => {
// Clear a previous visitor's private state even while the host keeps
// this frame hidden; visibility must never race identity replacement.
void bootstrap()
},
onOpen: () => {
setIsOpen(true)
setIsVisible(true)
},
onMinimize: () => {
setIsVisible(false)
},
})
}, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => {
void bootstrap()
}, [isOpen, bootstrap])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
disconnectSocket()
}
}, [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])
function requireDisplayedConversation() {
const current = useSupportChatStore.getState()
if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) {
throw new Error(t("supportChat.sendUnavailable"))
}
}
async function handleSend(content: string) {
requireDisplayedConversation()
const submittedConversationId = conversation?.id
await handleSendMessage(content)
const current = useSupportChatStore.getState()
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
messageListRef.current?.scrollToBottom()
}
}
function handleCloseWindow() {
setIsVisible(false)
if (isEmbedded) {
requestSupportHostMinimize()
} else {
setIsOpen(false)
window.location.replace(getStandaloneClosedUrl(window.location.search))
}
}
async function confirmCloseConversation() {
if (isClosingConversation) {
return
}
setIsClosingConversation(true)
try {
if (conversation?.id) {
requireDisplayedConversation()
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={(satisfaction) => {
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
setConversationSatisfaction(satisfaction)
}
}}
/>
</div>
) : null}
<CustomerMessageEditor
conversationId={conversation?.id}
draftScope={identityScope}
sending={sending}
uploadingAsset={uploadingAsset}
disabled={!conversation || conversation.status === 4}
onSend={handleSend}
onUploadImage={async (file) => {
requireDisplayedConversation()
return uploadMessageImage(file)
}}
onSendAttachment={async (file) => {
requireDisplayedConversation()
await sendAttachment(file)
}}
/>
</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>
)
}