package pushserver import ( "context" "encoding/json" "errors" "strconv" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Receipt types that move the counts. A private receipt is still the // user saying they have read up to here; it is private from other users, // not from the user's own badge. const ( receiptRead = "m.read" receiptReadPrivate = "m.read.private" // threadMain is the thread ID of a receipt for the main timeline. threadMain = "main" ) // ReceiptSet is told about a read receipt a local user set, and clears // their notifications in the room up to the event it names. // // Notifications are filed by thread, and a receipt reads the part of the // room it is for: thread_id "main" the main timeline, a thread root's ID // that thread, and no thread_id the whole room. Reading one thread leaves // the main timeline and the other threads unread, and a client that does // not know about threads sends unthreaded receipts and keeps clearing the // room as a whole, as before threads. func (a *API) ReceiptSet(ctx context.Context, userID, roomID, receiptType, threadID, eventID string) { if receiptType != receiptRead && receiptType != receiptReadPrivate { return } thread := receiptThread(threadID) pos, err := a.rooms.EventPosition(ctx, roomID, userID, eventID) if err != nil { // A receipt for an event this server does not have — or one the // user cannot see — reads nothing. if !errors.Is(err, roomserver.ErrNoSuchEvent) && !errors.Is(err, roomserver.ErrForbidden) { a.log.Warn("locating a receipt's event failed", "user_id", userID, "room_id", roomID, "event_id", eventID, "error", err) } return } if err := a.markRead(ctx, userID, roomID, thread, pos); err != nil { a.log.Warn("clearing notifications on a receipt failed", "user_id", userID, "room_id", roomID, "error", err) } } // receiptThread is the part of a room a receipt's thread_id reads, as the // store names it. func receiptThread(threadID string) string { switch threadID { case "": return storage.AllThreads case threadMain: return storage.ThreadMain } return threadID } // markRead clears a user's notifications in a room, or in one thread of // it, up to a position, and wakes their syncs if the counts moved. func (a *API) markRead(ctx context.Context, userID, roomID, thread string, upTo int64) error { _, moved, err := a.store.MarkNotificationsRead(ctx, userID, roomID, thread, upTo) if err != nil { return err } if moved { a.notifyUsers(userID) } return nil } // NotificationCounts reads a user's unread counts in the given rooms. func (a *API) NotificationCounts(ctx context.Context, userID string, roomIDs []string) (map[string]storage.NotificationCounts, error) { return a.store.NotificationCounts(ctx, userID, roomIDs) } // ThreadNotificationCounts reads a user's unread counts in the given rooms // per thread, with the main timeline under storage.ThreadMain. func (a *API) ThreadNotificationCounts(ctx context.Context, userID string, roomIDs []string) (map[string]map[string]storage.NotificationCounts, error) { return a.store.ThreadNotificationCounts(ctx, userID, roomIDs) } // NotificationCountChanges lists the rooms whose counts for a user moved // in a window of the notification stream. func (a *API) NotificationCountChanges(ctx context.Context, userID string, from, to int64) ([]string, error) { return a.store.NotificationCountChanges(ctx, userID, from, to) } // Notification is one entry of GET /notifications. type Notification struct { Actions []json.RawMessage `json:"actions"` Event roomserver.ClientEvent `json:"event"` ProfileTag string `json:"profile_tag,omitempty"` Read bool `json:"read"` RoomID string `json:"room_id"` TS int64 `json:"ts"` } // NotificationsRequest is GET /notifications. type NotificationsRequest struct { UserID string // From is the next_token of an earlier page, or empty for the newest. From string Limit int OnlyHighlight bool } // Limits on a page of notifications. const ( DefaultNotificationsLimit = 20 MaxNotificationsLimit = 100 ) // ErrInvalidFrom is a from token that is not one this server handed out. var ErrInvalidFrom = errors.New("invalid pagination token") // Notifications pages through a user's notifications, newest first. It // returns the page and the token for the next one, which is empty at the // end. func (a *API) Notifications(ctx context.Context, req NotificationsRequest) ([]Notification, string, error) { var before int64 if req.From != "" { var err error if before, err = strconv.ParseInt(req.From, 10, 64); err != nil || before <= 0 { return nil, "", ErrInvalidFrom } } limit := req.Limit if limit <= 0 { limit = DefaultNotificationsLimit } limit = min(limit, MaxNotificationsLimit) stored, err := a.store.Notifications(ctx, req.UserID, before, limit, req.OnlyHighlight) if err != nil { return nil, "", err } out := make([]Notification, 0, len(stored)) for _, n := range stored { ev, err := a.rooms.Event(ctx, n.RoomID, req.UserID, n.EventID) switch { case errors.Is(err, roomserver.ErrNoSuchEvent), errors.Is(err, roomserver.ErrNoSuchRoom), errors.Is(err, roomserver.ErrForbidden): // Purged, or no longer the user's to see: the notification // has nothing left to show. continue case err != nil: return nil, "", err } var actions []json.RawMessage if err := json.Unmarshal(n.Actions, &actions); err != nil { actions = []json.RawMessage{} } out = append(out, Notification{ Actions: actions, Event: ev, Read: n.Read, RoomID: n.RoomID, TS: n.TimestampMS, }) } var next string if len(stored) == limit { next = strconv.FormatInt(stored[len(stored)-1].ID, 10) } return out, next, nil }