Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,15 +506,32 @@ const statusCardScript = `<script>

bindForm();

// Poll while the tab is visible.
setInterval(function(){
// Unified poll via /updates — only refreshes the status stream when
// there are actual new entries, and updates mail badge from the same
// call. Much cheaper than fetching the full HTML fragment every 10s.
var lastTS = Math.floor(Date.now() / 1000);

function checkUpdates() {
if (document.hidden) return;
refresh();
}, pollInterval);
fetch('/updates?since=' + lastTS, { credentials: 'same-origin', cache: 'no-store' })
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(data){
if (!data) return;
lastTS = data.ts || lastTS;
// Refresh status stream only when new entries exist.
if (data.status > 0) refresh();
// Update mail badges in the header/nav.
var badges = [document.getElementById('head-mail-badge'), document.getElementById('nav-mail-badge')];
for (var i = 0; i < badges.length; i++) {
if (badges[i]) badges[i].textContent = data.mail > 0 ? data.mail : '';
}
})
.catch(function(){});
}

// Fetch immediately when the tab regains focus.
setInterval(checkUpdates, pollInterval);
document.addEventListener('visibilitychange', function(){
if (!document.hidden) refresh();
if (!document.hidden) checkUpdates();
});
})();
</script>`
60 changes: 60 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -1169,6 +1170,65 @@ func main() {
app.Log("main", "Server stopped")
}

// updatesHandler serves GET /updates?since=<unix> — 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
Expand Down
14 changes: 14 additions & 0 deletions social/social.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 24 additions & 0 deletions user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading