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:
| Requirement | Recommended path |
|---|---|
| Plain text, attachments, replies | Native multiline TextInput |
| Standard bold, lists, and links | TenTap simple editor |
| Structured mentions or custom nodes | TenTap 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
TextInputwhen 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.