jacquardSnapshot

← snapshot

7286 bytes
"use client";

import { useEffect, useRef, useState } from "react";
import { StatementTextarea, statementStatus } from "./statement-textarea";

/**
 * Speak it, or type it — either way, it's your verbatim account, yours to
 * edit before it's sealed.
 *
 * Defaults to recording via the browser's Web Speech API
 * (`SpeechRecognition` / `webkitSpeechRecognition`): live interim results
 * are shown dimmed, finalized text accumulates into the same editable
 * transcript. Nothing about the data model changes — the transcript is a
 * plain string, validated exactly like a typed statement, and the human can
 * edit it before submitting. A "Type it instead" toggle reaches the
 * unchanged textarea directly, and browsers without speech recognition
 * (Safari, Firefox) land there automatically, with one honest line saying
 * why — not a mic button that quietly does nothing.
 */
export function VoiceStatementInput({
  value,
  onChange,
}: {
  value: string;
  onChange: (next: string) => void;
}) {
  const [supported, setSupported] = useState<boolean | null>(null);
  const [typing, setTyping] = useState(false);
  const [recording, setRecording] = useState(false);
  const [interim, setInterim] = useState("");
  const [error, setError] = useState<string | null>(null);
  const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
  const baseRef = useRef(value);

  useEffect(() => {
    const Ctor =
      (window as WindowWithSpeech).SpeechRecognition ??
      (window as WindowWithSpeech).webkitSpeechRecognition;
    setSupported(Boolean(Ctor));
  }, []);

  function start() {
    const Ctor =
      (window as WindowWithSpeech).SpeechRecognition ??
      (window as WindowWithSpeech).webkitSpeechRecognition;
    if (!Ctor) return;

    setError(null);
    baseRef.current = value;
    const recognition = new Ctor();
    recognition.continuous = true;
    recognition.interimResults = true;
    recognition.lang = "en-US";

    recognition.onresult = (event: SpeechRecognitionEventLike) => {
      let finalText = "";
      let interimText = "";
      for (let i = event.resultIndex; i < event.results.length; i++) {
        const result = event.results[i];
        const transcript = result[0]?.transcript ?? "";
        if (result.isFinal) finalText += transcript;
        else interimText += transcript;
      }
      if (finalText) {
        const joined = [baseRef.current, finalText.trim()].filter(Boolean).join(" ");
        baseRef.current = joined;
        onChange(joined);
      }
      setInterim(interimText);
    };
    recognition.onerror = (event: { error: string }) => {
      const reason =
        event.error === "not-allowed" || event.error === "permission-denied"
          ? "Microphone access was denied."
          : event.error === "no-speech"
            ? "No speech was detected."
            : event.error === "network"
              ? "A network error interrupted recognition."
              : `Recognition stopped (${event.error}).`;
      setError(reason);
      setRecording(false);
    };
    recognition.onend = () => {
      setRecording(false);
      setInterim("");
    };

    recognitionRef.current = recognition;
    recognition.start();
    setRecording(true);
  }

  function stop() {
    recognitionRef.current?.stop();
  }

  useEffect(
    () => () => {
      recognitionRef.current?.stop();
    },
    [],
  );

  if (supported === null) {
    // Feature-detecting on the client only; render nothing that would flash.
    return <div className="jac-voice" style={{ minHeight: 96 }} />;
  }

  if (!supported || typing) {
    return (
      <div>
        <StatementTextarea value={value} onChange={onChange} />
        {!supported ? (
          <p className="jac-hint" style={{ marginTop: 8 }}>
            This browser doesn&apos;t expose speech recognition — Chrome and
            Edge do, Safari and Firefox don&apos;t yet. Typing works exactly the
            same either way.
          </p>
        ) : (
          <button
            type="button"
            className="jac-type-instead"
            style={{ marginTop: 8 }}
            onClick={() => setTyping(false)}
          >
            Speak it instead
          </button>
        )}
      </div>
    );
  }

  const status = statementStatus(value);
  const hasText = value.trim().length > 0;

  return (
    <div className="jac-voice">
      <div className="jac-voice-row">
        <button
          type="button"
          className="jac-mic-btn"
          data-recording={recording}
          onClick={recording ? stop : start}
          aria-label={recording ? "Stop recording" : "Start recording"}
        >
          {recording ? "■" : "🎙"}
        </button>
        <span className="jac-voice-status">
          {recording
            ? "Listening — speak your statement, then stop when you're done."
            : hasText
              ? "Recorded. Review below, or record again to add more."
              : "Speak it, or type it — either way, it's your verbatim account, yours to edit before it's sealed."}
        </span>
      </div>

      {error ? <div className="jac-hint jac-hint--error">{error}</div> : null}

      <div
        className="jac-voice-transcript"
        data-empty={!hasText && !interim}
        aria-live="polite"
      >
        {hasText || interim ? (
          <>
            {value}
            {interim ? <span className="jac-voice-interim"> {interim}</span> : null}
          </>
        ) : (
          "your transcript will appear here"
        )}
      </div>

      {hasText ? (
        <div className="jac-hint" style={{ display: "flex", justifyContent: "space-between", marginTop: 10 }}>
          <span className={status.valid ? undefined : "jac-hint--error"}>
            {status.valid ? "editable before you sign" : status.reason}
          </span>
          <span className={`jac-counter${status.trimmedLength > 2000 ? " jac-counter--over" : ""}`}>
            {status.trimmedLength} / 2000
          </span>
        </div>
      ) : null}

      <button
        type="button"
        className="jac-type-instead"
        style={{ marginTop: 12 }}
        onClick={() => {
          stop();
          setTyping(true);
        }}
      >
        Type it instead
      </button>
    </div>
  );
}

// ---------------------------------------------------------------------------
// Minimal Web Speech API surface — no @types/dom-speech-recognition dependency.
// ---------------------------------------------------------------------------

interface SpeechRecognitionAlternativeLike {
  transcript: string;
}

interface SpeechRecognitionResultLike {
  isFinal: boolean;
  [index: number]: SpeechRecognitionAlternativeLike;
}

interface SpeechRecognitionEventLike {
  resultIndex: number;
  results: ArrayLike<SpeechRecognitionResultLike>;
}

interface SpeechRecognitionLike {
  continuous: boolean;
  interimResults: boolean;
  lang: string;
  onresult: ((event: SpeechRecognitionEventLike) => void) | null;
  onerror: ((event: { error: string }) => void) | null;
  onend: (() => void) | null;
  start: () => void;
  stop: () => void;
}

interface WindowWithSpeech {
  SpeechRecognition?: new () => SpeechRecognitionLike;
  webkitSpeechRecognition?: new () => SpeechRecognitionLike;
}