Repository navigation
feat(messages): typing indicator and message editing - #6891
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
rebelchris
left a comment
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
Non-blocking (worth fixing before merge): a rejected edit only puts the thread back.
useEditMessagepatches the inbox row too, throughpatchDmMessage. This branch only invalidatesdmThreadQueryKey, so if the edited message is the latest one, the inbox preview keeps the rejected text until something else refetches the list.- A server bounce here is almost always the same cause as
reactionRejected: the peer blocked you or turned DMs off. That branch showspeerUnavailableCopyand 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) => { |
There was a problem hiding this comment.
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 sendspausedthroughgetDmTransport(user.id)with theuserit captured. If that runs afteruseMessagesEnabledhas calledcloseDmTransports()(e.g. logging out within 4s of typing),getDmTransportcreates 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 && ( |
There was a problem hiding this comment.
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', |
There was a problem hiding this comment.
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 = () => { |
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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.
What
Two direct-message features, both client-only XMPP (no daily-api or skirnir change):
Typing indicator (XEP-0085 chat states)
composingwhen the draft gets text, repeats it every 5s while typing continues, andpaused4s after the last keystroke, when the draft is cleared, or when leaving the thread. Sending the message ends it.paused, on the peer's message, or after 10s without a repeat, so a peer whose tab closed never looks busy forever.dmTypingQueryOptions, never fetched, reset by the reconnect invalidation).Edit messages (XEP-0308 last message correction)
(edited)with the edit time on hover.<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.applyEdit), so a peer can't rewrite your bubbles. A correction whose original is outside the loaded page is dropped with it.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.Mock mode (
NEXT_PUBLIC_CHAT_MOCK=true): the canned reply now "types" for ~1s first, and edits persist.Decisions worth a look
<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 callmarkDirectMessageSent, 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.🤖 Generated with Claude Code
Preview domain
https://feat-messages-typing-and-edit.preview.app.daily.dev