#
TODO: NIP-17 Read Receipts & Chat Liveness
Status: Not started References:
- PR #2000 — Mark as Received (vitorpamplona, open)
- PR #1761 — Seen events / bloom filter (kehiy, open)
- PR #1994 — "seen" event (fiatjaf, closed)
- PR #1405 — Read status 2 (staab, closed — removed from Coracle)
- Issue #2002 — Liveness pulse
#
Overview
There are three distinct concerns for message-delivery feedback in NIP-17 DMs. Each has a different privacy/complexity trade-off and should be implemented separately.
#
1. "Received" heartbeat — kind 10017
#
What it does
A public replaceable event (kind 10017) updated every time the NIP-17
client decrypts a new message batch. Contains no message IDs and no content —
just a created_at timestamp. Counterparties check: "is their 10017
timestamp newer than my last sent message?" If yes → message was likely
received.
#
Event shape
{
"kind": 10017,
"pubkey": "<my-pubkey>",
"created_at": "<now, slightly randomized ±30s>",
"tags": [],
"content": ""
}
#
Implementation plan
Create
ReceivedHeartbeatservice inusecase/messaging/received_heartbeat.dart.- Inject
Ndk,Auth,Requests. - On each
Threads.processMessage()call (or debounced after a batch), publish/replace akind 10017event signed by the active keypair. - Randomise
created_atby up to ±30 seconds to limit metadata leakage (as discussed in PR #2000).
- Inject
Query counterparty heartbeats in
Thread:- When opening a thread, subscribe to
kind 10017from the counterparty pubkey. - Expose a
Stream<DateTime?> lastReceivedAtonThread. - In the UI, compare each outgoing message's
created_atagainstlastReceivedAt. IflastReceivedAt >= message.created_at→ show a single-check (✓) "delivered" indicator.
- When opening a thread, subscribe to
Debounce publishing — don't publish a new
10017on every single message decrypt. Debounce to ~5 seconds so that catching up on 100 messages produces one event, not 100.Files to create/modify:
usecase/messaging/received_heartbeat.dart(new — service)usecase/messaging/threads.dart(call heartbeat after processing)usecase/messaging/thread/thread.dart(subscribe to counterparty's 10017)datasources/— add filter/kind constantkNostrKindReceivedHeartbeat = 10017
#
2. "Seen" status — private, per-conversation
#
What it does
Tells the counterparty which specific messages you have actually opened/read (double-check ✓✓). Two sub-approaches are viable:
#
Option A: Timestamp-based (recommended — simplest)
Store a single timestamp per conversation = created_at of the most recent
message you've scrolled to / rendered on screen. Everything with
created_at <= that timestamp is "seen." Send this inside a NIP-17 gift-wrapped
event to the counterparty.
Pros: Simple, tiny payload, no bloom-filter complexity. Cons: Late-arriving or out-of-order messages may be incorrectly marked seen. staab found this acceptable in practice for 1:1 chats.
// Rumor (unsigned, inside gift-wrap)
{
"kind": 16,
"tags": [
["p", "<counterparty-pubkey>"],
["seen_until", "<unix-timestamp-of-last-seen-message>"]
],
"content": ""
}
#
Option B: Bloom filter on replaceable event (PR #1761)
A kind 30010 addressable event whose .content is a bloom filter encoding
seen gift-wrap IDs. The d tag is derived as
sha256(hkdf(private_key, salt: 'nip17') || "<counterparty-pubkey>") to hide
the conversation counterparty.
Pros: Per-message granularity, public but obfuscated, replaceable. Cons: Must spec bloom filter exactly (hash function, encoding, salt, size). False positives (message falsely shown as seen) are possible but benign.
#
Implementation plan (Option A — timestamp-based)
Create
SeenStatusservice inusecase/messaging/seen_status.dart.- Track
Map<String, int> lastSeenTimestampByThread(thread ID → unix ts). - When the user scrolls to / views a message in the UI, update the timestamp for that thread.
- Track
Broadcast seen timestamp to counterparty:
- Debounce (e.g. 3 seconds after last scroll).
- Create a
kind 16rumor with aseen_untiltag. - Gift-wrap and send via the same
Messaging.broadcastTextpath to the counterparty's10050relays. - Set a short
expirationtag on the gift wrap (e.g. 7 days) to avoid relay bloat.
Receive and display counterparty's seen status:
- In
Thread, listen for incomingkind 16events withseen_untiltag. - Expose
Stream<int?> counterpartySeenUntilonThread. - UI: for each outgoing message, if
message.created_at <= counterpartySeenUntil→ show double-check ✓✓.
- In
Cross-client sync (own devices):
Optionally publish a private replaceable event (
kind 30010or similar) encrypted to yourself, storinglastSeenTimestampByThread.This lets a second client (e.g. desktop) pick up where mobile left off without re-marking everything as unread.
Files to create/modify:
usecase/messaging/seen_status.dart(new — service)usecase/messaging/thread/thread.dart(expose seen stream, process incoming seen events)usecase/messaging/thread/state.dart(add seen-until to thread state)- App UI layer — message bubbles need ✓ / ✓✓ indicators
#
3. "Typing" / liveness indicator — kind 10018 (ephemeral)
#
What it does
A public, short-lived replaceable event indicating the user is currently typing
in a specific chat room. The room is identified by a bloom filter of recent
message IDs (so it doesn't leak which conversation).
#
Event shape (per vitorpamplona's proposal)
{
"kind": 10018,
"pubkey": "<my-pubkey>",
"created_at": "<now>",
"tags": [
["room", "<bloom-filter-of-last-3-message-ids>"],
["expiration", "<now + 15 seconds>"]
],
"content": ""
}
Receiving clients check if the IDs of the 3 most recent kind 14 messages in
the current thread are inside the bloom filter. If yes → show "typing…".
#
Implementation plan
Create
TypingIndicatorservice inusecase/messaging/typing_indicator.dart.- Expose
void startTyping(Thread thread)/void stopTyping(). - On
startTyping, publish akind 10018with a 15-second expiration. - Re-publish every ~10 seconds while the user continues typing.
- On
stopTyping(or after 15s idle), stop publishing.
- Expose
Bloom filter for room identification:
- Take the last 3 message IDs from the thread.
- Build a small bloom filter (≤256 bits, 3 hash rounds, random salt).
- Encode as
size:rounds:base64(bits):base64(salt)in theroomtag.
Subscribe to counterparty typing events:
- In
Thread, subscribe tokind 10018from the counterparty pubkey. - On receive, check if your last 3 thread message IDs appear in the bloom
filter → if yes, set
isCounterpartyTyping = true. - Auto-expire after 15 seconds with no new event.
- In
Privacy considerations:
- This is opt-in. Users should be able to disable it in settings.
- The bloom filter prevents leaking exact conversation identity to relay operators while still allowing the counterparty to match.
Files to create/modify:
usecase/messaging/typing_indicator.dart(new)util/bloom_filter.dart(new — shared bloom filter implementation)usecase/messaging/thread/thread.dart(subscribe, expose typing stream)
#
Key Constants to Add
In models or datasources, define:
const kNostrKindReceivedHeartbeat = 10017;
const kNostrKindTypingIndicator = 10018;
const kNostrKindSeenMessages = 30010;
const kNostrKindSeenStatus = 16; // rumor kind inside gift-wrap
#
Risks & Open Questions
No merged NIP yet. All proposals are still open PRs. Kind numbers and event shapes may change. Build behind a feature flag.
Gift-wrap bloat. Seen events sent as gift wraps are non-replaceable on relays. Always set
expirationtags to limit storage.Group chats. The timestamp approach works well for 1:1 DMs (Hostr's primary use case for host↔guest messaging). For groups >2, bloom filters or per-member seen events are needed — defer until needed.
staab's lesson. Enumerating individual event IDs in persistent events caused "notification badge whack-a-mole" due to late-arriving events. Timestamp-based is more forgiving. Don't store full ID lists.
Privacy. The
10017heartbeat is public — anyone can see when you last fetched DMs. Randomisecreated_atto limit precision.