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(
- `
`,
- 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(
+ `
`,
+ 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)))
+}