ReplyKit
  • 01Radar
  • 02초안
  • 04요금
  • 05자주 묻는 질문
웹 앱Chrome에 추가 →
ReplyKit

답글 달 만한 게시물을 찾아서 내 말투로 답글 세 개를 써 줘요. 게시는 언제나 직접 해요.

ReplyKit on PeerPushReplyKit - Featured on Startup Fame

제품

  • Chrome Web Store
  • 모바일 웹 앱
  • 요금
  • 자주 묻는 질문
  • 비교
  • 변경 내역
  • 블로그

회사

  • 개인정보 처리방침
  • contact@replykit.eu
  • @FredericTheTall
  • 만든 사람: Frédéric Legrand

© 2026 ReplyKit · 파리

X, LinkedIn, Reddit과 제휴 관계가 없어요

블로그 · engineering

2026년 10월 17일9분 분량이 글은 영어로 작성됐어요.

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.

작성자

Frédéric Legrand는 컴퓨터 사용 에이전트를 만드는 본업과 함께 ReplyKit을 만들고 있어요. @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.

만든 사람 · 한 사람

ReplyKit은 1인 프로젝트예요. 필요한 걸 알려 주세요.

뒤에 회사는 없어요. 메시지를 하나하나 다 읽는 개발자 한 명뿐이에요. ReplyKit이 내 작업 방식과 안 맞는다면 기능이든 플랫폼이든 다른 답글 스타일이든 말해 주세요. 다음에 뭘 내놓을지는 사용자 요청으로 정해져요.

이메일이 편한가요? contact@replykit.eu

대기열이 아니라 제 휴대폰에 Telegram 메시지로 바로 와요.

← 전체 글Chrome에 무료로 추가 →