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
157 changes: 112 additions & 45 deletions home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,55 +291,22 @@ func Handler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
b.WriteString(fmt.Sprintf(`<p id="home-date">%s</p>`, 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(`<form id="home-status-form" method="POST" action="/user/status"><input type="text" name="status" placeholder="What's your status?" maxlength="100" id="home-status-input"></form>`)
}
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(`<div id="home-statuses">`)
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 = ` <a href="/user/status" onclick="event.preventDefault();fetch('/user/status',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'status='}).then(()=>location.reload())" class="home-status-clear" title="Clear status">✕</a>`
}
sc.WriteString(fmt.Sprintf(
`<div class="%s"><div class="home-status-avatar" style="background:%s">%s</div><div class="home-status-body"><div class="home-status-header"><a href="/@%s" class="home-status-name">%s</a>%s<span class="home-status-time">%s</span></div><div class="home-status-text">%s</div></div></div>`,
entryClass, color, initial, htmlEsc(s.UserID), htmlEsc(s.Name), clearBtn, app.TimeAgo(s.UpdatedAt), htmlEsc(s.Status)))
}
sc.WriteString(`</div>`)
}
statusCardHTML = fmt.Sprintf(app.CardTemplate, "status", "status", "Status", sc.String())
}
statusInner := user.RenderStatusStream(viewerID)
statusCardBody := `<div id="home-status-wrap">` + statusInner + `</div>` + statusCardScript
statusCardHTML := fmt.Sprintf(
app.CardTemplate,
"status", "status", "Status",
statusCardBody,
)

// Feed section — existing home cards below the agent
var leftHTML []string
Expand Down Expand Up @@ -438,3 +405,103 @@ func htmlEsc(s string) string {
s = strings.ReplaceAll(s, "'", "&#39;")
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 = `<script>
(function(){
var wrap = document.getElementById('home-status-wrap');
if (!wrap) return;
var pollInterval = 10000;
var inflight = false;

function csrfToken() {
var m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}

function currentInput() {
var el = document.getElementById('home-status-input');
return el ? { value: el.value, focused: document.activeElement === el } : null;
}
function restoreInput(saved) {
if (!saved) return;
var el = document.getElementById('home-status-input');
if (!el) return;
el.value = saved.value;
if (saved.focused) {
el.focus();
try { el.setSelectionRange(el.value.length, el.value.length); } catch(e){}
}
}

function refresh() {
if (inflight) return;
inflight = true;
fetch('/user/status/stream', { credentials: 'same-origin', cache: 'no-store' })
.then(function(r){ return r.ok ? r.text() : null; })
.then(function(html){
if (html == null) return;
var saved = currentInput();
wrap.innerHTML = html;
restoreInput(saved);
bindForm();
})
.catch(function(){})
.then(function(){ inflight = false; });
}

function bindForm() {
var form = document.getElementById('home-status-form');
if (!form || form.dataset.bound) return;
form.dataset.bound = '1';
form.addEventListener('submit', function(ev){
ev.preventDefault();
var input = document.getElementById('home-status-input');
if (!input) return;
var text = input.value.trim();
if (!text) return;
var body = new URLSearchParams();
body.set('status', text);
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
var tok = csrfToken();
if (tok) headers['X-CSRF-Token'] = tok;
fetch('/user/status', {
method: 'POST',
credentials: 'same-origin',
headers: headers,
body: body.toString()
}).then(function(){
input.value = '';
refresh();
}).catch(function(){
// Fall back to a native form submit on network error.
form.submit();
});
});
}

bindForm();

// Poll while the tab is visible.
setInterval(function(){
if (document.hidden) return;
refresh();
}, pollInterval);

// Fetch immediately when the tab regains focus.
document.addEventListener('visibilitychange', function(){
if (!document.hidden) refresh();
});
})();
</script>`
44 changes: 26 additions & 18 deletions internal/app/html/mu.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading