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.
479 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|