RecordingFenceNode.tsx3.4 KBView on GitHub
'use client';

import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { Node } from '@tiptap/core';

import { MeetingRecordingPlayer } from '@/modules/documents/recording/MeetingRecordingPlayer';
import { clockToSeconds } from '@/modules/documents/recording/timestamps';
import { decodeFenceAttr, safeHref } from '@/modules/documents/coaching/fence-attrs';
import { parseRecording } from '@/modules/documents/recording/recording-fence';

/**
 * Renders a ```recording fenced block as the meeting itself, playable in place.
 *
 * This is the whole-call embed; the per-moment one lives on the moment card, which reuses the
 * same player. The body is kept verbatim on the node so it round-trips losslessly to markdown,
 * and a body that fails to parse renders raw rather than a broken frame.
 */
function RecordingView({ node }: NodeViewProps) {
  const body = (node.attrs.body as string) ?? '';
  const recording = parseRecording(body);

  if (!recording) {
    return (
      <NodeViewWrapper as="div" className="my-3" contentEditable={false}>
        <pre className="rounded-md border border-border bg-muted p-3 font-mono text-xs">{body}</pre>
      </NodeViewWrapper>
    );
  }

  return (
    <NodeViewWrapper
      as="div"
      className="my-4 overflow-hidden rounded-lg border border-border bg-background"
      contentEditable={false}
    >
      {recording.title ? (
        <div className="border-b border-border px-3 py-2 text-sm font-semibold text-foreground">
          {recording.title}
        </div>
      ) : null}
      <div className="p-3">
        <MeetingRecordingPlayer
          externalId={recording.externalId}
          atSeconds={clockToSeconds(recording.at)}
          // The body is agent-authored document content, so the link is untrusted input.
          fallbackUrl={safeHref(recording.recording) ?? null}
        />
      </div>
    </NodeViewWrapper>
  );
}

const FENCE_RE = /^```recording[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)/;

export const RecordingFenceNode = Node.create({
  name: 'recordingBlock',
  group: 'block',
  atom: true,
  selectable: true,

  addAttributes() {
    return { body: { default: '' } };
  },

  parseHTML() {
    return [
      {
        tag: 'div[data-recording-block]',
        getAttrs: (el) => ({
          body: decodeFenceAttr((el as HTMLElement).getAttribute('data-recording-block')),
        }),
      },
    ];
  },

  renderHTML({ node }) {
    return [
      'div',
      { 'data-recording-block': encodeURIComponent((node.attrs.body as string) ?? '') },
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(RecordingView);
  },

  markdownTokenizer: {
    name: 'recordingBlock',
    level: 'block',
    start(src: string) {
      const idx = src.indexOf('```recording');
      return idx >= 0 ? idx : -1;
    },
    tokenize(src: string): MarkdownToken | undefined {
      const match = FENCE_RE.exec(src);
      if (!match) return undefined;
      return { type: 'recordingBlock', raw: match[0], body: match[1] };
    },
  },

  parseMarkdown(token=[redacted], helpers: MarkdownParseHelpers) {
    return helpers.createNode('recordingBlock', { body: token.body ?? '' }, []);
  },

  renderMarkdown(node: { attrs?: Record<string, unknown> }) {
    return '```recording\n' + ((node.attrs?.body as string) ?? '') + '\n```';
  },
});