Offline chat
This recipe builds the messaging core of a one-to-one chat app: send encrypted messages to a contact who may be offline, receive them promptly when online, and never lose or double-show one. It composes messages, encryption, notifications and acknowledgement.
The design
Section titled “The design”- Each user’s identity key is stored on the device. Their peer ID is their chat address.
- Chat messages go to the folder
chatin the recipient’s mailbox, sealed withWithEncryption. The folder keeps chat apart from other mail the app may use, and it is created as a private mailbox on first delivery. - The receiver retrieves on start, on reconnect, on each notification and on a timer, because notifications can be dropped.
- The receiver stores each message locally first, then acknowledges it. A crash between the two shows the message again on the next retrieve, and the local store removes the duplicate by message ID.
- Both users list the same server first in
PreferredServers, because servers do not forward mail to each other.
Sending
Section titled “Sending”// Sketch: the chat send path.type ChatMessage struct { Text string `json:"text"` Sent time.Time `json:"sent"`}
func Send(ctx context.Context, cl *client.Client, to peer.ID, text string) (string, error) { body, _ := json.Marshal(ChatMessage{Text: text, Sent: time.Now()}) res, err := cl.SendMessage(ctx, to, body, client.WithFolderPath("chat"), client.WithEncryption(), client.WithCompression(), // only payloads of 1 KiB or more are compressed ) if err != nil { return "", err // not sent: show "retrying" and try again later } if err := res.Err(); err != nil { switch { case errors.Is(err, client.ErrMailboxFull): return "", fmt.Errorf("%s's inbox is full", to) // retrying will not help case client.IsRetryable(err): return "", err // queue and retry after client.RetryAfter(err) default: return "", err } } return res.MessageID, nil // show as "delivered to server"}The server stores the message for seven days by default. Use WithExpiry for a shorter life; the
server clamps a longer one to the mailbox’s retention.
Receiving
Section titled “Receiving”// Sketch: the chat receive loop. Store is your app's local database.type Store interface { Has(id string) bool Save(from string, msg ChatMessage, id string) error}
func Receive(ctx context.Context, h host.Host, cl *client.Client, server peer.AddrInfo, db Store) error { wake := make(chan struct{}, 1) signal := func() { select { case wake <- struct{}{}: default: } } cl.RegisterNotificationHandler(func(n *wire.Notification) { if n.MailboxPath == "chat" { signal() } })
poll := time.NewTicker(time.Minute) defer poll.Stop() signal() // retrieve once at start
for { select { case <-ctx.Done(): return ctx.Err() case <-wake: case <-poll.C: // Reconnect if the connection dropped; push needs a live connection. if h.Network().Connectedness(server.ID) != network.Connected { if err := h.Connect(ctx, server); err != nil { continue } } } if err := drain(ctx, cl, db); err != nil { log.Println("retrieve:", err) } }}
func drain(ctx context.Context, cl *client.Client, db Store) error { page, err := cl.RetrievePage(ctx, client.WithRetrieveFolderPath("chat")) for err == nil { var handled []string for _, m := range page.Messages { var cm ChatMessage if json.Unmarshal(m.Payload, &cm) == nil && !db.Has(m.MessageID) { if err := db.Save(m.SenderPeerID, cm, m.MessageID); err != nil { return err // do not acknowledge what was not saved } } handled = append(handled, m.MessageID) } if len(handled) > 0 { if _, err := cl.MarkDelivered(ctx, handled); err != nil { return err } } if !page.HasMore { return nil } page, err = cl.RetrievePage(ctx, client.WithRetrieveFolderPath("chat")) } return err}Because the acknowledged messages are deleted, the next RetrievePage returns the following page; no
sequence bookkeeping is needed on a private mailbox.
Things to decide
Section titled “Things to decide”- Unencrypted senders. A private mailbox accepts mail from any peer, and the client clears the
encrypted flag after decrypting, so your app cannot tell from a retrieved message whether it arrived
encrypted. Reject payloads that do not parse, and keep a contact list to filter senders by
SenderPeerID. - Unknown senders. Anyone who knows a user’s peer ID can write to their
chatfolder. To accept only contacts, createchatas a shared mailbox and grant each contactwire.AccessWriteOnly; see Mailboxes & ACLs. - Forward secrecy.
WithEncryptionuses long-term keys. For a chat app that needs forward secrecy, run a ratchet such as Signal’s over these messages and send its ciphertext as the payload. - Read receipts. Send them as ordinary messages in another folder, such as
chat-receipts, so they never mix with chat text.