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.