From 3ea2ba0d9503fd67e02e85f49308852c96f91686 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:03:50 +0000 Subject: [PATCH 1/2] Turn status card into a live stream with @micro AI mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users were already using the status card as chat — everyone posting updates that read like replies to each other. Instead of fighting it, make the stream the thing. The card is now a live scrollable feed of every status from everyone, and the AI is a first-class participant you can summon with @micro. Data model: - user.UpdateProfile now always pushes the previous status onto the history (not just on change), and merges the stored history with whatever the caller passed so a freshly-constructed *Profile{} can't accidentally drop history. History cap raised 20 → 100 per user. - user.StatusStream(max) is new: flattens every user's current + history into one chronological feed, newest first, within the existing 7-day cutoff. Home card: - Replaced the "latest status per user" render with a shared RenderStatusStream helper used by both the home page and a new /user/status/stream fragment endpoint. - The card body is wrapped in #home-status-wrap and followed by an inline ` diff --git a/internal/app/html/mu.css b/internal/app/html/mu.css index 0adc125e..6ee9c982 100644 --- a/internal/app/html/mu.css +++ b/internal/app/html/mu.css @@ -629,14 +629,26 @@ body.page-home #page-title ~ #customize-link { #home-status-input::placeholder { color: #aaa; } +#home-status-wrap { + display: flex; + flex-direction: column; + min-height: 0; +} #home-statuses { margin: 0; + /* Stream is scrollable. Capped at ~55vh on mobile (so the card never + takes the whole screen) and 420px on desktop. Overflowing entries + scroll inside the card rather than expanding the layout. */ + max-height: min(55vh, 420px); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding-right: 4px; } .home-status-entry { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; - padding: 6px 0; + padding: 8px 0; border-bottom: 1px solid #f0f0f0; } .home-status-entry:last-child { @@ -675,19 +687,6 @@ body.page-home #page-title ~ #customize-link { .home-status-name:hover { text-decoration: underline; } -.home-status-clear { - text-decoration: none; - color: #ccc; - font-size: 11px; - opacity: 0; - transition: opacity 0.15s; -} -.home-status-entry:hover .home-status-clear { - opacity: 1; -} -.home-status-clear:hover { - color: #dc3545; -} .home-status-time { color: #bbb; font-size: 12px; @@ -697,9 +696,18 @@ body.page-home #page-title ~ #customize-link { .home-status-text { color: #555; margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + /* Wrap long lines naturally and break any ultra-long unbroken + strings (URLs, code) so nothing can widen the card. */ + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: anywhere; + line-height: 1.4; +} +.home-status-system .home-status-name { + color: #1f7a4a; +} +.home-status-system .home-status-avatar { + background: #1f7a4a !important; } #home { diff --git a/main.go b/main.go index e894c02b..cdb749a6 100644 --- a/main.go +++ b/main.go @@ -189,6 +189,31 @@ func main() { return result } user.LinkifyContent = blog.Linkify + + // Wire @micro mention handling in the status stream. When a user + // posts a status containing "@micro ...", run the agent against + // the sender's wallet and post the reply as a status from the + // system user. Runs async so the POST /user/status handler returns + // immediately. We never fire this for the system user itself. + user.AIReplyHook = func(askerID, prompt string) { + if askerID == app.SystemUserID { + return + } + answer, err := agent.Query(askerID, prompt) + if err != nil { + app.Log("status", "@micro agent error for %s: %v", askerID, err) + // Post a short apology rather than leaving the mention silent. + _ = user.PostSystemStatus("I couldn't answer that one — try again in a moment.") + return + } + answer = strings.TrimSpace(answer) + if answer == "" { + return + } + if err := user.PostSystemStatus(answer); err != nil { + app.Log("status", "failed to post @micro reply: %v", err) + } + } user.GetUserApps = func(authorID string) []user.UserApp { appList := apps.GetAppsByAuthor(authorID) result := make([]user.UserApp, len(appList)) @@ -740,6 +765,7 @@ func main() { http.HandleFunc("/social", social.Handler) http.HandleFunc("/social/thread", social.ThreadHandler) http.HandleFunc("/user/status", user.StatusHandler) + http.HandleFunc("/user/status/stream", user.StatusStreamHandler) // redirect /reminder to reminder.dev http.HandleFunc("/reminder", reminder.Handler) diff --git a/user/status_test.go b/user/status_test.go new file mode 100644 index 00000000..5f68e89b --- /dev/null +++ b/user/status_test.go @@ -0,0 +1,156 @@ +package user + +import ( + "testing" + "time" +) + +func TestContainsMention(t *testing.T) { + cases := []struct { + text string + want bool + }{ + {"@micro hello", true}, + {"hey @micro can you help", true}, + {"prefix @micro", true}, + {"@micro", true}, + {"@micro!", true}, + {"@micro, please", true}, + {"visit @microwave", false}, + {"@microsoft is a company", false}, + {"email me@micro.xyz", false}, + {"no mention at all", false}, + {"", false}, + {"@micro @micro twice", true}, + } + for _, tc := range cases { + got := containsMention(tc.text, MicroMention) + if got != tc.want { + t.Errorf("containsMention(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +func TestStatusStream_ChronologicalOrder(t *testing.T) { + profileMutex.Lock() + saved := profiles + profiles = map[string]*Profile{} + profileMutex.Unlock() + t.Cleanup(func() { + profileMutex.Lock() + profiles = saved + profileMutex.Unlock() + }) + + now := time.Now() + profileMutex.Lock() + profiles["alice"] = &Profile{ + UserID: "alice", + Status: "latest", + UpdatedAt: now, + History: []StatusHistory{ + {Status: "oldest", SetAt: now.Add(-5 * time.Minute)}, + {Status: "middle", SetAt: now.Add(-2 * time.Minute)}, + }, + } + profiles["bob"] = &Profile{ + UserID: "bob", + Status: "bob now", + UpdatedAt: now.Add(-1 * time.Minute), + History: []StatusHistory{ + {Status: "bob old", SetAt: now.Add(-3 * time.Minute)}, + }, + } + profileMutex.Unlock() + + stream := StatusStream(100) + + // Expected order (newest first): + // alice "latest" (now) + // bob "bob now" (-1m) + // alice "middle" (-2m) + // bob "bob old" (-3m) + // alice "oldest" (-5m) + wantOrder := []string{"latest", "bob now", "middle", "bob old", "oldest"} + if len(stream) != len(wantOrder) { + t.Fatalf("got %d entries, want %d: %+v", len(stream), len(wantOrder), stream) + } + for i, w := range wantOrder { + if stream[i].Status != w { + t.Errorf("stream[%d].Status = %q, want %q", i, stream[i].Status, w) + } + } +} + +func TestStatusStream_RespectsMax(t *testing.T) { + profileMutex.Lock() + saved := profiles + profiles = map[string]*Profile{} + profileMutex.Unlock() + t.Cleanup(func() { + profileMutex.Lock() + profiles = saved + profileMutex.Unlock() + }) + + now := time.Now() + var history []StatusHistory + for i := 0; i < 50; i++ { + history = append(history, StatusHistory{ + Status: "old", + SetAt: now.Add(-time.Duration(i+1) * time.Minute), + }) + } + profileMutex.Lock() + profiles["alice"] = &Profile{ + UserID: "alice", + Status: "current", + UpdatedAt: now, + History: history, + } + profileMutex.Unlock() + + stream := StatusStream(10) + if len(stream) != 10 { + t.Errorf("got %d, want 10", len(stream)) + } + if stream[0].Status != "current" { + t.Errorf("newest should be 'current', got %q", stream[0].Status) + } +} + +func TestUpdateProfile_AlwaysAppendsHistory(t *testing.T) { + profileMutex.Lock() + saved := profiles + profiles = map[string]*Profile{} + profileMutex.Unlock() + t.Cleanup(func() { + profileMutex.Lock() + profiles = saved + profileMutex.Unlock() + }) + + // First status — no history yet. + p := &Profile{UserID: "alice", Status: "hello"} + if err := UpdateProfile(p); err != nil { + t.Fatalf("first update: %v", err) + } + + // Second status — previous should be pushed. + p2 := &Profile{UserID: "alice", Status: "world"} + if err := UpdateProfile(p2); err != nil { + t.Fatalf("second update: %v", err) + } + if len(p2.History) != 1 || p2.History[0].Status != "hello" { + t.Errorf("after second update, history = %+v, want one entry 'hello'", p2.History) + } + + // Third status — even when the text repeats, the previous is pushed. + p3 := &Profile{UserID: "alice", Status: "world"} + if err := UpdateProfile(p3); err != nil { + t.Fatalf("third update: %v", err) + } + if len(p3.History) != 2 || p3.History[0].Status != "world" || p3.History[1].Status != "hello" { + t.Errorf("after third update, history = %+v, want ['world', 'hello']", p3.History) + } +} diff --git a/user/user.go b/user/user.go index ecc1c9fa..05f3a2db 100644 --- a/user/user.go +++ b/user/user.go @@ -3,7 +3,9 @@ package user import ( "encoding/json" "fmt" + htmlpkg "html" "net/http" + "sort" "strings" "sync" "time" @@ -59,7 +61,7 @@ type StatusHistory struct { } // maxStatusHistory is the number of past statuses to keep per user. -const maxStatusHistory = 20 +const maxStatusHistory = 100 // Presence tracking var ( @@ -214,20 +216,39 @@ func GetProfile(userID string) *Profile { return profile } -// UpdateProfile saves a user's profile. If the status changed and the -// previous status was non-empty, it's pushed onto the history. +// UpdateProfile saves a user's profile. Every non-empty previous +// status is pushed onto the history so the full timeline of what a +// user has said is preserved. Empty updates (clearing a status) are +// never pushed. +// +// To avoid a whole class of "caller forgot to carry over history" +// bugs, this function always merges with whatever is already stored +// under the same UserID — you can pass a freshly-constructed +// &Profile{UserID: ..., Status: ...} and history is still preserved. func UpdateProfile(profile *Profile) error { profileMutex.Lock() defer profileMutex.Unlock() - // Record previous status in history if it changed - if old, ok := profiles[profile.UserID]; ok && old.Status != "" && old.Status != profile.Status { - profile.History = append([]StatusHistory{{Status: old.Status, SetAt: old.UpdatedAt}}, profile.History...) - if len(profile.History) > maxStatusHistory { - profile.History = profile.History[:maxStatusHistory] - } + // Start from the existing history in the map rather than whatever + // the caller passed. If the caller supplied extra history entries + // (tests / migrations), keep them at the front. + existing, hasExisting := profiles[profile.UserID] + mergedHistory := append([]StatusHistory{}, profile.History...) + if hasExisting { + mergedHistory = append(mergedHistory, existing.History...) + } + + // Record previous status in history — always, not just on change. + // The home card renders the combined stream, so the history is + // where the conversation actually lives. Repeating yourself is OK. + if hasExisting && existing.Status != "" { + mergedHistory = append([]StatusHistory{{Status: existing.Status, SetAt: existing.UpdatedAt}}, mergedHistory...) } + if len(mergedHistory) > maxStatusHistory { + mergedHistory = mergedHistory[:maxStatusHistory] + } + profile.History = mergedHistory profile.UpdatedAt = time.Now() profiles[profile.UserID] = profile data.SaveJSON("profiles.json", profiles) @@ -270,19 +291,78 @@ func RecentStatuses(viewerID string, max int) []StatusEntry { }) } // Sort newest first - for i := 0; i < len(entries); i++ { - for j := i + 1; j < len(entries); j++ { - if entries[j].UpdatedAt.After(entries[i].UpdatedAt) { - entries[i], entries[j] = entries[j], entries[i] + sort.Slice(entries, func(i, j int) bool { + return entries[i].UpdatedAt.After(entries[j].UpdatedAt) + }) + if len(entries) > max { + entries = entries[:max] + } + return entries +} + +// StatusStream returns a flat chronological feed of every status ever +// posted (current + history), newest first. This is the home card data +// source — it turns what was an accidental chat surface into an honest +// live stream. Older entries beyond statusMaxAge are dropped. +func StatusStream(max int) []StatusEntry { + profileMutex.RLock() + defer profileMutex.RUnlock() + + cutoff := time.Now().Add(-statusMaxAge) + var entries []StatusEntry + for _, p := range profiles { + name := p.UserID + if acc, err := auth.GetAccount(p.UserID); err == nil { + name = acc.Name + } else if p.UserID == app.SystemUserID { + name = app.SystemUserName + } + // Current status — latest entry for this user. + if p.Status != "" && !p.UpdatedAt.Before(cutoff) { + entries = append(entries, StatusEntry{ + UserID: p.UserID, + Name: name, + Status: p.Status, + UpdatedAt: p.UpdatedAt, + }) + } + // History entries — also within cutoff. + for _, h := range p.History { + if h.SetAt.Before(cutoff) { + continue } + entries = append(entries, StatusEntry{ + UserID: p.UserID, + Name: name, + Status: h.Status, + UpdatedAt: h.SetAt, + }) } } + sort.Slice(entries, func(i, j int) bool { + return entries[i].UpdatedAt.After(entries[j].UpdatedAt) + }) if len(entries) > max { entries = entries[:max] } return entries } +// 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. +const MaxStatusLength = 512 + +// MicroMention is the token that triggers an AI response in the status +// stream. Posting "@micro what's the btc price?" queues a background +// agent call whose answer is posted as a status from the system user. +const MicroMention = "@micro" + +// AIReplyHook is wired from main.go. It receives (askerID, prompt) and +// should call the agent, then post the answer as a status from the +// system user. Kept as a callback to avoid a user→agent import cycle. +var AIReplyHook func(askerID, prompt string) + // StatusHandler handles POST /user/status to update the current user's status. func StatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { @@ -295,15 +375,22 @@ func StatusHandler(w http.ResponseWriter, r *http.Request) { return } - status := r.FormValue("status") - if len(status) > 100 { - status = status[:100] + status := strings.TrimSpace(r.FormValue("status")) + if len(status) > MaxStatusLength { + status = status[:MaxStatusLength] } profile := GetProfile(sess.Account) profile.Status = status UpdateProfile(profile) + // If the user @mentioned the system agent, fire off a background + // agent call that will post the answer as a status from @micro. + // Skipped when the system user is mentioning itself. + if status != "" && sess.Account != app.SystemUserID && AIReplyHook != nil && containsMention(status, MicroMention) { + go AIReplyHook(sess.Account, status) + } + // Redirect back to referrer or home ref := r.Header.Get("Referer") if ref == "" || !strings.HasPrefix(ref, "/") { @@ -321,6 +408,57 @@ func StatusHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, ref, http.StatusSeeOther) } +// PostSystemStatus posts a status from the system user (@micro) without +// the usual auth checks. Used by the AI reply hook. +func PostSystemStatus(text string) error { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + if len(text) > MaxStatusLength { + text = text[:MaxStatusLength-1] + "…" + } + profile := GetProfile(app.SystemUserID) + profile.Status = text + return UpdateProfile(profile) +} + +// containsMention returns true when the mention token appears in the +// text as a standalone word (not inside another word like "@microsoft"). +func containsMention(text, mention string) bool { + idx := 0 + for { + i := strings.Index(text[idx:], mention) + if i < 0 { + return false + } + pos := idx + i + // Left boundary — start of string or whitespace/punct. + if pos > 0 { + c := text[pos-1] + if !isMentionBoundary(c) { + idx = pos + len(mention) + continue + } + } + // Right boundary — end of string or whitespace/punct (not a + // word char, so "@microwave" doesn't match). + after := pos + len(mention) + if after < len(text) { + c := text[after] + if !isMentionBoundary(c) { + idx = after + continue + } + } + return true + } +} + +func isMentionBoundary(c byte) bool { + return !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') +} + // Handler renders a user profile page at /@username func Handler(w http.ResponseWriter, r *http.Request) { // Extract username from URL path (remove /@ prefix) @@ -347,15 +485,19 @@ func Handler(w http.ResponseWriter, r *http.Request) { return } - status := r.FormValue("status") - if len(status) > 100 { - status = status[:100] + status := strings.TrimSpace(r.FormValue("status")) + if len(status) > MaxStatusLength { + status = status[:MaxStatusLength] } profile := GetProfile(sess.Account) profile.Status = status UpdateProfile(profile) + if status != "" && sess.Account != app.SystemUserID && AIReplyHook != nil && containsMention(status, MicroMention) { + go AIReplyHook(sess.Account, status) + } + // Redirect back to profile http.Redirect(w, r, "/@"+sess.Account, http.StatusSeeOther) return @@ -514,3 +656,85 @@ func Handler(w http.ResponseWriter, r *http.Request) { html := app.RenderHTML(acc.Name, fmt.Sprintf("Profile of %s", acc.Name), content) w.Write([]byte(html)) } + +// avatarColors are the palette used for status card avatars. +var avatarColors = []string{ + "#56a8a1", // teal + "#8e7cc3", // purple + "#e8a87c", // pastel orange + "#5c9ecf", // blue + "#e06c75", // rose + "#c2785c", // terracotta + "#7bab6e", // sage + "#9e7db8", // lavender +} + +// StatusStreamMax is the maximum number of entries rendered on the home +// status card. The card is scrollable, so this mostly caps memory/render +// cost rather than what the user can see. +const StatusStreamMax = 50 + +// RenderStatusStream renders the inner markup of the home status card: +// the compose form (when a viewer is logged in) plus the scrollable +// stream of recent statuses. Extracted so the fragment endpoint and +// the home card can share one code path. +func RenderStatusStream(viewerID string) string { + entries := StatusStream(StatusStreamMax) + + var sb strings.Builder + if viewerID != "" { + sb.WriteString(fmt.Sprintf( + `
`, + MaxStatusLength)) + } + sb.WriteString(`
`) + if len(entries) == 0 { + sb.WriteString(`

No statuses yet. Be the first.

`) + } + for _, s := range entries { + initial := "?" + if s.Name != "" { + initial = strings.ToUpper(s.Name[:1]) + } + colorIdx := 0 + for _, c := range s.UserID { + colorIdx += int(c) + } + color := avatarColors[colorIdx%len(avatarColors)] + entryClass := "home-status-entry" + if s.UserID == viewerID { + entryClass += " home-status-mine" + } + if s.UserID == app.SystemUserID { + entryClass += " home-status-system" + } + sb.WriteString(fmt.Sprintf( + `
%s
%s%s
%s
`, + entryClass, + color, + htmlpkg.EscapeString(initial), + htmlpkg.EscapeString(s.UserID), + htmlpkg.EscapeString(s.Name), + app.TimeAgo(s.UpdatedAt), + htmlpkg.EscapeString(s.Status))) + } + sb.WriteString(`
`) + return sb.String() +} + +// StatusStreamHandler returns the rendered status stream as an HTML +// fragment at GET /user/status/stream. Polled by the home card for +// near-real-time updates without a full page reload. +func StatusStreamHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + viewerID := "" + if sess, _ := auth.TrySession(r); sess != nil { + viewerID = sess.Account + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write([]byte(RenderStatusStream(viewerID))) +} From 6edd67a191ee55d004f79273b19f9caf212bf0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:14:55 +0000 Subject: [PATCH 2/2] Cap the status stream at 30 entries with a per-user flood guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limit how many statuses are shown on the home card to stop the feed from becoming a scroll-wall. Two caps apply: - StatusStreamMax (default 30, STATUS_STREAM_LIMIT to override) — total entries in the visible stream. Past ~30 the scroll becomes noise rather than signal and the card stops feeling live. - StatusStreamPerUser (default 10, STATUS_STREAM_LIMIT_PER_USER to override) — how many entries any one user can contribute to the visible window. Without this, a single chatty user or a long @micro conversation would flood the feed and push everyone else off. Applied to each user's list (newest-first) BEFORE the chronological merge so older entries from flooders can't displace more recent entries from quieter users. Exposed via StatusStreamCapped(maxTotal, maxPerUser) so future callers (profile history pages, admin views) can pick different shapes without re-implementing. Test coverage: new TestStatusStream_PerUserCapPreventsFlood builds a profile for Alice with 20 recent entries and Bob with 1, verifies Alice contributes at most 3 when cap is 3/user and that Bob's single entry still appears. --- user/status_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++ user/user.go | 64 ++++++++++++++++++++++++++++++++------ 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/user/status_test.go b/user/status_test.go index 5f68e89b..a7d36911 100644 --- a/user/status_test.go +++ b/user/status_test.go @@ -1,6 +1,7 @@ package user import ( + "fmt" "testing" "time" ) @@ -82,6 +83,80 @@ func TestStatusStream_ChronologicalOrder(t *testing.T) { } } +func TestStatusStream_PerUserCapPreventsFlood(t *testing.T) { + profileMutex.Lock() + saved := profiles + profiles = map[string]*Profile{} + profileMutex.Unlock() + t.Cleanup(func() { + profileMutex.Lock() + profiles = saved + profileMutex.Unlock() + }) + + now := time.Now() + + // Alice is an active chatter with 20 recent messages. + var aliceHistory []StatusHistory + for i := 0; i < 20; i++ { + aliceHistory = append(aliceHistory, StatusHistory{ + Status: fmt.Sprintf("alice %d", i), + SetAt: now.Add(-time.Duration(i+1) * time.Minute), + }) + } + profileMutex.Lock() + profiles["alice"] = &Profile{ + UserID: "alice", + Status: "alice now", + UpdatedAt: now, + History: aliceHistory, + } + // Bob posted once half an hour ago — should still appear even + // though Alice has 20 messages in between. + profiles["bob"] = &Profile{ + UserID: "bob", + Status: "bob says hi", + UpdatedAt: now.Add(-30 * time.Minute), + } + profileMutex.Unlock() + + // Cap: 10 total, 3 per user. Alice should contribute at most 3. + stream := StatusStreamCapped(10, 3) + + aliceCount := 0 + bobCount := 0 + for _, e := range stream { + if e.UserID == "alice" { + aliceCount++ + } + if e.UserID == "bob" { + bobCount++ + } + } + if aliceCount > 3 { + t.Errorf("alice contributed %d entries, want at most 3", aliceCount) + } + if bobCount != 1 { + t.Errorf("bob contributed %d entries, want 1", bobCount) + } + // Alice's 3 entries should be her 3 most recent, not random. + var aliceStatuses []string + for _, e := range stream { + if e.UserID == "alice" { + aliceStatuses = append(aliceStatuses, e.Status) + } + } + want := []string{"alice now", "alice 0", "alice 1"} + if len(aliceStatuses) != len(want) { + t.Fatalf("got %d alice entries, want %d", len(aliceStatuses), len(want)) + } + for i, w := range want { + if aliceStatuses[i] != w { + t.Errorf("aliceStatuses[%d] = %q, want %q", i, aliceStatuses[i], w) + } + } +} + func TestStatusStream_RespectsMax(t *testing.T) { profileMutex.Lock() saved := profiles diff --git a/user/user.go b/user/user.go index 05f3a2db..9b5b1cfa 100644 --- a/user/user.go +++ b/user/user.go @@ -5,6 +5,7 @@ import ( "fmt" htmlpkg "html" "net/http" + "os" "sort" "strings" "sync" @@ -304,7 +305,23 @@ func RecentStatuses(viewerID string, max int) []StatusEntry { // posted (current + history), newest first. This is the home card data // source — it turns what was an accidental chat surface into an honest // live stream. Older entries beyond statusMaxAge are dropped. +// +// Two caps are applied: +// - perUser: within any one user's contribution, only the most recent +// perUser entries are eligible. This stops a single chatty user +// (or a long @micro conversation) from flooding the feed. +// - max: the final chronological merge is trimmed to max total +// entries. +// +// Pass 0 for either cap to disable it. func StatusStream(max int) []StatusEntry { + return StatusStreamCapped(max, StatusStreamPerUser) +} + +// StatusStreamCapped is the underlying implementation with explicit +// per-user and total caps. Exported so the profile page and any future +// callers can pick their own shape. +func StatusStreamCapped(maxTotal, maxPerUser int) []StatusEntry { profileMutex.RLock() defer profileMutex.RUnlock() @@ -317,33 +334,45 @@ func StatusStream(max int) []StatusEntry { } else if p.UserID == app.SystemUserID { name = app.SystemUserName } - // Current status — latest entry for this user. + + // Collect this user's eligible entries (current + history), + // newest first. We apply the per-user cap to this collection + // BEFORE merging, so an older entry from a flooding user can't + // push a more recent entry from another user off the end. + var userEntries []StatusEntry if p.Status != "" && !p.UpdatedAt.Before(cutoff) { - entries = append(entries, StatusEntry{ + userEntries = append(userEntries, StatusEntry{ UserID: p.UserID, Name: name, Status: p.Status, UpdatedAt: p.UpdatedAt, }) } - // History entries — also within cutoff. for _, h := range p.History { if h.SetAt.Before(cutoff) { continue } - entries = append(entries, StatusEntry{ + userEntries = append(userEntries, StatusEntry{ UserID: p.UserID, Name: name, Status: h.Status, UpdatedAt: h.SetAt, }) } + // History is stored newest-first already, and the current + // status (if present) is always newer than any history entry, + // so userEntries is already in the right order. + if maxPerUser > 0 && len(userEntries) > maxPerUser { + userEntries = userEntries[:maxPerUser] + } + entries = append(entries, userEntries...) } + sort.Slice(entries, func(i, j int) bool { return entries[i].UpdatedAt.After(entries[j].UpdatedAt) }) - if len(entries) > max { - entries = entries[:max] + if maxTotal > 0 && len(entries) > maxTotal { + entries = entries[:maxTotal] } return entries } @@ -670,9 +699,26 @@ var avatarColors = []string{ } // StatusStreamMax is the maximum number of entries rendered on the home -// status card. The card is scrollable, so this mostly caps memory/render -// cost rather than what the user can see. -const StatusStreamMax = 50 +// status card. The card is scrollable but past ~30 entries the scroll +// becomes noise rather than signal, so we cap below the visual ceiling. +// Overridable via the STATUS_STREAM_LIMIT environment variable. +var StatusStreamMax = envInt("STATUS_STREAM_LIMIT", 30) + +// StatusStreamPerUser caps how many entries from any one user appear in +// the visible stream. Without this, a single chatty user (or a long +// @micro conversation) will flood the feed and push everyone else off. +// Overridable via STATUS_STREAM_LIMIT_PER_USER. +var StatusStreamPerUser = envInt("STATUS_STREAM_LIMIT_PER_USER", 10) + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 { + return n + } + } + return def +} // RenderStatusStream renders the inner markup of the home status card: // the compose form (when a viewer is logged in) plus the scrollable