package syncapi import ( "cmp" "context" "encoding/json" "fmt" "slices" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/storage" ) // The receipt types the Client-Server API defines. const ( // ReceiptRead is public: everybody in the room sees it. ReceiptRead = "m.read" // ReceiptReadPrivate is the user's own, and is reported only back to // them. It exists so that a user can mark a room read without telling // the room they read it. ReceiptReadPrivate = "m.read.private" // ReceiptFullyRead is not a receipt at all: it is the read marker, and // is stored as the room account data type m.fully_read. It is named // here because POST /read_markers takes all three in one body. ReceiptFullyRead = "m.fully_read" ) // requireJoined refuses a caller who is not in the room. // // Every write in this file is a claim about a room — "I am typing in it", // "I have read this far" — rather than data belonging to the caller, so // being in the room is the condition for all of them. func (s *API) requireJoined(ctx context.Context, roomID, userID string) error { membership, err := s.rooms.Membership(ctx, roomID, userID) if err != nil { return err } if membership != event.MembershipJoin { return errForbiddenf("%s is not joined to %s", userID, roomID) } return nil } // ReceiptRequest is POST /rooms/{roomID}/receipt/{receiptType}/{eventID}. type ReceiptRequest struct { RoomID string // User is the full user ID sending the receipt. User string ReceiptType string EventID string // ThreadID is the thread the receipt is for, or empty for the room's // main timeline. Threads are PLAN.md phase 10; the value is stored so // that a client that sends one gets it back, rather than having its // threaded receipt silently collapse onto the main timeline's. ThreadID string } // SetReceipt records a read receipt. func (s *API) SetReceipt(ctx context.Context, req ReceiptRequest) error { switch req.ReceiptType { case ReceiptRead, ReceiptReadPrivate, ReceiptFullyRead: default: return errInvalidParamf("%q is not a receipt type this server accepts", req.ReceiptType) } if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil { return err } // The spec routes m.fully_read through this endpoint too, and it is // account data rather than a receipt — but it is still a claim about a // room the caller has to be in, which is why the check above comes // first. if req.ReceiptType == ReceiptFullyRead { return s.setFullyRead(ctx, req.RoomID, req.User, req.EventID) } _, moved, err := s.store.SetReceipt(ctx, storage.Receipt{ RoomID: req.RoomID, UserID: req.User, ReceiptType: req.ReceiptType, ThreadID: req.ThreadID, EventID: req.EventID, TimestampMS: s.nowMS(), }) if err != nil { return err } // Nothing moved means the user had already read further, and there is // nothing for anybody to be woken about. if !moved { return nil } // A private receipt concerns only its sender; a public one is part of // the room's ephemeral stream and everybody in it wants it. if req.ReceiptType == ReceiptReadPrivate { s.notifier.NotifyUser(req.User) return nil } s.notifier.NotifyRoom(req.RoomID) return nil } // ReadMarkersRequest is POST /rooms/{roomID}/read_markers. type ReadMarkersRequest struct { RoomID string User string // FullyRead is the event up to which the user has read, stored as the // room account data m.fully_read. It is where a client draws the // "unread from here" line, which is a different thing from a receipt: // the line stays where the user left it even as they scroll past. FullyRead string // Read and ReadPrivate are ordinary receipts, sent in the same request // because a client moving the marker almost always moves both. Read string ReadPrivate string } // SetReadMarkers implements the read markers endpoint: up to three // independent writes from one request. // // They are not atomic, and cannot usefully be: the marker is account data // and the receipts are the receipt stream, which are two streams with two // positions. A failure partway through leaves the earlier writes in place, // which is the same state a client would reach by sending them separately // and losing its connection — and which the next call corrects. func (s *API) SetReadMarkers(ctx context.Context, req ReadMarkersRequest) error { if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil { return err } if req.FullyRead != "" { if err := s.setFullyRead(ctx, req.RoomID, req.User, req.FullyRead); err != nil { return err } } for _, receipt := range []struct { receiptType string eventID string }{ {ReceiptRead, req.Read}, {ReceiptReadPrivate, req.ReadPrivate}, } { if receipt.eventID == "" { continue } if err := s.SetReceipt(ctx, ReceiptRequest{ RoomID: req.RoomID, User: req.User, ReceiptType: receipt.receiptType, EventID: receipt.eventID, }); err != nil { return err } } return nil } // setFullyRead writes the read marker as room account data. func (s *API) setFullyRead(ctx context.Context, roomID, userID, eventID string) error { localpart, ok := s.localpartOf(userID) if !ok { return errForbiddenf("%s is not a user of this server", userID) } content, err := json.Marshal(map[string]string{"event_id": eventID}) if err != nil { return fmt.Errorf("marshalling the read marker: %w", err) } if err := s.accounts.SetAccountData(ctx, localpart, roomID, ReceiptFullyRead, content); err != nil { return err } s.notifier.NotifyUser(userID) return nil } // receiptsEvent builds the m.receipt EDU of one room. // // The shape is the spec's, and it is an awkward one: a map of event ID to // receipt type to user ID to the receipt's data. It is nested that way // because a client's job is to answer "who has read up to this event", // which this shape makes a lookup and a flat list would make a scan. func receiptsEvent(receipts []storage.Receipt) *EphemeralEvent { if len(receipts) == 0 { return nil } type receiptData struct { TS int64 `json:"ts"` ThreadID string `json:"thread_id,omitempty"` } content := map[string]map[string]map[string]receiptData{} for _, r := range receipts { byType, ok := content[r.EventID] if !ok { byType = map[string]map[string]receiptData{} content[r.EventID] = byType } byUser, ok := byType[r.ReceiptType] if !ok { byUser = map[string]receiptData{} byType[r.ReceiptType] = byUser } byUser[r.UserID] = receiptData{TS: r.TimestampMS, ThreadID: r.ThreadID} } body, err := json.Marshal(content) if err != nil { // Every value here is a string or an integer, so this cannot // happen; dropping the EDU beats failing the sync if it does. return nil } return &EphemeralEvent{Type: "m.receipt", Content: body} } // roomReceipts reads the receipts a user is entitled to see in a set of // rooms, between two positions. // // Private receipts are filtered here rather than in SQL: the query is the // same one the public receipts come from, and a WHERE clause that had to // name the asking user would turn one query per sync into two. func (s *API) roomReceipts(ctx context.Context, userID string, roomIDs []string, from, to int64) (map[string][]storage.Receipt, error) { if len(roomIDs) == 0 { return nil, nil } receipts, err := s.store.Receipts(ctx, roomIDs, from, to) if err != nil { return nil, err } // An initial sync also wants the user's own receipts however old they // are: a client that has just logged in has to know where it had read // up to, and that receipt was set long before this sync's window. if from == 0 { own, err := s.store.ReceiptsForUser(ctx, userID, roomIDs) if err != nil { return nil, err } receipts = append(receipts, own...) } out := map[string][]storage.Receipt{} seen := map[storage.Receipt]struct{}{} for _, r := range receipts { if r.ReceiptType == ReceiptReadPrivate && r.UserID != userID { continue } if _, dup := seen[r]; dup { // The user's own receipts can arrive from both reads above. continue } seen[r] = struct{}{} out[r.RoomID] = append(out[r.RoomID], r) } for roomID := range out { slices.SortFunc(out[roomID], func(a, b storage.Receipt) int { return cmp.Compare(a.StreamPos, b.StreamPos) }) } return out, nil }