"use client"; import { Edit, Notification, NotificationOff, Send, Show, } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupTextarea, } from "@openstatus/ui/components/ui/input-group"; import { Kbd, KbdGroup } from "@openstatus/ui/components/ui/kbd"; import { Tabs, TabsContent } from "@openstatus/ui/components/ui/tabs"; import { Toggle } from "@openstatus/ui/components/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react"; import { ProcessMessage } from "@/components/content/process-message"; import { useHydrated } from "@/hooks/use-hydrated"; import { useComposedRefs } from "@/lib/composition"; type ComposerView = "write" | "preview"; const DRAFT_EVENT = "composer-draft"; // Drafts localStorage refused (quota, private mode) live here instead, so the // controlled textarea never snaps back to the stored snapshot. const memoryDrafts = new Map(); /** * Message text that survives navigation, like Linear's unsent comments. * Stored per `key` (e.g. `incident:12`) in localStorage; set "" to clear. */ export function useComposerDraft(key: string) { const storageKey = `composer-draft:${key}`; const value = useSyncExternalStore( (onChange) => { window.addEventListener("storage", onChange); window.addEventListener(DRAFT_EVENT, onChange); return () => { window.removeEventListener("storage", onChange); window.removeEventListener(DRAFT_EVENT, onChange); }; }, () => { const memory = memoryDrafts.get(storageKey); if (memory !== undefined) return memory; try { return localStorage.getItem(storageKey) ?? ""; } catch { return ""; } }, () => "", ); const setValue = useCallback( (next: string) => { try { if (next) localStorage.setItem(storageKey, next); else localStorage.removeItem(storageKey); memoryDrafts.delete(storageKey); } catch { memoryDrafts.set(storageKey, next); } window.dispatchEvent(new Event(DRAFT_EVENT)); }, [storageKey], ); return [value, setValue] as const; } // Shared by the preview toggle and the Tabs; Radix keeps its own private. const ComposerContext = createContext<{ view: ComposerView; setView: (view: ComposerView) => void; /** Switch views and focus the pane that mounts, so a follow-up E lands. */ toggleView: () => void; /** True once after `toggleView`; the pane mounting next takes it. */ claimFocus: () => boolean; } | null>(null); // Radix mounts tab content a render after the switch, so each pane claims // focus itself on mount instead of the switcher reaching for it. function useFocusOnMount() { const { claimFocus } = useComposer(); return useCallback( (el: HTMLElement | null) => { if (el && claimFocus()) el.focus(); }, [claimFocus], ); } // A bare E anywhere on the page flips write/preview, unless a field has // focus and the letter is typed instead, or an overlay (select, menu, // dialog) owns the keyboard. function isPreviewHotkey(e: KeyboardEvent) { if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false; if (e.key.toLowerCase() !== "e" || e.isComposing || e.repeat) return false; return !( e.target instanceof HTMLElement && (e.target.isContentEditable || e.target.matches("input, textarea, select") || e.target.closest( '[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="combobox"]', )) ); } // With several composers on a page (incident notes and postmortem) the key // goes to the one last focused or clicked, else the first mounted. const composers: Array<() => void> = []; let activeComposer: (() => void) | null = null; /** "⌘" on Apple platforms, "Ctrl" elsewhere; ⌘ until hydrated. */ function useModifierKey() { const hydrated = useHydrated(); return hydrated && !/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? "Ctrl" : "⌘"; } function useComposer() { const ctx = useContext(ComposerContext); if (!ctx) throw new Error("Composer parts must be rendered inside "); return ctx; } /** * Markdown composer: a textarea with a footer for actions, switchable to a * rendered preview via `ComposerPreviewToggle`. `size="lg"` is for long-form * documents (postmortem) and only raises the minimum height. */ export function Composer({ children, className, size = "default", defaultValue = "write", onKeyDown, onFocusCapture, onPointerDownCapture, ...props }: Omit, "value" | "onValueChange"> & { size?: "default" | "lg"; defaultValue?: ComposerView; }) { const [view, setView] = useState(defaultValue); const focusNext = useRef(false); const toggleView = useCallback(() => { focusNext.current = true; setView((v) => (v === "write" ? "preview" : "write")); }, []); const claimFocus = useCallback(() => { if (!focusNext.current) return false; focusNext.current = false; return true; }, []); useEffect(() => { composers.push(toggleView); const onKeyDown = (e: KeyboardEvent) => { if (!isPreviewHotkey(e)) return; if ((activeComposer ?? composers[0]) !== toggleView) return; e.preventDefault(); toggleView(); }; document.addEventListener("keydown", onKeyDown); return () => { composers.splice(composers.indexOf(toggleView), 1); if (activeComposer === toggleView) activeComposer = null; document.removeEventListener("keydown", onKeyDown); }; }, [toggleView]); return ( setView(value as ComposerView)} data-size={size} className={cn("min-w-0 flex-1", className)} // Escape leaves the composer so the page's own keys (E, F, …) apply; // an open menu or tooltip has already consumed it by then onKeyDown={(e) => { onKeyDown?.(e); if (e.defaultPrevented || e.nativeEvent.isComposing) return; if (e.key !== "Escape") return; e.preventDefault(); if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); }} onFocusCapture={(e) => { onFocusCapture?.(e); activeComposer = toggleView; }} onPointerDownCapture={(e) => { onPointerDownCapture?.(e); activeComposer = toggleView; }} {...props} > {children} ); } /** `onSubmit` fires on Cmd/Ctrl+Enter. */ export function ComposerTextarea({ className, onSubmit, onKeyDown, ref, ...props }: React.ComponentProps & { onSubmit?: () => void; }) { const focusOnMount = useComposedRefs(ref, useFocusOnMount()); return ( { onKeyDown?.(e); if (e.defaultPrevented || e.nativeEvent.isComposing) return; if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && onSubmit) { e.preventDefault(); onSubmit(); } }} {...props} /> ); } export function ComposerPreview({ value, className, ref, ...props }: Omit, "children"> & { value: string }) { const focusOnMount = useComposedRefs(ref, useFocusOnMount()); return (
{value.trim() ? ( ) : (

Nothing to preview.

)}
); } /** Structured fields between the textarea and the footer. */ export function ComposerSection({ children, className, ...props }: React.ComponentProps) { return ( {children} ); } export function ComposerFooter({ children, className, ...props }: React.ComponentProps) { return ( {children} ); } /** Right-hand footer cluster; takes its own row under the fields on phones. */ export function ComposerActions({ children, className, ...props }: React.ComponentProps<"div">) { return (
{children}
); } /** Icon send button; the tooltip carries the label and the ⌘/Ctrl+↵ shortcut. */ export function ComposerSubmit({ label, className, ...props }: Omit, "children"> & { label: string }) { const modifier = useModifierKey(); return ( {label} {modifier} ↵ ); } /** Strips border/shadow from a select trigger or outline button in the footer. */ export const composerControlClassName = "text-foreground hover:bg-muted border-0 bg-transparent px-2 font-mono shadow-none dark:bg-transparent dark:hover:bg-muted"; // on: tinted like a hovered control; off: flat and muted const footerToggleClassName = "text-muted-foreground aria-pressed:bg-muted aria-pressed:text-foreground aria-disabled:opacity-50"; /** Ghost icon button for the footer; the tooltip carries the label and keys. */ export function ComposerIconButton({ label, shortcut, className, children, ...props }: React.ComponentProps & { label: string; shortcut?: React.ReactNode; }) { return ( {label} {shortcut} ); } /** * Swaps the textarea for the rendered markdown. The icon is the next action * (eye = preview, pencil = write), so it is a plain button, not a pressed toggle. */ export function ComposerPreviewToggle() { const { view, toggleView } = useComposer(); const previewing = view === "preview"; return ( E} onClick={toggleView} > {previewing ? : } ); } /** Bell toggle; without `canNotify` it stays off and the hover explains why. */ export function ComposerNotifyToggle({ canNotify, pressed, onPressedChange, }: { canNotify: boolean; pressed: boolean; onPressedChange: (pressed: boolean) => void; }) { const notify = canNotify && pressed; return ( {/* aria-disabled (not disabled) so the plan hint still opens */} canNotify && onPressedChange(value)} className={footerToggleClassName} > {notify ? : } {!canNotify ? "Subscriber notifications are not included in your plan." : notify ? "Subscribers will be notified" : "Subscribers will not be notified"} ); }