diff options
| -rw-r--r-- | docs/typing-indicators.md | 80 | ||||
| -rw-r--r-- | internal/handler/websocket/handler.go | 15 | ||||
| -rwxr-xr-x | muxstr | bin | 0 -> 27788215 bytes |
3 files changed, 95 insertions, 0 deletions
diff --git a/docs/typing-indicators.md b/docs/typing-indicators.md new file mode 100644 index 0000000..a85633d --- /dev/null +++ b/docs/typing-indicators.md | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | # Typing/Thinking Indicators | ||
| 2 | |||
| 3 | Ephemeral events for real-time activity feedback in Nostr conversations. | ||
| 4 | |||
| 5 | ## Event Kind | ||
| 6 | |||
| 7 | **Kind 20001** - Typing/Thinking Indicator (ephemeral) | ||
| 8 | |||
| 9 | Per NIP-01, kinds 20000-29999 are ephemeral and should not be stored by relays. | ||
| 10 | |||
| 11 | ## Event Structure | ||
| 12 | |||
| 13 | ```json | ||
| 14 | { | ||
| 15 | "kind": 20001, | ||
| 16 | "content": "<status>", | ||
| 17 | "tags": [ | ||
| 18 | ["p", "<recipient_pubkey>"], | ||
| 19 | ["e", "<event_id_being_replied_to>", "", "reply"] | ||
| 20 | ], | ||
| 21 | "created_at": <unix_timestamp>, | ||
| 22 | "pubkey": "<sender_pubkey>", | ||
| 23 | "id": "<event_id>", | ||
| 24 | "sig": "<signature>" | ||
| 25 | } | ||
| 26 | ``` | ||
| 27 | |||
| 28 | ### Content Values | ||
| 29 | |||
| 30 | | Value | Meaning | | ||
| 31 | |-------|---------| | ||
| 32 | | `typing` | User is composing a message | | ||
| 33 | | `thinking` | Agent is processing/generating | | ||
| 34 | | `stopped` | Activity stopped (explicit clear) | | ||
| 35 | | `` (empty) | Clear indicator | | ||
| 36 | |||
| 37 | ### Tags | ||
| 38 | |||
| 39 | - `p` (required): Pubkey of the recipient | ||
| 40 | - `e` (optional): Event being replied to, with `reply` marker | ||
| 41 | |||
| 42 | ## Client Behavior | ||
| 43 | |||
| 44 | ### Sending | ||
| 45 | 1. Publish `thinking` when starting to process a message | ||
| 46 | 2. Publish actual response when done | ||
| 47 | 3. Optionally publish `stopped` or empty content to clear early | ||
| 48 | |||
| 49 | ### Receiving | ||
| 50 | 1. Subscribe to kind 20001 events where you're tagged | ||
| 51 | 2. Display indicator while active | ||
| 52 | 3. Auto-clear after ~15 seconds if no update (stale indicator) | ||
| 53 | 4. Clear immediately when: | ||
| 54 | - Receive `stopped` or empty content from same pubkey | ||
| 55 | - Receive an actual message (kind 1, 4, 14, etc.) from same pubkey | ||
| 56 | |||
| 57 | ## Example Flow | ||
| 58 | |||
| 59 | ``` | ||
| 60 | Agent receives message | ||
| 61 | └─> Publish kind:20001 content:"thinking" p:[user] | ||
| 62 | └─> Process request... | ||
| 63 | └─> Publish kind:1 content:"Here's the answer..." (reply) | ||
| 64 | └─> (indicator auto-clears on client when they see the kind:1) | ||
| 65 | ``` | ||
| 66 | |||
| 67 | ## Relay Support | ||
| 68 | |||
| 69 | Relays SHOULD: | ||
| 70 | - Forward ephemeral events to matching subscriptions | ||
| 71 | - NOT persist ephemeral events to storage | ||
| 72 | - NOT return ephemeral events in REQ responses for historical data | ||
| 73 | |||
| 74 | muxstr handles this correctly as of the ephemeral event support addition. | ||
| 75 | |||
| 76 | ## Security Considerations | ||
| 77 | |||
| 78 | - Indicators reveal that someone is actively engaged with you | ||
| 79 | - Consider privacy implications before implementing | ||
| 80 | - Clients MAY offer a setting to disable sending indicators | ||
diff --git a/internal/handler/websocket/handler.go b/internal/handler/websocket/handler.go index c64f3f9..b591f61 100644 --- a/internal/handler/websocket/handler.go +++ b/internal/handler/websocket/handler.go | |||
| @@ -359,6 +359,14 @@ func (h *Handler) handleEvent(ctx context.Context, conn *websocket.Conn, raw []j | |||
| 359 | return nil | 359 | return nil |
| 360 | } | 360 | } |
| 361 | 361 | ||
| 362 | // Handle ephemeral events (kinds 20000-29999) - broadcast but don't store | ||
| 363 | if isEphemeralKind(pbEvent.Kind) { | ||
| 364 | h.subs.MatchAndFan(pbEvent) | ||
| 365 | status = "ok" | ||
| 366 | h.sendOK(ctx, conn, event.ID, true, "") | ||
| 367 | return nil | ||
| 368 | } | ||
| 369 | |||
| 362 | eventData := &storage.EventData{ | 370 | eventData := &storage.EventData{ |
| 363 | Event: pbEvent, | 371 | Event: pbEvent, |
| 364 | CanonicalJSON: canonicalJSON, | 372 | CanonicalJSON: canonicalJSON, |
| @@ -643,3 +651,10 @@ func (h *Handler) isAllowedKind(kind int32) bool { | |||
| 643 | } | 651 | } |
| 644 | return h.allowedKinds[kind] | 652 | return h.allowedKinds[kind] |
| 645 | } | 653 | } |
| 654 | |||
| 655 | // isEphemeralKind returns true if the event kind is ephemeral (20000-29999). | ||
| 656 | // Ephemeral events are broadcast to subscribers but not stored. | ||
| 657 | // See NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md | ||
| 658 | func isEphemeralKind(kind int32) bool { | ||
| 659 | return kind >= 20000 && kind < 30000 | ||
| 660 | } | ||
| Binary files differ | |||
