Skip to content

Commit b52b891

Browse files
committed
feat(chat): mark read and typing automatically
Reading or replying to a conversation implies you have seen it, so: - 'chat read' and 'chat listen' now mark the conversation read automatically — up to the newest event, which the backend treats as a watermark that also marks every earlier message read. 'listen' advances the watermark as new messages arrive. - 'chat send' marks the conversation read after sending and sends a typing indicator before, mirroring how a person composes. All are best-effort writes (a failure warns, never aborts the read or send) and opt-out via --no-mark-read / --no-typing for lurking or scripting. The standalone 'mark-read' and 'typing' commands remain for explicit/scripted use.
1 parent 8e76339 commit b52b891

3 files changed

Lines changed: 46 additions & 11 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,10 +414,14 @@ xurl chat send @bob "look" --file photo.png # encrypt + upload an attachment
414414
xurl chat send @bob "agreed" --reply-to 1234 # reply to an event (sequence id from --json)
415415
xurl chat download @bob MEDIA_HASH_KEY -o out.png # download + decrypt an attachment
416416
xurl chat add-members g123 @carol # add a member (rotates the group key)
417-
xurl chat mark-read @bob # mark read up to the latest message
418-
xurl chat typing @bob # send a typing indicator
417+
xurl chat mark-read @bob # explicit mark-read (also automatic on read/listen/send)
418+
xurl chat typing @bob # explicit typing indicator (also automatic before send)
419419
```
420420
421+
`read`, `listen`, and `send` mark the conversation read automatically (marking the
422+
newest message read clears everything before it), and `send` sends a typing indicator
423+
first. Suppress these writes with `--no-mark-read` / `--no-typing`.
424+
421425
Sending to someone new establishes the conversation automatically: xurl generates a
422426
conversation key, encrypts it to both participants' newest registered keys, and sends.
423427
`rotate` runs the same key change on an existing conversation — use it if a key may be

SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ Tokens are persisted to `~/.xurl/auth.yml` in YAML format (a legacy single-file
108108
| Reply to a message | `xurl chat send CONV "text" --reply-to SEQUENCE_ID` |
109109
| Download an attachment | `xurl chat download CONV MEDIA_HASH_KEY -o out.png` |
110110
| Add group members | `xurl chat add-members GROUP @user --yes` (write op) |
111-
| Mark read | `xurl chat mark-read CONV` |
112-
| Typing indicator | `xurl chat typing CONV` |
111+
| Mark read (explicit) | `xurl chat mark-read CONV` |
112+
| Typing indicator (explicit) | `xurl chat typing CONV` |
113113
| **App Management** | |
114114
| Register app | Manual, outside agent (do not pass secrets via agent) |
115115
| List apps | `xurl auth apps list` |
@@ -294,6 +294,7 @@ xurl chat rotate @someuser --yes # skip the prompt (required non-TTY)
294294
Notes for agents:
295295
- Messages whose authorship signature cannot be verified are rejected by default and surface as stderr decrypt warnings; unsigned messages that still render carry a red `[unverified]` marker — treat those with suspicion.
296296
- Messages with attachments render a `📎 attachment <media_hash_key>` marker; pass that hash key to `xurl chat download CONV <media_hash_key>` to fetch and decrypt the file. Replies show a `` prefix.
297+
- **`read` and `listen` mark the conversation read automatically** (a read receipt visible to other participants); `send` also marks read and sends a typing indicator first. These are writes — pass `--no-mark-read` / `--no-typing` to suppress them (e.g. to read without signaling). The standalone `mark-read` and `typing` commands remain for scripted/explicit use.
297298
- Decrypt warnings for individual events go to stderr and are non-fatal; the rest of the conversation still renders.
298299
- If a command reports missing keys, do not attempt to generate or register any — tell the user to run `xurl chat keys restore` (or `import`) themselves.
299300
- `chat rotate` is a write visible to every participant's clients; never run it without explicit user intent, and prefer letting the user confirm the prompt over passing `--yes`.

cli/chat.go

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,18 @@ func chatReadCmd(a *auth.Auth) *cobra.Command {
301301
Run: func(cmd *cobra.Command, args []string) {
302302
maxResults, _ := cmd.Flags().GetInt("max-results")
303303
asJSON, _ := cmd.Flags().GetBool("json")
304+
noMarkRead, _ := cmd.Flags().GetBool("no-mark-read")
304305
s, err := newChatSession(a, cmd, true)
305306
exitOnError(err)
306307
defer s.Close()
307308
convID, err := s.resolveConversation(args[0])
308309
exitOnError(err)
309-
exitOnError(s.readConversation(convID, maxResults, asJSON))
310+
exitOnError(s.readConversation(convID, maxResults, asJSON, !noMarkRead))
310311
},
311312
}
312313
cmd.Flags().IntP("max-results", "n", 50, "Maximum number of events to fetch (1-100)")
313314
cmd.Flags().Bool("json", false, "Output decrypted events as JSON")
315+
cmd.Flags().Bool("no-mark-read", false, "Do not mark the conversation read")
314316
addCommonFlags(cmd)
315317
return cmd
316318
}
@@ -326,7 +328,8 @@ participants automatically.`,
326328
Run: func(cmd *cobra.Command, args []string) {
327329
file, _ := cmd.Flags().GetString("file")
328330
replyTo, _ := cmd.Flags().GetString("reply-to")
329-
markRead, _ := cmd.Flags().GetBool("mark-read")
331+
noMarkRead, _ := cmd.Flags().GetBool("no-mark-read")
332+
noTyping, _ := cmd.Flags().GetBool("no-typing")
330333
text := ""
331334
if len(args) == 2 {
332335
text = args[1]
@@ -339,12 +342,13 @@ participants automatically.`,
339342
defer s.Close()
340343
convID, err := s.resolveConversation(args[0])
341344
exitOnError(err)
342-
exitOnError(s.sendMessage(convID, text, sendOptions{filePath: file, replyToID: replyTo, markRead: markRead}))
345+
exitOnError(s.sendMessage(convID, text, sendOptions{filePath: file, replyToID: replyTo, markRead: !noMarkRead, typing: !noTyping}))
343346
},
344347
}
345348
cmd.Flags().StringP("file", "F", "", "Attach an encrypted media file")
346349
cmd.Flags().String("reply-to", "", "Sequence id of the event to reply to")
347-
cmd.Flags().Bool("mark-read", false, "Mark the conversation read after sending")
350+
cmd.Flags().Bool("no-mark-read", false, "Do not mark the conversation read after sending")
351+
cmd.Flags().Bool("no-typing", false, "Do not send a typing indicator before sending")
348352
addCommonFlags(cmd)
349353
return cmd
350354
}
@@ -356,15 +360,17 @@ func chatListenCmd(a *auth.Auth) *cobra.Command {
356360
Args: cobra.ExactArgs(1),
357361
Run: func(cmd *cobra.Command, args []string) {
358362
interval, _ := cmd.Flags().GetInt("interval")
363+
noMarkRead, _ := cmd.Flags().GetBool("no-mark-read")
359364
s, err := newChatSession(a, cmd, true)
360365
exitOnError(err)
361366
defer s.Close()
362367
convID, err := s.resolveConversation(args[0])
363368
exitOnError(err)
364-
exitOnError(s.listen(convID, time.Duration(interval)*time.Second))
369+
exitOnError(s.listen(convID, time.Duration(interval)*time.Second, !noMarkRead))
365370
},
366371
}
367372
cmd.Flags().Int("interval", 3, "Polling interval in seconds")
373+
cmd.Flags().Bool("no-mark-read", false, "Do not mark new messages read as they arrive")
368374
addCommonFlags(cmd)
369375
return cmd
370376
}
@@ -1063,11 +1069,17 @@ func (s *chatSession) adoptKeyEvents(keyEvents []string) {
10631069
}
10641070
}
10651071

1066-
func (s *chatSession) readConversation(conversationID string, maxResults int, asJSON bool) error {
1072+
func (s *chatSession) readConversation(conversationID string, maxResults int, asJSON, markRead bool) error {
10671073
result, events, _, err := s.loadBacklog(conversationID, maxResults, "")
10681074
if err != nil {
10691075
return err
10701076
}
1077+
// Reading a conversation means you have seen it: mark it read up to the
1078+
// newest event (a watermark — this also marks every earlier message
1079+
// read). Best-effort; opt out with --no-mark-read.
1080+
if markRead {
1081+
s.markReadLatest(conversationID, events)
1082+
}
10711083

10721084
// Print oldest-first for reading, ordering by event timestamp.
10731085
messages := make([]*chatxdk.Event, 0, len(result.Messages))
@@ -1112,7 +1124,7 @@ func (s *chatSession) readConversation(conversationID string, maxResults int, as
11121124
return nil
11131125
}
11141126

1115-
func (s *chatSession) listen(conversationID string, interval time.Duration) error {
1127+
func (s *chatSession) listen(conversationID string, interval time.Duration, markRead bool) error {
11161128
if interval <= 0 {
11171129
interval = 3 * time.Second
11181130
}
@@ -1129,6 +1141,10 @@ func (s *chatSession) listen(conversationID string, interval time.Duration) erro
11291141
for _, e := range backlog {
11301142
seen[e.ID] = true
11311143
}
1144+
// Opening the conversation marks the existing backlog read.
1145+
if markRead {
1146+
s.markReadLatest(conversationID, backlog)
1147+
}
11321148

11331149
fmt.Printf("Listening on %s (polling every %s, Ctrl-C to stop)...\n", conversationID, interval)
11341150
// Show a little context: the last few messages, oldest first.
@@ -1196,6 +1212,11 @@ func (s *chatSession) listen(conversationID string, interval time.Duration) erro
11961212
}
11971213
s.printEvent(event, s.opts.Verbose)
11981214
}
1215+
// New arrivals shown means they have been seen: advance the read
1216+
// watermark to the newest of this batch.
1217+
if markRead && len(fresh) > 0 {
1218+
s.markReadLatest(conversationID, fresh)
1219+
}
11991220
}
12001221
}
12011222

@@ -1314,9 +1335,18 @@ type sendOptions struct {
13141335
filePath string // attach an encrypted media file
13151336
replyToID string // reply to the event with this sequence id
13161337
markRead bool // mark the conversation read after sending
1338+
typing bool // send a typing indicator before sending
13171339
}
13181340

13191341
func (s *chatSession) sendMessage(conversationID, text string, sopts sendOptions) error {
1342+
// A typing indicator before the message mirrors how a person composes;
1343+
// best-effort so it never blocks the send. Opt out with --no-typing.
1344+
if sopts.typing {
1345+
if _, err := api.SendChatTyping(s.client, conversationID, s.opts); err != nil && s.opts.Verbose {
1346+
fmt.Fprintf(os.Stderr, "warning: could not send typing indicator: %v\n", err)
1347+
}
1348+
}
1349+
13201350
// Load the backlog: it extracts the conversation key and tells us whether
13211351
// the conversation exists.
13221352
result, events, _, err := s.loadBacklog(conversationID, 100, "")

0 commit comments

Comments
 (0)