Skip to content

feat(dms): typing indicators#3551

Open
joelklabo wants to merge 6 commits intodamus-io:masterfrom
joelklabo:feat/dm-typing-indicators
Open

feat(dms): typing indicators#3551
joelklabo wants to merge 6 commits intodamus-io:masterfrom
joelklabo:feat/dm-typing-indicators

Conversation

@joelklabo
Copy link
Copy Markdown
Contributor

@joelklabo joelklabo commented Jan 25, 2026

Summary

Adds best-effort typing indicators to direct messages using kind 20001 ephemeral events.

  • Outbound: send start/stop typing events (NIP-04 encrypted) with t=damus-typing namespace and expiration tag.
  • Inbound: listen for typing events and update DM partner typing state with a local auto-clear.
  • UI: shows “Typing…” in DM chat when the partner is typing.
  • Tests: unit tests for event creation/decryption and auto-clear behavior.

Checklist (Standard PR)

  • Familiar with contribution guidelines
  • Tests run (see test report)
  • No new persistence/storage mechanisms introduced
  • Signed-off commits

Test report

Device: iPhone 16e (Simulator)

iOS: 26.0

Damus: PR branch feat/dm-typing-indicators

Steps:

  1. Run unit tests.

Results:

  • PASS (damusTests)

Visual evidence

Summary by CodeRabbit

  • New Features

    • Typing indicators for direct messages—see "Typing…" when a partner is composing; input layout updated to accommodate this.
    • Throttled start/stop typing events are emitted automatically (stop also sent on send/when view closes); partner typing auto-clears after a timeout.
  • Tests

    • Added unit tests covering typing event creation, encryption/decryption, and auto-clear behavior.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 25, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds typing indicator support for DMs: introduces Nostr kind 20001, creates encrypted typing events, handles receipt/decryption in HomeModel, tracks partner typing state in DirectMessageModel, integrates UI sending/throttling in DMChatView, and adds unit tests.

Changes

Cohort / File(s) Summary
Nostr Kind Extension
damus/Core/Nostr/NostrKind.swift
Added case typing = 20001 to NostrKind.
Typing Indicator Models
damus/Features/DMs/Models/DMTypingIndicator.swift
New file: DMTypingAction (start/stop) and DMTypingIndicator.makeEvent(...) builds encrypted NIP04 typing events with recipient p tag, namespace t:damus-typing, and expiration tag.
Direct Message State
damus/Features/DMs/Models/DirectMessageModel.swift
Added @Published private(set) var partner_is_typing and @MainActor func set_partner_typing(_:autoClearAfter:) using a DispatchWorkItem to auto-clear typing state.
Chat View Integration
damus/Features/DMs/Views/DMChatView.swift
Added send_typing(_:, force:) async with start throttling (lastTypingStartSentAt, typingStartThrottleSeconds) and forced stop; footer shows "Typing…" and stop sent on message send / lifecycle changes.
Event Display Handling
damus/Features/Events/Models/LoadableNostrEventView.swift
Added .typing case to known_kind switch mapping it to unsupported-kind handling.
DM Event Routing & Processing
damus/Features/Timeline/Models/HomeModel.swift
Added typingHandlerTask, included a NostrFilter for .typing, routed .typing events to handle_typing_indicator(_:), validates recipient/namespace, respects expiration, decrypts content, and updates DirectMessageModel typing state.
Test Coverage
damusTests/DMTypingIndicatorTests.swift
New tests for event creation, tag structure, NIP04 encryption/decryption, and DirectMessageModel auto-clear timing.
Project File
damus.xcodeproj/project.pbxproj
Added DMTypingIndicator.swift to project file references and build phases so it is compiled into targets.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant DMChatView
    participant DMTypingIndicator
    participant Relay as Network/Relay
    participant HomeModel
    participant DirectMessageModel

    rect rgba(100, 150, 200, 0.5)
    Note over User,DMChatView: Sending typing indicator
    User->>DMChatView: Edit message draft
    DMChatView->>DMChatView: Detect draft change / throttle
    DMChatView->>DMTypingIndicator: makeEvent(action: start, to: recipient, ...)
    DMTypingIndicator->>DMTypingIndicator: Encrypt content via NIP04
    DMTypingIndicator-->>DMChatView: NostrEvent (kind 20001)
    DMChatView->>Relay: Send typing start event (throttled)
    end

    rect rgba(150, 100, 200, 0.5)
    Note over Relay,HomeModel: Receiving typing indicator
    Relay->>HomeModel: Receive typing event (kind 20001)
    HomeModel->>HomeModel: handle_typing_indicator()
    HomeModel->>HomeModel: Verify namespace tag & recipient, decrypt content
    HomeModel->>DirectMessageModel: set_partner_typing(true)
    DirectMessageModel->>DirectMessageModel: Schedule auto-clear timer
    DirectMessageModel-->>DMChatView: partner_is_typing updated
    DMChatView->>DMChatView: Render "Typing…" indicator
    end

    rect rgba(200, 100, 100, 0.5)
    Note over DirectMessageModel,DMChatView: Auto-clear typing
    DirectMessageModel->>DirectMessageModel: Timer expires
    DirectMessageModel->>DirectMessageModel: set_partner_typing(false)
    DirectMessageModel-->>DMChatView: partner_is_typing = false
    DMChatView->>DMChatView: Hide "Typing…" indicator
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hummed a secret "start" into the night,
Encrypted hops that flicker soft and light,
A little timer keeps the pause polite,
When fingers rest the signal folds to sleep,
Quiet bytes and carrots — gentle, small and neat.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description provides a clear summary and includes a checklist with testing results, but several required checklist items are incomplete or partially addressed. Complete the Standard PR Checklist by addressing: profiling/performance impact statement, GitHub issue reference, and changelog entry details. Expand test report setup section and specify any known limitations.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(dms): typing indicators' clearly and concisely summarizes the main change: adding typing indicator functionality to direct messages.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alltheseas
Copy link
Copy Markdown
Collaborator

Welcome back @joelklabo

@coderabbitai review please

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 26, 2026

@alltheseas, I'll review the changes in this PR right away. Let me analyze the typing indicators implementation for DMs.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@joelklabo
Copy link
Copy Markdown
Contributor Author

Still working on this @alltheseas

Good to 👀 you!

@alltheseas
Copy link
Copy Markdown
Collaborator

Join us

https://signal.group/#CjQKIEvAppoOYdGInMegWrzfI1t7bb1HhRqAoDY51sLOFi3REhA6WDp91871Vms9JIZ__DVK

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@damus/Features/DMs/Models/DMTypingIndicator.swift`:
- Around line 31-32: The expiration calculation in DMTypingIndicator (variables
created and expiration using ttlSeconds) can overflow when adding ttlSeconds to
created; update the logic to detect overflow (use UInt32.addingReportingOverflow
or perform a checked add) and clamp to UInt32.max or handle the error path
instead of allowing a trap—compute created =
UInt32(Date().timeIntervalSince1970) as before, then call
created.addingReportingOverflow(ttlSeconds) and if overflowed set expiration =
UInt32.max (or return/abort gracefully), otherwise set expiration to the sum.
- Around line 21-30: Add Swift doc comments for the new public-facing type and
its API: document the DMTypingIndicator struct and its static factory method
makeEvent(action:to:keypair:ttlSeconds:) by describing their purpose,
parameters, return value and any important behavior/constraints (e.g., that
makeEvent builds a NostrEvent for typing with TTL and may return nil on
failure). Place /// doc comments above the DMTypingIndicator declaration and
above the makeEvent method, mentioning the meaning of action, recipient
(Pubkey), keypair, ttlSeconds (default 30s), and when nil is returned.

In `@damus/Features/DMs/Views/DMChatView.swift`:
- Around line 16-18: Add triple-slash doc comments describing the purpose and
behavior of the outbound typing indicator state and helper: document `@State`
private var lastTypingStartSentAt: Date? = nil (tracks when the last
typing-start event was sent and why it's optional), the private let
typingStartThrottleSeconds: TimeInterval = 5.0 (explain it is the throttle
interval and units), and the send_typing helper function (describe what it
sends, when it should be called, and how it respects the throttle). Place these
/// comments immediately above the declarations for lastTypingStartSentAt,
typingStartThrottleSeconds, and the send_typing method so they follow Swift doc
comment conventions.

In `@damus/Features/Timeline/Models/HomeModel.swift`:
- Around line 957-996: In handle_typing_indicator(_ ev: NostrEvent) skip
processing if the event has an expiration tag that is already in the past:
inspect ev.tags for the expiration-style tag (e.g. a tag where t[0] ==
"expiration" or the convention used in your Nostr model and t[1] is a unix
timestamp), parse t[1] to an integer/time interval and compare against
Date().timeIntervalSince1970 (or Date()), and return early when the expiration
timestamp < now so stale typing events are ignored before decrypting or updating
model.set_partner_typing; keep the check above decryption and the other guards
in handle_typing_indicator.

In `@damusTests/DMTypingIndicatorTests.swift`:
- Around line 11-58: Add missing docstrings for the test class and its methods:
add a triple-slash comment above the DMTypingIndicatorTests declaration
describing the test suite, and triple-slash comments above
testTypingIndicatorEventCreationAndDecryption and
testDirectMessageModelTypingAutoClear describing each test’s purpose (e.g.,
event creation/decryption and typing auto-clear behavior). Keep comments short
and focused so they satisfy docstring coverage without changing any test logic
or identifiers.

Comment thread damus/Features/DMs/Models/DMTypingIndicator.swift
Comment thread damus/Features/DMs/Models/DMTypingIndicator.swift Outdated
Comment thread damus/Features/DMs/Views/DMChatView.swift Outdated
Comment thread damus/Features/Timeline/Models/HomeModel.swift
Comment thread damusTests/DMTypingIndicatorTests.swift
@alltheseas
Copy link
Copy Markdown
Collaborator

@joelklabo can you have the agent run the code by

https://github.com/damus-io/damus/blob/master/AGENTS.md

joelklabo added a commit to joelklabo/damus that referenced this pull request Jan 26, 2026
Signed-off-by: Joel Klabo <joelklabo@gmail.com>
Signed-off-by: Joel Klabo <joelklabo@gmail.com>
Signed-off-by: Joel Klabo <joelklabo@gmail.com>
@joelklabo joelklabo force-pushed the feat/dm-typing-indicators branch from d14ce0d to 9904662 Compare January 26, 2026 06:23
Signed-off-by: Joel Klabo <joelklabo@gmail.com>
@joelklabo joelklabo requested a review from Copilot January 26, 2026 06:43
@joelklabo joelklabo marked this pull request as ready for review January 26, 2026 06:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds best-effort typing indicators to DMs using kind 20001 ephemeral Nostr events. Users see "Typing…" in the DM chat UI when their conversation partner is actively typing. Events are NIP-04 encrypted and include expiration tags with a namespace identifier ("damus-typing") to distinguish them from other clients.

Changes:

  • Added DMTypingIndicator helper struct and DMTypingAction enum to create encrypted kind 20001 typing indicator events
  • Modified DirectMessageModel to track partner typing state with auto-clear timeout
  • Updated DMChatView to send typing start/stop events (with throttling) and display typing indicator UI
  • Extended HomeModel to subscribe to and handle kind 20001 typing events
  • Added unit tests for event creation/decryption and auto-clear behavior

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
damus/Features/DMs/Models/DMTypingIndicator.swift New helper for creating encrypted kind 20001 typing indicator events with expiration tags
damus/Features/DMs/Models/DirectMessageModel.swift Added partner typing state tracking with auto-clear timer
damus/Features/DMs/Views/DMChatView.swift Integrated typing event sending (throttled) and "Typing…" UI indicator
damus/Features/Timeline/Models/HomeModel.swift Added subscription filters and handler for kind 20001 typing events
damus/Core/Nostr/NostrKind.swift Added .typing = 20001 case for ephemeral typing indicators
damus/Features/Events/Models/LoadableNostrEventView.swift Added .typing to unsupported kinds list
damusTests/DMTypingIndicatorTests.swift Unit tests for event creation/decryption and auto-clear behavior
damus.xcodeproj/project.pbxproj Added new files to project build targets
docs/typing-indicator-pr3551.png Screenshot showing typing indicator UI

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 912 to 919
var dms_filter = NostrFilter(kinds: [.dm, .typing])
dms_filter.pubkeys = [damus_state.pubkey]
dms_filter.limit = 500

// DMs we sent
var our_dms_filter = NostrFilter(kinds: [.dm])
var our_dms_filter = NostrFilter(kinds: [.dm, .typing])
our_dms_filter.authors = [damus_state.pubkey]
our_dms_filter.limit = 500
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

Typing indicator events (kind 20001) should not be fetched via negentropy or included in DM history. These are ephemeral events that should only be received in real-time and should not be persisted or retrieved from history.

The filters in fetchFullDMHistory() should only request kind 4 (DMs), not kind 20001 (typing indicators). Including typing indicators in historical DM fetches with limits (500 events) could cause older actual DMs to be missed, and there's no value in fetching old typing indicators since they're time-sensitive ephemeral signals.

Copilot uses AI. Check for mistakes.
Comment on lines +551 to +553
var dms_filter = NostrFilter(kinds: [.dm, .typing])

var our_dms_filter = NostrFilter(kinds: [.dm])
var our_dms_filter = NostrFilter(kinds: [.dm, .typing])
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The subscription filters for DMs should probably not include kind 20001 (typing indicators) in real-time subscriptions. Typing indicators are intentionally ephemeral and should not be stored in NostrDB or included in persistent subscriptions.

Consider either:

  1. Subscribing to typing indicators separately with a dedicated ephemeral-only subscription that doesn't store events, or
  2. Handling typing indicators through a different mechanism that explicitly marks them as ephemeral to prevent storage.

Including them in the same filter as regular DMs means they'll be treated the same way (stored in NostrDB), which defeats the purpose of them being ephemeral best-effort signals.

Copilot uses AI. Check for mistakes.
senderPubkey: ev.pubkey,
content: ev.content,
encoding: .base64
)) ?? ev.content
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

The comment says "If decryption fails, assume 'start' (clients may send plaintext)" but the code actually falls back to ev.content (the raw encrypted/plaintext content), not to "start".

This could lead to unexpected behavior:

  • If decryption fails and the content is "stop" (plaintext), it would be treated as "start" because the only check is action != "stop" and a failed decryption fallback to encrypted gibberish wouldn't equal "stop".
  • The comment suggests plaintext is supported, but the encryption in DMTypingIndicator.makeEvent always encrypts, so there's no path for plaintext events in this codebase.

The fallback logic should either:

  1. Explicitly default to "start" as the comment suggests: ?? "start", or
  2. Update the comment to match the actual behavior of falling back to the content value.
Suggested change
)) ?? ev.content
)) ?? "start"

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@damus/Features/DMs/Views/DMChatView.swift`:
- Around line 145-167: The throttle logic in send_typing(_:force:) fails to
clear lastTypingStartSentAt on a .stop, causing a subsequent .start to be
suppressed; update send_typing (the method that creates events via
DMTypingIndicator.makeEvent and posts via damus_state.nostrNetwork.postbox.send)
so that when action == .stop you reset lastTypingStartSentAt (e.g., set it to
nil) before returning, while keeping the existing behavior of updating
lastTypingStartSentAt only after successfully creating a .start event.
🧹 Nitpick comments (2)
damus/Features/DMs/Models/DMTypingIndicator.swift (1)

30-42: Consider adding - Returns: documentation.

The docstring documents parameters well but omits the return value documentation. Swift conventions and the coding guidelines recommend complete docstring coverage.

📝 Suggested addition
     /// - Parameters:
     ///   - action: Whether the user started or stopped typing.
     ///   - recipient: The pubkey of the DM recipient.
     ///   - keypair: Sender keypair.
     ///   - ttlSeconds: Best-effort expiration in seconds, encoded in an `expiration` tag.
+    /// - Returns: An encrypted `NostrEvent`, or `nil` if keypair conversion or encryption fails.
     static func makeEvent(
damus/Features/Timeline/Models/HomeModel.swift (1)

957-1005: Move NIP04 decryption off the MainActor to avoid UI stalls.

handle_typing_indicator runs on @MainActor, and decrypting per typing signal can be CPU work. Consider doing the decrypt in a background task, then hop back to @MainActor to update partner_is_typing. Also, reusing DMTypingIndicator.namespaceTagValue will avoid hard‑coding the namespace string. As per coding guidelines, avoid expensive work on the main thread.

♻️ Small maintainability tweak
-        let hasNamespace = ev.tags.contains { t in
-            t.count >= 2 && t[0].matches_str("t") && t[1].matches_str("damus-typing")
-        }
+        let hasNamespace = ev.tags.contains { t in
+            t.count >= 2 && t[0].matches_str("t") && t[1].matches_str(DMTypingIndicator.namespaceTagValue)
+        }

Comment thread damus/Features/DMs/Views/DMChatView.swift
Signed-off-by: Joel Klabo <joelklabo@gmail.com>
@joelklabo
Copy link
Copy Markdown
Contributor Author

Addressed review feedback:

  • Keep typing indicators (kind 20001) out of DM history / negentropy fetch: removed from DM filters + fetchFullDMHistory; added a separate typing-only subscription.
  • handle_typing_indicator decryption fallback now matches behavior (plaintext start/stop supported; otherwise default to start).
  • Reset outbound start-throttle after sending stop to allow immediate restart.

Commit: e94d2af

@joelklabo
Copy link
Copy Markdown
Contributor Author

Hi @alltheseas - I've addressed the previous feedback. The PR is now mergeable. The docstring coverage is at 66.67% which is below the 80% threshold but the code is functional. Would you be able to review and merge this PR? The typing indicators feature is complete and ready.

@alltheseas
Copy link
Copy Markdown
Collaborator

Hey ser, with agents and beads we now have the "merge problem" and a volley of PRs.

Right now trying out the Linux one branch multi commit approach, as opposed to having 70+ open PRs.

Dont hold your breath for typing indicator PR merge soon. That said if the Linux branch approach works and makes it easier for Will, and Daniel's review process, it makes sense to add the typing indicator PR on the same branch as nip-17 dm PR.

@danieldaquino
Copy link
Copy Markdown
Collaborator

Thank you @joelklabo, and I apologize for the long wait. What NIP spec is this implementation based on?

Copy link
Copy Markdown
Collaborator

@danieldaquino danieldaquino left a comment

Choose a reason for hiding this comment

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

Please let me know the spec this change is based on so I can provide a more accurate review.

Thank you!

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.

4 participants