diff --git a/home/home.go b/home/home.go index 81cde1ca..0d932f43 100644 --- a/home/home.go +++ b/home/home.go @@ -291,55 +291,22 @@ func Handler(w http.ResponseWriter, r *http.Request) { now := time.Now() b.WriteString(fmt.Sprintf(`

%s

`, now.Format("Monday, 2 January 2006"))) - // Status card content (will be prepended to left column) - var statusCardHTML string + // Status card content (will be prepended to left column). + // Built by user.RenderStatusStream so the fragment endpoint and the + // home card share one code path. The #home-status-wrap element is + // polled every ~10 seconds for near-real-time updates, and the + // compose form submits via fetch so the stream refreshes in place. var viewerID string if sess, _ := auth.TrySession(r); sess != nil { viewerID = sess.Account } - statuses := user.RecentStatuses(viewerID, 10) - if viewerID != "" || len(statuses) > 0 { - var sc strings.Builder - if viewerID != "" { - sc.WriteString(`
`) - } - if len(statuses) > 0 { - avatarColors := []string{ - "#56a8a1", // teal - "#8e7cc3", // purple - "#e8a87c", // pastel orange - "#5c9ecf", // blue - "#e06c75", // rose - "#c2785c", // terracotta - "#7bab6e", // sage - "#9e7db8", // lavender - } - sc.WriteString(`
`) - for _, s := range statuses { - 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)] - isMe := s.UserID == viewerID - entryClass := "home-status-entry" - clearBtn := "" - if isMe { - entryClass += " home-status-mine" - clearBtn = ` ` - } - sc.WriteString(fmt.Sprintf( - `
%s
%s%s%s
%s
`, - entryClass, color, initial, htmlEsc(s.UserID), htmlEsc(s.Name), clearBtn, app.TimeAgo(s.UpdatedAt), htmlEsc(s.Status))) - } - sc.WriteString(`
`) - } - statusCardHTML = fmt.Sprintf(app.CardTemplate, "status", "status", "Status", sc.String()) - } + statusInner := user.RenderStatusStream(viewerID) + statusCardBody := `
` + statusInner + `
` + statusCardScript + statusCardHTML := fmt.Sprintf( + app.CardTemplate, + "status", "status", "Status", + statusCardBody, + ) // Feed section — existing home cards below the agent var leftHTML []string @@ -438,3 +405,103 @@ func htmlEsc(s string) string { s = strings.ReplaceAll(s, "'", "'") return s } + +// statusCardScript wires the status card for live updates: +// +// - Polls /user/status/stream every 10 seconds and swaps the inner +// markup of #home-status-wrap, preserving whatever the user is +// currently typing in the compose input. +// - Intercepts the compose form submit so it POSTs via fetch and +// then refreshes the stream in place (no full page reload). +// - Keeps the stream scrolled to the top after a refresh so new +// messages are always visible. +// +// The script is defensive: if anything throws, the form still falls +// back to its native POST + redirect behaviour. +const statusCardScript = `` 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..a7d36911 --- /dev/null +++ b/user/status_test.go @@ -0,0 +1,231 @@ +package user + +import ( + "fmt" + "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_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 + 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..9b5b1cfa 100644 --- a/user/user.go +++ b/user/user.go @@ -3,7 +3,10 @@ package user import ( "encoding/json" "fmt" + htmlpkg "html" "net/http" + "os" + "sort" "strings" "sync" "time" @@ -59,7 +62,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 +217,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 +292,106 @@ 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. +// +// 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() + + 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 + } + + // 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) { + userEntries = append(userEntries, StatusEntry{ + UserID: p.UserID, + Name: name, + Status: p.Status, + UpdatedAt: p.UpdatedAt, + }) + } + for _, h := range p.History { + if h.SetAt.Before(cutoff) { + continue } + 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...) } - if len(entries) > max { - entries = entries[:max] + + sort.Slice(entries, func(i, j int) bool { + return entries[i].UpdatedAt.After(entries[j].UpdatedAt) + }) + if maxTotal > 0 && len(entries) > maxTotal { + entries = entries[:maxTotal] } 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 +404,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 +437,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 +514,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 +685,102 @@ 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 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 +// 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))) +}