ReplyKit
  • 01Radar
  • 02Entwürfe
  • 04Preise
  • 05FAQ
Web-AppZu Chrome hinzufügen →
ReplyKit

Findet die Posts, auf die sich eine Antwort lohnt, und entwirft drei Antworten in deinem Stil. Posten tust du sie selbst, immer.

ReplyKit on PeerPushReplyKit - Featured on Startup Fame

Produkt

  • Chrome Web Store
  • Mobile Web-App
  • Preise
  • FAQ
  • Vergleich
  • Changelog
  • Blog

Unternehmen

  • Datenschutzerklärung
  • contact@replykit.eu
  • @FredericTheTall
  • Entwickelt von Frédéric Legrand

© 2026 ReplyKit · Paris

Nicht verbunden mit X, LinkedIn oder Reddit

Blog · engineering

17. Okt. 20269 Min. LesezeitDieser Beitrag ist auf Englisch.

How a Chrome extension types into X's reply box

X's composer is Draft.js, and setting textContent breaks it. The three insertion paths ReplyKit uses, how it builds threads by clicking the +, and why it never streams into the editor.

Autor

Frédéric Legrand baut ReplyKit nebenher, neben seinem Hauptjob, in dem er Computer-Use-Agents entwickelt. @FredericTheTall

ReplyKit drafts a reply, you pick one, and it lands in the reply box on X ready to edit. That last step sounds like the easy part. It took more iterations than the prompting did, because X's composer is not a text field. It is a Draft.js editor, and Draft.js does not care what you put in its DOM.

This post is the state of that code as of October 2026. All of it lives in one file, src/adapters/x.ts, behind a small adapter interface so the rest of the extension never knows which site it is on.

What does not work

The obvious move is to find the editable node and set its text. The node is easy to find: X marks it with data-testid="tweetTextarea_0" and the Draft.js class public-DraftEditor-content. Then:

editor.textContent = draft;
editor.dispatchEvent(new Event("input", { bubbles: true }));

The text appears. The character counter stays at 0, the Post button stays disabled, and the next React render wipes it. Draft.js keeps its own model of the content and rebuilds the DOM from that model. What you wrote was never in the model.

So the job is not "put text in the DOM". It is "make Draft.js believe the user typed this". There are three ways to do that, and ReplyKit tries them in order.

Path 1: a synthetic paste

Draft.js listens for paste and reads clipboardData off the event. A content script can build that event itself. It does not need clipboard permission, because nothing touches the real clipboard; the DataTransfer is constructed in memory.

function pasteInto(editor: HTMLElement, text: string): boolean {
  try {
    const dt = new DataTransfer();
    dt.setData("text/plain", text);
    editor.focus();
    selectAllIn(editor); // replace, don't append
    return !editor.dispatchEvent(
      new ClipboardEvent("paste", {
        clipboardData: dt,
        bubbles: true,
        cancelable: true,
      })
    );
  } catch {
    return false; // no DataTransfer here (old engines, jsdom)
  }
}

Two details carry this function. The first is selecting the editor's contents before dispatching, so a second insert replaces the first draft instead of stacking under it. The second is the return value. dispatchEvent returns false when a listener called preventDefault(). Draft.js does exactly that when it consumes a paste. So a false means the editor took the text, and a true means nobody handled it and we have to fall back. No polling, no timeout.

This path is the good one. The counter updates, the Post button enables, and line breaks become real Draft.js blocks, which matters for multi-line posts. It works on every current Chromium build we have seen.

Path 2: execCommand("insertText")

document.execCommand is deprecated and still everywhere. insertText fires the beforeinput flow that Draft.js also listens to, so it is a second native route into the model.

function execInsert(editor: HTMLElement, text: string): boolean {
  if (typeof document.execCommand !== "function") return false;
  editor.focus();
  selectAllIn(editor);
  return (
    document.execCommand("insertText", false, text) &&
    (editor.textContent || "").includes(text.slice(0, 8))
  );
}

The return value of execCommand is not trustworthy on its own, so the function also checks that the first few characters actually landed. It is a fallback and in practice the paste path catches almost everything before this runs.

Path 3: rebuild the Draft.js DOM by hand

If neither event is consumed, the last resort is to write the DOM in the shape Draft.js expects: a data-contents wrapper, one data-block per line, each with the data-offset-key Draft.js uses to map DOM back to its model, then a burst of input, textInput and keyup events to make it look.

text.split("\n").forEach((line, i) => {
  const key = `${editorId}-${i}-0`;
  const block = el("div", { "data-block": "true", "data-offset-key": key });
  const inner = el("div", {
    "data-offset-key": key,
    class: "public-DraftStyleDefault-block public-DraftStyleDefault-ltr",
  });
  const span = el("span", { "data-offset-key": key });
  span.textContent = line;
  inner.append(span); block.append(inner); contents.append(block);
});

One block per line is the point of this path. A single text node would collapse a four-line post into one paragraph. Even so, this is the path we least want to run: the editor may or may not pick the content up into its model, and the next re-render decides. It exists so the Insert button does something visible when X changes its event handling, and the panel falls back to copying the text if even this fails.

Which reply box?

A single X page can hold three composers at once: the home box at the top of the feed, an inline reply box under a post, and the modal that opens when you click Reply. Each of them is tweetTextarea_0. Picking the first match inserts your reply into the wrong place about a third of the time.

ReplyKit mounts its panel next to the composer the user is working in, so the rule is: of all the editors on the page, take the one closest in the DOM to our own panel. When the reply modal is open, the panel inside the dialog wins, which is what you want.

Threads: clicking a button that is not there yet

A thread is several tweet boxes in one composer. You get a new one by clicking the "+" button. Three things about that button made this the slowest part to get right.

It does not exist until the previous box has content. X renders and enables the "+" after a React re-render that follows the insertion. Checking for it before inserting gives up too early. Clicking it once right after inserting often lands while it is still disabled. So the code inserts first, then polls: look for box i, and if it is not there and the composer still has only i boxes, click the "+" if it is enabled, sleep 150 ms, repeat for up to five seconds. The "only click while the box count is still i" guard is what prevents a slow render from producing two empty tweets.

It ignores click(). X's toolbar buttons act on pointerdown and mousedown, so they do not steal focus from the editor. A bare .click() dispatches only click, and nothing happens. The fix is to fire the whole sequence at the real interactive element:

function simulateClick(el: HTMLElement): void {
  const target = el.closest("button, [role=button]") ?? el;
  const opts = { bubbles: true, cancelable: true, view: window };
  target.dispatchEvent(new PointerEvent("pointerdown", opts));
  target.dispatchEvent(new MouseEvent("mousedown", opts));
  target.dispatchEvent(new PointerEvent("pointerup", opts));
  target.dispatchEvent(new MouseEvent("mouseup", opts));
  target.dispatchEvent(new MouseEvent("click", opts));
}

Its test id and label are not stable. data-testid="addButton" has been renamed before, and aria-label changes with the UI language. The SVG path of the plus icon has not changed, so as a last lookup the code finds the button by the first characters of that path's d attribute. It is an ugly selector and it has outlived the pretty ones.

When a step fails, the adapter logs one line listing every editor and button it can see in the composer and whether the plus glyph is present. X ships DOM changes without notice, and that one line has turned "threads are broken" into a five-minute fix more than once.

Why the stream never touches the editor

Drafts stream in token by token. The tempting demo is to watch them type themselves into X's reply box. We tried it and took it out.

A synthetic paste per chunk means dozens of paste events a second into an editor that re-renders on each one. It fights the user's cursor, it fills the undo stack with garbage, and it leaves X's own draft-saving in a strange state if the user navigates away mid-stream. It also means three drafts cannot stream at once, and three drafts per click is the product.

So the architecture is: the background service worker does the fetch (content scripts are subject to the page's CORS and CSP, the worker is not), parses the server-sent events, and relays the cumulative text to the tab with a requestId. The content script renders that into its own panel, a plain textarea it controls completely, and drops any chunk whose request id is not the current one so a fast second click cannot interleave two generations. The Draft.js code above runs exactly once, when the user picks a draft and presses Insert.

What I would tell someone starting this

  • Treat the editor as a black box with an event API. The synthetic paste is that API, and the dispatchEvent return value is the acknowledgement.
  • Never pick an editor by selector alone when a page can hold several. Pick by proximity to your own UI.
  • Buttons in React apps often act on pointer events, not click. If a programmatic click does nothing, send the full sequence before assuming the element is wrong.
  • Log the DOM you depended on at the moment of failure. The site will change, and the log is the only thing that tells you how.

The whole adapter, with the LinkedIn and Reddit ones beside it, is on GitHub. If you maintain an extension that writes into X and have hit a case this misses, I would like to hear about it.

Entwickelt von · Eine Person

ReplyKit ist ein Soloprojekt. Sag mir, was du brauchst.

Dahinter steht keine Firma, nur ein Entwickler, der jede Nachricht liest. Wenn ReplyKit nicht zu deiner Arbeitsweise passt, sei es ein Feature, eine Plattform oder ein anderer Antwortstil, sag es. Was als Nächstes kommt, entscheiden die Wünsche der Nutzer.

Lieber per E-Mail? contact@replykit.eu

Landet als Telegram-Nachricht auf meinem Handy, nicht in einer Warteschlange.

← Alle BeiträgeZu Chrome hinzufügen, kostenlos →