diff --git a/home/home.go b/home/home.go index a20d564d..00c0be09 100644 --- a/home/home.go +++ b/home/home.go @@ -506,15 +506,32 @@ const statusCardScript = `` diff --git a/main.go b/main.go index 451e1226..d125cbc5 100644 --- a/main.go +++ b/main.go @@ -820,6 +820,7 @@ func main() { http.HandleFunc("/account", app.Account) http.HandleFunc("/verify", app.Verify) http.HandleFunc("/session", app.Session) + http.HandleFunc("/updates", updatesHandler) http.HandleFunc("/token", app.TokenHandler) http.HandleFunc("/passkey/", app.PasskeyHandler) @@ -1169,6 +1170,65 @@ func main() { app.Log("main", "Server stopped") } +// updatesHandler serves GET /updates?since= — a single lightweight +// endpoint the client polls for change counts. Returns JSON: +// +// {"mail":3,"status":2,"social":1,"ts":1713254400} +// +// The client stores ts and sends it back on the next poll. If since is +// omitted, returns current totals (unread mail, stream size, etc.). +func updatesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var viewerID string + if sess, _ := auth.TrySession(r); sess != nil { + viewerID = sess.Account + } + + now := time.Now() + + // Parse the "since" parameter — unix timestamp. + var since time.Time + if s := r.URL.Query().Get("since"); s != "" { + var n int64 + if _, err := fmt.Sscanf(s, "%d", &n); err == nil { + since = time.Unix(n, 0) + } + } + + result := map[string]interface{}{ + "ts": now.Unix(), + } + + // Mail — always unread count (personal, independent of since). + if viewerID != "" { + result["mail"] = mail.GetUnreadCount(viewerID) + } else { + result["mail"] = 0 + } + + // Status — new entries since last poll. + if since.IsZero() { + result["status"] = 0 + } else { + result["status"] = user.StatusCountSince(since, viewerID) + } + + // Social — new messages since last poll. + if since.IsZero() { + result["social"] = 0 + } else { + result["social"] = social.CountSince(since) + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(result) +} + // chargedWriteOp maps a request method + path to the wallet operation // that should be charged. Returns "" for routes that don't cost credits // (reads, auth, payments, MCP — MCP has its own QuotaCheck). This is diff --git a/social/social.go b/social/social.go index ffd99abf..28e0d1b6 100644 --- a/social/social.go +++ b/social/social.go @@ -244,6 +244,20 @@ func CardHTML() string { return cardHTML } +// CountSince returns the number of messages (threads + replies) posted +// after the given timestamp. Used by the /updates endpoint. +func CountSince(since time.Time) int { + mutex.RLock() + defer mutex.RUnlock() + count := 0 + for _, p := range messages { + if p.PostedAt.After(since) { + count++ + } + } + return count +} + // GetThreads returns all cached messages (most recent first) func GetThreads() []*Message { mutex.RLock() diff --git a/user/user.go b/user/user.go index 5e78423e..4501bc68 100644 --- a/user/user.go +++ b/user/user.go @@ -413,6 +413,30 @@ func StatusStreamCapped(maxTotal, maxPerUser int, viewerID string) []StatusEntry return entries } +// StatusCountSince returns how many status entries are newer than the +// given timestamp. Used by the /updates endpoint to tell the client +// whether it needs to refresh the stream. +func StatusCountSince(since time.Time, viewerID string) int { + profileMutex.RLock() + defer profileMutex.RUnlock() + + count := 0 + for _, p := range profiles { + if auth.IsBanned(p.UserID) && p.UserID != viewerID { + continue + } + if !p.UpdatedAt.IsZero() && p.UpdatedAt.After(since) { + count++ + } + for _, h := range p.History { + if h.SetAt.After(since) { + count++ + } + } + } + return count +} + // MaxStatusLength is the upper bound on a single status message. Larger // than a tweet, smaller than an essay — enough room for a short thought // or an @micro question without inviting wall-of-text posts.