Skip to content

feat(messages): typing indicator and message editing - #6891

Merged
rebelchris merged 2 commits into
mainfrom
feat/messages-typing-and-edit
Oct 13, 2026
Merged

rebelchris merged 2 commits into
mainfrom
feat/messages-typing-and-edit

Conversation

@rebelchris

@rebelchris rebelchris commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

Two direct-message features, both client-only XMPP (no daily-api or skirnir change):

Typing indicator (XEP-0085 chat states)

  • The composer sends composing when the draft gets text, repeats it every 5s while typing continues, and paused 4s after the last keystroke, when the draft is cleared, or when leaving the thread. Sending the message ends it.
  • The thread shows a three-dot bubble under the newest message while the peer types. It clears on paused, on the peer's message, or after 10s without a repeat, so a peer whose tab closed never looks busy forever.
  • Standalone chat states are transient in ejabberd (not archived, not stored offline). Our own other sessions' states arrive as carbons and are ignored.
  • Typing state lives in the query cache (dmTypingQueryOptions, never fetched, reset by the reconnect invalidation).

Edit messages (XEP-0308 last message correction)

  • Hover a sent message of yours → pencil → inline editor (Enter saves, Esc cancels). Arrow up in an empty composer edits your last message. Edited messages show (edited) with the edit time on hover.
  • Wire: a new message carrying <replace id="original"/>. The archive keeps original and correction as separate items; loading folds them, keeping the original's time, reactions and comment reference. The inbox preview updates in place without reordering.
  • Only the sender's own message is changed (sender check in applyEdit), so a peer can't rewrite your bubbles. A correction whose original is outside the loaded page is dropped with it.
  • An edit is tracked through stream management like a message but is never reported via markDirectMessageSent, so it doesn't bump the peer's unread count or trigger a push. A bounce or missing ack reverts the optimistic copy from the archive with a toast.
  • Only the text is editable; attached images stay and are reattached in the composer's shape on save. Image-only / GIF-only messages have no edit button. The request intro note (lives in the API) can't be edited.

Mock mode (NEXT_PUBLIC_CHAT_MOCK=true): the canned reply now "types" for ~1s first, and edits persist.

Decisions worth a look

  • No time limit on edits and any own message (not just the last one, which XEP-0308 nominally restricts). Both ends are our client, so this is a product choice, easy to tighten.
  • The indicator is thread-only; the inbox rows don't show "typing…" yet.
  • The future ejabberd policy module must let bodyless <composing/>/<paused/> stanzas and <replace> messages through, same as it must for reactions.

Tests

  • stanzas.spec: corrections aren't read as new messages, sender guard, standalone-only chat states.
  • xmppTransport.spec: archive folding of corrections, edit acks don't call markDirectMessageSent, bounced edits, peer typing relayed but own carbons ignored.
  • ConversationThread.spec: indicator shows/hides and follows the bottom, inline edit keeps images, Escape cancels, (edited) marker.
  • shared messages suite (117), webapp suite (619), strict typecheck on changed files and full webapp tsc all green.

🤖 Generated with Claude Code

Preview domain

https://feat-messages-typing-and-edit.preview.app.daily.dev

Both are plain XMPP, so neither daily-api nor skirnir changes.

Typing uses XEP-0085 chat states. The composer sends `composing` when a
draft gets text, repeats it every 5s while typing goes on, and `paused`
4s after the last keystroke, on clearing the draft, or on leaving the
thread; the message itself ends it. The receiver shows a dots bubble
under the newest message, drops it on `paused`, on the peer's message,
or after 10s without a repeat, so a peer whose tab closed never looks
busy. Standalone chat states aren't archived or held offline by
ejabberd, and our own sessions' states arrive as carbons and are
ignored. The state lives in the query cache (never fetched, reset on
reconnect) so it survives thread remounts and needs no new store.

Editing uses XEP-0308 corrections: a new message with a `<replace>`
pointing at the original. Only the sender's own message is changed,
and a correction of a message outside the loaded page is dropped with
it, so a peer can't rewrite our bubbles and nothing shows up as a
duplicate. The archive stores the original and the correction as
separate items; loading folds them, keeping the original's time,
reactions and comment reference. An edit is tracked through stream
management like a message but never reported as sent, so it doesn't
bump the peer's unread count or push. The inline editor replaces the
bubble's text; images in the message stay as they are and are
reattached in the composer's shape on save. Arrow up in an empty
composer edits the viewer's last message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Oct 13, 2026 2:30pm UTC

Request Review

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is solid: archive folding, the sender guard in applyEdit, and keeping edits out of markDirectMessageSent all look correct, the tests cover them, and CI is green. I found no blockers. The inline comments are non-blocking. The most useful one is how a rejected edit is handled (inbox preview isn't restored, and access isn't refreshed after a bounce).

Reviewed by AI.

}

// The optimistic copy is wrong; the archive has what went through.
if (event.type === 'editRejected') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (worth fixing before merge): a rejected edit only puts the thread back.

  1. useEditMessage patches the inbox row too, through patchDmMessage. This branch only invalidates dmThreadQueryKey, so if the edited message is the latest one, the inbox preview keeps the rejected text until something else refetches the list.
  2. A server bounce here is almost always the same cause as reactionRejected: the peer blocked you or turned DMs off. That branch shows peerUnavailableCopy and invalidates the peer query so access updates. This one says "Try again" and leaves the editor available, so the user can keep retrying an edit that will always bounce.

Suggestion: invalidate dmConversationsQueryKey(user) here as well. For an error bounce, follow the reactionRejected handling (peer invalidation and the peer-unavailable copy), and keep the generic copy for the ack timeout. Adding a reason to the event would let you tell the two apart.

Reviewed by AI.


connection.send(stanza);
},
sendChatState: async (peer, state) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: sendChatState goes through ensureSession(), so a best-effort typing signal can open or reopen a connection.

  • When the reconnect cap is hit, every 5s of typing now starts a reconnect attempt. Before this, only opening messages or sending did that.
  • useTypingNotifier's unmount cleanup sends paused through getDmTransport(user.id) with the user it captured. If that runs after useMessagesEnabled has called closeDmTransports() (e.g. logging out within 4s of typing), getDmTransport creates a new transport for the signed-out user and asks for a token.

A chat state isn't worth a connection. Could it send only over a session that is already live, and do nothing otherwise?

Reviewed by AI.

</React.Fragment>
);
})}
{isPeerTyping && peer && (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the indicator renders whatever the thread's access is. The sender only gates on its own view of access, so a peer I've blocked (who can't see that) still sends composing, and I see them typing in a thread I've blocked. The same applies while the policy module isn't filtering. access === DmAccess.Allowed && isPeerTyping && peer would match how reactions and editing are gated here.

Reviewed by AI.

<span
key={delay}
className={classNames(
'size-1.5 animate-bounce rounded-full bg-text-tertiary',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the dots bounce forever for users with reduced motion turned on. motion-safe:animate-bounce (or adding motion-reduce:animate-none, as HighlightChip does) would respect that.

Reviewed by AI.

const trimmed = value.trim();
const canSave = !!trimmed && trimmed !== initialValue;

const resize = () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the autoresize and the Enter/isComposing keydown handling repeat MessageComposer's. The editor also loses the composer's emoji suggestions, so :smile works when sending but not when editing. Could the textarea behaviour be shared (a small hook, or reuse of useComposerEmojiSuggestions) so the two inputs don't drift apart?

Reviewed by AI.

};

// Shown on hover with a mouse; always there on touch screens.
const hoverActionClassName =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question (mobile): on touch devices this class is always visible, so every one of your own bubbles on a phone now shows a pencil next to the reaction button. Was this checked on a real phone, and is a permanent pencil per message the intended design there, rather than long-press or a menu? A phone screenshot of a thread with several own messages would settle it.

Reviewed by AI.

@rebelchris
rebelchris merged commit 4a0432d into main Oct 13, 2026
2 of 4 checks passed
@rebelchris
rebelchris deleted the feat/messages-typing-and-edit branch October 13, 2026 14:26

This branch was successfully deployed

1 active deployment
Preview — 42cbe6bf Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant