Skip to content

WebView Rich Text Composer Recipes

A React Native guide for coding agents. Also covers react native rich text input, tentap composer, tiptap webview editor, message composer mentions, webview mention bridge, pasted image upload, and 1 more.

Show all 7 aliases

react native rich text input, tentap composer, tiptap webview editor, message composer mentions, webview mention bridge, pasted image upload, structured chat input

Use a WebView editor when the product truly needs structured mentions, marks, links, or pasted media. Keep the WebView responsible for editing only. Native React components should own suggestions, attachments, sending, errors, and keyboard-safe layout.

Compatibility and Setup Choice

These recipes target @10play/tentap-editor 1.x and react-native-webview 13.x. Install both with bun expo install @10play/tentap-editor react-native-webview. Basic TenTap usage works in Expo Go, but a custom TipTap extension and bundled customSource require a development build.

These examples target TenTap 1 and React Native WebView 13. Confirm the installed editor and bridge APIs before applying them.

Choose the smallest editor that satisfies the product:

RequirementRecommended path
Plain text, attachments, repliesNative multiline TextInput
Standard bold, lists, and linksTenTap simple editor
Structured mentions or custom nodesTenTap advanced bundle plus a typed BridgeExtension

Do not load remote or user-supplied HTML as the editor source. Bundle the editor locally and treat its JSON document as untrusted input at the API boundary.

Define a Safe Document Boundary

Store structured JSON rather than arbitrary HTML. Validate node kinds, link protocols, depth, and total nodes before accepting a document on the server.

type RichMark = {
  type: "bold" | "italic" | "link";
  attrs?: { href?: string };
};

export type RichNode = {
  type: "doc" | "paragraph" | "text" | "hardBreak" | "mention";
  text?: string;
  attrs?: { id?: string; label?: string };
  marks?: readonly RichMark[];
  content?: readonly RichNode[];
};

export type MentionIdentity = {
  id: string;
  displayLabel: string;
};

type ResolveMentionForSender = (
  targetUserId: string,
) => Promise<MentionIdentity | null>;

const allowedProtocols = new Set(["https:", "http:", "mailto:"]);

function validateLink(href: string): void {
  const url = new URL(href);
  if (!allowedProtocols.has(url.protocol)) throw new Error("Unsupported link protocol");
}

export async function validateRichDocument(
  input: unknown,
  resolveMentionForSender: ResolveMentionForSender,
): Promise<RichNode> {
  let visited = 0;
  let characters = 0;
  const allowedTypes = new Set(["doc", "paragraph", "text", "hardBreak", "mention"]);

  async function visit(value: unknown, depth: number): Promise<RichNode> {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      throw new Error("Invalid rich-text node");
    }
    if (depth > 12 || ++visited > 500) throw new Error("Document is too large");

    const node = value as Record<string, unknown>;
    if (typeof node.type !== "string" || !allowedTypes.has(node.type)) {
      throw new Error("Unsupported rich-text node");
    }

    const nodeText = typeof node.text === "string" ? node.text : undefined;
    characters += nodeText?.length ?? 0;
    if (characters > 8000) throw new Error("Document text is too long");

    const marks = Array.isArray(node.marks)
      ? node.marks.map((candidate): RichMark => {
          if (!candidate || typeof candidate !== "object") throw new Error("Invalid mark");
          const mark = candidate as Record<string, unknown>;
          if (mark.type !== "bold" && mark.type !== "italic" && mark.type !== "link") {
            throw new Error("Unsupported mark");
          }
          const href = (mark.attrs as { href?: unknown } | undefined)?.href;
          if (mark.type === "link") {
            if (typeof href !== "string") throw new Error("Link is missing an address");
            validateLink(href);
          }
          return { type: mark.type, attrs: typeof href === "string" ? { href } : undefined };
        })
      : undefined;

    const attrs = node.attrs as Record<string, unknown> | undefined;
    if (node.type === "mention" && typeof attrs?.id !== "string") {
      throw new Error("Mention is missing a user ID");
    }

    const mention = node.type === "mention" && typeof attrs?.id === "string"
      ? await resolveMentionForSender(attrs.id)
      : null;
    if (node.type === "mention" && !mention) {
      throw new Error("Mention is not available to this sender");
    }

    const content = Array.isArray(node.content)
      ? await Promise.all(node.content.map((child) => visit(child, depth + 1)))
      : undefined;
    const normalized: RichNode = {
      type: node.type as RichNode["type"],
      text: nodeText,
      attrs: mention
        ? { id: mention.id, label: mention.displayLabel }
        : undefined,
      marks,
      content,
    };

    return normalized;
  }

  const document = await visit(input, 0);
  if (document.type !== "doc") throw new Error("Rich text must start with a document node");
  return document;
}

Call validateRichDocument in the authenticated API handler. Bind resolveMentionForSender to the session user and return a target only when that sender can see and mention them. The resolver's display label replaces client-provided text, so a caller cannot spoof a name. Mention IDs are authorization identities; labels are display text and may change. Sanitize links again when rendering and never execute embedded scripts or event attributes.

Bridge Mention State Between TipTap and Native UI

The TipTap suggestion plugin detects the query inside the WebView. It publishes only the current query and replacement range to React Native. The native list searches people and sends the selected stable user ID back through the bridge.

// editor/mention-bridge.ts
import { BridgeExtension } from "@10play/tentap-editor";
import type { Editor } from "@tiptap/core";
import Mention, { type MentionOptions } from "@tiptap/extension-mention";

export type MentionCandidate = {
  id: string;
  displayName: string;
  handle: string;
};

type MentionRange = { from: number; to: number };
type MentionBridgeState = {
  mentionQuery: string | null;
  mentionRange: MentionRange | null;
};
type MentionBridgeCommands = {
  insertMention(person: MentionCandidate, range: MentionRange): void;
};
type MentionBridgeMessage = {
  type: "mention.insert";
  payload: { id: string; label: string; from: number; to: number };
};
type MentionStorage = {
  active: boolean;
  query: string;
  from: number | null;
  to: number | null;
};

declare module "@10play/tentap-editor" {
  interface BridgeState extends MentionBridgeState {}
  interface EditorBridge extends MentionBridgeCommands {}
}

const MentionNode = Mention.extend({
  addStorage(): MentionStorage {
    return { active: false, query: "", from: null, to: null };
  },
}).configure({
  HTMLAttributes: { class: "mention" },
  renderHTML({ node }) {
    return [
      "span",
      {
        "data-type": "mention",
        "data-id": String(node.attrs.id),
        "data-label": String(node.attrs.label),
        class: "mention",
      },
      `@${String(node.attrs.label)}`,
    ];
  },
  suggestion: {
    char: "@",
    allowSpaces: true,
    items: () => [],
    command: () => undefined,
    render: () => ({
      onStart: ({ editor, query, range }) => {
        const state = (editor.storage as { mention: MentionStorage }).mention;
        Object.assign(state, { active: true, query, from: range.from, to: range.to });
      },
      onUpdate: ({ editor, query, range }) => {
        const state = (editor.storage as { mention: MentionStorage }).mention;
        Object.assign(state, { active: true, query, from: range.from, to: range.to });
      },
      onExit: ({ editor }) => {
        const state = (editor.storage as { mention: MentionStorage }).mention;
        state.active = false;
      },
      onKeyDown: () => false,
    }),
  },
} as Partial<MentionOptions>);

export const ChatMentionBridge = new BridgeExtension<
  MentionBridgeState,
  MentionBridgeCommands,
  MentionBridgeMessage
>({
  tiptapExtension: MentionNode,
  extendEditorState: (editor: Editor) => {
    const state = (editor.storage as { mention?: MentionStorage }).mention;
    const range =
      state?.from !== null && state?.to !== null && state?.from !== undefined && state?.to !== undefined
        ? { from: state.from, to: state.to }
        : null;
    return { mentionQuery: state?.active ? state.query : null, mentionRange: range };
  },
  extendEditorInstance: (send) => ({
    insertMention: (person, range) => {
      send({
        type: "mention.insert",
        payload: {
          id: person.id,
          label: person.displayName,
          from: range.from,
          to: range.to,
        },
      });
    },
  }),
  onBridgeMessage: (editor: Editor, message) => {
    if (message.type !== "mention.insert") return false;
    const { id, label, from, to } = message.payload;
    editor
      .chain()
      .focus()
      .insertContentAt({ from, to }, [
        { type: "mention", attrs: { id, label } },
        { type: "text", text: " " },
      ])
      .run();

    const state = (editor.storage as { mention: MentionStorage }).mention;
    Object.assign(state, { active: false, query: "", from: null, to: null });
    return true;
  },
});

The custom editor bundle must include the same ChatMentionBridge in its web-side bridge list. Build the bundle into a local string and pass that string as customSource; do not point production at a development server.

Create the Editor Bridge and Keep Web Traffic Bounded

Subscribe to debounced text or JSON rather than calling getJSON() on every keystroke. dynamicHeight is useful for a compact composer; avoidIosKeyboard is useful for a full-screen editor. Test both platforms because the two options solve different layouts.

// hooks/use-rich-composer.ts
import {
  CoreBridge,
  PlaceholderBridge,
  TenTapStartKit,
  useEditorBridge,
  useEditorContent,
} from "@10play/tentap-editor";
import { ChatMentionBridge } from "../editor/mention-bridge";
import { editorHtml } from "../editor-web/build/editorHtml";

const composerCSS = `
  :root { color-scheme: light dark; }
  body { margin: 0; padding: 8px 10px; }
  .ProseMirror { min-height: 28px; outline: none; }
  .mention { font-weight: 600; }
`;

export function useRichComposer(placeholder: string) {
  const editor = useEditorBridge({
    autofocus: false,
    avoidIosKeyboard: false,
    dynamicHeight: true,
    initialContent: { type: "doc", content: [{ type: "paragraph" }] },
    customSource: editorHtml,
    bridgeExtensions: [
      ...TenTapStartKit,
      PlaceholderBridge.configureExtension({ placeholder }),
      CoreBridge.configureCSS(composerCSS),
      ChatMentionBridge,
    ],
  });

  const text = useEditorContent(editor, {
    type: "text",
    debounceInterval: 80,
  });

  return {
    editor,
    plainText: typeof text === "string" ? text : "",
  };
}

TenTap advanced setup builds the web editor with a browser bundler and aliases TenTap to its web exports. Add that build to CI so the bundled editorHtml cannot silently fall behind the native bridge code.

Compose With Native Mention Results and Error Recovery

Keep network search, visible errors, attachment state, and send state in React Native. The WebView receives only the selected mention and the content it edits.

import { useCallback, useMemo, useState } from "react";
import {
  FlatList,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import {
  RichText,
  useBridgeState,
} from "@10play/tentap-editor";
import type { MentionCandidate } from "../editor/mention-bridge";
import { useRichComposer } from "../hooks/use-rich-composer";

type ComposerPalette = {
  surface: string;
  border: string;
  foreground: string;
  mutedForeground: string;
  primary: string;
  primaryForeground: string;
  focus: string;
  danger: string;
};

export function RichMessageComposer({
  people,
  palette,
  uploadsPending,
  onSend,
}: {
  people: readonly MentionCandidate[];
  palette: ComposerPalette;
  uploadsPending: boolean;
  onSend(untrustedDocument: unknown): Promise<void>;
}) {
  const { editor, plainText } = useRichComposer("Write a message");
  const { mentionQuery, mentionRange, isReady, isFocused } = useBridgeState(editor);
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const suggestions = useMemo(() => {
    if (mentionQuery === null) return [];
    const query = mentionQuery.toLocaleLowerCase();
    return people
      .filter((person) =>
        `${person.displayName} ${person.handle}`.toLocaleLowerCase().includes(query),
      )
      .slice(0, 8);
  }, [mentionQuery, people]);

  const renderSuggestion = useCallback(
    ({ item }: { item: MentionCandidate }) => (
      <Pressable
        accessibilityLabel={`Mention ${item.displayName}, at ${item.handle}`}
        accessibilityRole="button"
        onPress={() => {
          if (mentionRange) editor.insertMention(item, mentionRange);
        }}
        style={composerStyles.suggestion}
      >
        <Text numberOfLines={1} style={{ color: palette.foreground, fontWeight: "600" }}>
          {item.displayName}
        </Text>
        <Text numberOfLines={1} style={{ color: palette.mutedForeground }}>
          @{item.handle}
        </Text>
      </Pressable>
    ),
    [editor, mentionRange, palette.foreground, palette.mutedForeground],
  );

  const canSend = isReady && plainText.trim().length > 0 && !uploadsPending && !sending;

  async function submit() {
    if (!canSend) return;
    setSending(true);
    setError(null);
    try {
      await onSend(await editor.getJSON());
      editor.setContent({ type: "doc", content: [{ type: "paragraph" }] });
    } catch {
      setError("Message not sent. Your draft is still here. Try again.");
    } finally {
      setSending(false);
    }
  }

  return (
    <View style={[composerStyles.shell, { backgroundColor: palette.surface, borderColor: palette.border }]}>
      {mentionQuery !== null && suggestions.length > 0 ? (
        <FlatList
          accessibilityLabel="Mention suggestions"
          data={suggestions}
          keyboardShouldPersistTaps="always"
          keyExtractor={(person) => person.id}
          renderItem={renderSuggestion}
          style={[composerStyles.suggestions, { backgroundColor: palette.surface, borderColor: palette.border }]}
        />
      ) : null}

      <View style={composerStyles.row}>
        <View style={[composerStyles.editor, { borderColor: isFocused ? palette.focus : palette.border }]}>
          <RichText
            accessibilityLabel="Message"
            editor={editor}
            scrollEnabled
            style={composerStyles.webview}
          />
        </View>
        <Pressable
          accessibilityLabel={sending ? "Sending message" : "Send message"}
          accessibilityRole="button"
          accessibilityState={{ busy: sending, disabled: !canSend }}
          disabled={!canSend}
          onPress={submit}
          style={[composerStyles.send, { backgroundColor: palette.primary }, !canSend ? composerStyles.disabled : null]}
        >
          <Text style={{ color: palette.primaryForeground, fontWeight: "700" }}>
            {sending ? "Sending" : "Send"}
          </Text>
        </Pressable>
      </View>

      {error ? (
        <Text accessibilityLiveRegion="polite" style={{ color: palette.danger }}>
          {error}
        </Text>
      ) : null}
    </View>
  );
}

const composerStyles = StyleSheet.create({
  shell: { position: "relative", gap: 6, borderTopWidth: StyleSheet.hairlineWidth, padding: 8 },
  row: { flexDirection: "row", alignItems: "flex-end", gap: 8 },
  editor: { flex: 1, minHeight: 44, maxHeight: 160, overflow: "hidden", borderWidth: 1, borderRadius: 18, borderCurve: "continuous" },
  webview: { minHeight: 42, maxHeight: 158 },
  send: { minWidth: 64, minHeight: 44, alignItems: "center", justifyContent: "center", borderRadius: 18, borderCurve: "continuous" },
  disabled: { opacity: 0.45 },
  suggestions: { position: "absolute", left: 8, right: 8, bottom: 62, maxHeight: 220, borderWidth: 1, borderRadius: 16, borderCurve: "continuous", zIndex: 10 },
  suggestion: { minHeight: 52, justifyContent: "center", gap: 2, paddingHorizontal: 12 },
});

Wrap the screen, not each row, in the app's keyboard-aware container. If a custom onMessage is passed to RichText, set exclusivelyUseCustomOnMessage={false} so TenTap still receives its own bridge messages.

Convert Pasted Data Images Into Native Attachments

Do not keep a base64 image inside the rich document. It inflates bridge traffic, storage, and message payloads. Detect embedded data images from the debounced JSON, remove them from the editor, then enqueue native uploads.

import { useEffect, useRef } from "react";
import {
  type EditorBridge,
  useEditorContent,
} from "@10play/tentap-editor";
import type { JSONContent } from "@tiptap/core";

function collectDataImages(node: JSONContent, output: string[]): void {
  if (node.type === "image" && typeof node.attrs?.src === "string" && node.attrs.src.startsWith("data:image/")) {
    output.push(node.attrs.src);
  }
  for (const child of node.content ?? []) collectDataImages(child, output);
}

function removeQueuedDataImages(
  node: JSONContent,
  queuedDataUrls: ReadonlySet<string>,
): JSONContent | null {
  const src = typeof node.attrs?.src === "string" ? node.attrs.src : null;
  if (node.type === "image" && src?.startsWith("data:image/") && queuedDataUrls.has(src)) {
    return null;
  }
  return {
    ...node,
    content: node.content
      ?.map((child) => removeQueuedDataImages(child, queuedDataUrls))
      .filter((child): child is JSONContent => child !== null),
  };
}

export function useEmbeddedImageUploads(
  editor: EditorBridge,
  enqueueUpload: (dataUrl: string) => Promise<void>,
  onFailure: (message: string) => void,
) {
  const document = useEditorContent(editor, { type: "json", debounceInterval: 100 });
  const processing = useRef(false);

  useEffect(() => {
    if (!document || typeof document !== "object" || processing.current) return;
    const root = document as JSONContent;
    const images: string[] = [];
    collectDataImages(root, images);
    if (images.length === 0) return;

    processing.current = true;
    void (async () => {
      try {
        await Promise.all(images.map(enqueueUpload));

        // Rebase cleanup on the current editor state so text, remote images,
        // and new attachments added while uploads ran are preserved.
        const latest = await editor.getJSON();
        if (!latest || typeof latest !== "object") return;
        const cleaned = removeQueuedDataImages(
          latest as JSONContent,
          new Set(images),
        );
        if (cleaned) editor.setContent(cleaned);
      } catch {
        onFailure("A pasted image could not be prepared. Try pasting it again.");
      } finally {
        processing.current = false;
      }
    })();
  }, [document, editor, enqueueUpload, onFailure]);
}

enqueueUpload should first write a durable pending attachment using a content hash as its ID, then start the remote upload. That makes a repeated paste event idempotent. Limit accepted image types and decoded size before upload. Show each upload as a native pending attachment with progress, retry, and remove controls. Disable Send until every attachment is uploaded or removed.

Rich Composer Invariants

  • Build the custom web editor in CI and package it locally in the app binary.
  • Pass only typed commands and state through the bridge.
  • Debounce content subscriptions to limit WebView traffic.
  • Keep mention search and suggestion UI native so keyboard taps remain reliable.
  • Persist drafts before navigation or process termination if losing them would be costly.
  • Validate the JSON document on the server and render only known nodes.
  • Remove base64 images from the document and upload them as attachments.
  • Keep the submitted document in place when sending fails.
  • Use a native TextInput when structured rich text is not a real product requirement.

Sources

The editor architecture follows current TenTap documentation for useEditorBridge, bridge extensions, useEditorContent, and advanced local bundles. WebView safety boundaries follow the React Native WebView guide. All code is independently written.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "rich-text-composer-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems