mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Feat/members messaging (#29)
* fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go linker's peak memory OOM-killed the build — and with it the sshd serving the deploy session — surfacing as "Connection closed by remote host" (exit 255). It was flaky because it tracked momentary memory pressure from the co-resident ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on near-identical code). Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain download when SKIP_BUILD=1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(members): member directory + store-and-forward messaging A members-only hub plugin (the BBS "who") plus user-to-user messaging: - internal/store: messages table + SendMessage/Inbox/UnreadCount/MarkRead, and OnlineUsers (open sessions) for presence. MarkRead is recipient-scoped so a member can only clear their own mail. - plugins/members: directory with online dots + last-seen, a finger-style profile view, a minimal compose box, and an inbox that marks read on open. - ssh msg@host <user> [text]: scriptable CLI to leave a note (body from args or stdin), mirroring the existing finger route; "msg"/"message" are reserved. - hub: "N unread" badge on login (hubMOTD). plugin.Context gains Host for member homepage URLs. Extends the existing finger@ behavior (ssh <name>@host) rather than replacing it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d763d732a3
commit
362b47fdde
6 changed files with 687 additions and 4 deletions
|
|
@ -8,6 +8,8 @@
|
|||
// emailed code, then offers $99 Founding Lifetime (CoinPay)
|
||||
// ssh pod@host your personal Linux pod — free for verified members
|
||||
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
|
||||
// ssh <name>@host (from another account) prints a finger card for that member
|
||||
// ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin
|
||||
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
|
||||
// ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
|
||||
// agent-vs-agent (also on wss://host/play). See docs/agentgames.md
|
||||
|
|
@ -72,6 +74,7 @@ import (
|
|||
"github.com/profullstack/agentbbs/plugins/about"
|
||||
"github.com/profullstack/agentbbs/plugins/agentgames"
|
||||
"github.com/profullstack/agentbbs/plugins/arcade"
|
||||
"github.com/profullstack/agentbbs/plugins/members"
|
||||
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
|
||||
)
|
||||
|
||||
|
|
@ -171,7 +174,7 @@ func main() {
|
|||
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
||||
time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
|
||||
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
|
||||
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}}
|
||||
|
||||
// Custom domains: maintain the symlink farm Caddy serves and answer its
|
||||
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
|
||||
|
|
@ -318,6 +321,8 @@ func (a *app) router() wish.Middleware {
|
|||
a.handleNews(s)
|
||||
case auth.IsMailName(user):
|
||||
a.handleMail(s)
|
||||
case auth.IsMsgName(user):
|
||||
a.handleMsg(s)
|
||||
case isVideo:
|
||||
a.handleVideo(s, code)
|
||||
case user == "agent":
|
||||
|
|
@ -345,7 +350,11 @@ func (a *app) hubMOTD(u auth.User) string {
|
|||
return "You're browsing as a guest.\n" + body +
|
||||
"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
|
||||
}
|
||||
return "Welcome back, " + u.Name + ".\n" + body
|
||||
welcome := "Welcome back, " + u.Name + "."
|
||||
if n, err := a.st.UnreadCount(u.Name); err == nil && n > 0 {
|
||||
welcome += fmt.Sprintf(" 📬 %d unread — open Members ▸ inbox (i).", n)
|
||||
}
|
||||
return welcome + "\n" + body
|
||||
}
|
||||
|
||||
// teaHandler builds the hub model for guests, members, and agents.
|
||||
|
|
@ -394,7 +403,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|||
sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
|
||||
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
|
||||
|
||||
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets}
|
||||
ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets, Host: a.host}
|
||||
if u.Kind != auth.Guest {
|
||||
ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
|
||||
_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
|
||||
|
|
@ -1421,6 +1430,62 @@ func (a *app) handleChat(s ssh.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// handleMsg is the member-to-member messaging route: `ssh msg@host <user> [text]`
|
||||
// leaves a note in <user>'s BBS inbox. The body is the remaining args, or stdin
|
||||
// when none are given (so `echo hi | ssh msg@host bob` works). Members only;
|
||||
// the recipient reads it in the hub's Members ▸ inbox.
|
||||
func (a *app) handleMsg(s ssh.Session) {
|
||||
fp := auth.Fingerprint(s.PublicKey())
|
||||
if fp == "" {
|
||||
wish.Println(s, "msg@ needs your registered SSH key. New here? ssh join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
from, found, err := a.st.UserByFingerprint(fp)
|
||||
if err != nil || !found {
|
||||
wish.Println(s, "key not registered — run: ssh join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
args := s.Command()
|
||||
if len(args) == 0 {
|
||||
wish.Println(s, "usage: ssh msg@"+a.host+" <user> [message] (or pipe the message on stdin)")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
to := strings.ToLower(args[0])
|
||||
recipient, ok, err := a.st.UserByName(to)
|
||||
if err != nil || !ok {
|
||||
wish.Println(s, "no member named "+to+" — check the spelling (ssh "+to+"@"+a.host+" to finger).")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if recipient.Name == from.Name {
|
||||
wish.Println(s, "you can't message yourself.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(strings.Join(args[1:], " "))
|
||||
if body == "" {
|
||||
// No inline text — read the message from stdin (piped, or typed then ^D).
|
||||
b, _ := io.ReadAll(io.LimitReader(s, 64*1024))
|
||||
body = strings.TrimSpace(string(b))
|
||||
}
|
||||
if body == "" {
|
||||
wish.Println(s, "empty message — nothing sent.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if err := a.st.SendMessage(from.Name, recipient.Name, body); err != nil {
|
||||
wish.Println(s, "could not send: "+err.Error())
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
_, _ = a.st.RecordSession(from.ID, s.User(), remoteIP(s), "msg")
|
||||
wish.Println(s, "✓ message left for "+recipient.Name+" — they'll see it in Members ▸ inbox.")
|
||||
_ = s.Exit(0)
|
||||
}
|
||||
|
||||
// handleFinger prints a classic finger card when someone ssh's to an
|
||||
// existing account name that isn't their own (e.g. ssh anthony@host).
|
||||
// Returns false when the route should fall through to the hub.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
|||
// IsMailName reports whether the SSH username requests the AgentMail client.
|
||||
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
||||
|
||||
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
|
||||
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
|
||||
var MsgNames = map[string]bool{"msg": true, "message": true}
|
||||
|
||||
// IsMsgName reports whether the SSH username requests the messaging route.
|
||||
func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] }
|
||||
|
||||
// systemReserved are names that don't drive an SSH route but would still
|
||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||
// infra hostnames — so members may not claim them as account names.
|
||||
|
|
@ -119,7 +126,8 @@ var systemReserved = map[string]bool{
|
|||
func IsReservedName(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] {
|
||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
|
||||
MsgNames[n] || systemReserved[n] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ type Context struct {
|
|||
DataDir string
|
||||
// AssetsDir is the read-only platform assets tree (wads, binaries).
|
||||
AssetsDir string
|
||||
// Host is the BBS hostname (e.g. bbs.profullstack.com), for building
|
||||
// member homepage URLs (https://Host/~name) and similar links.
|
||||
Host string
|
||||
}
|
||||
|
||||
// Plugin is the only integration point between a feature and the hub.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package store
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
|
@ -45,6 +46,16 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// Message is one member-to-member note in the store-and-forward inbox.
|
||||
type Message struct {
|
||||
ID int64
|
||||
From string
|
||||
To string
|
||||
Body string
|
||||
Read bool
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
type Score struct {
|
||||
User string
|
||||
|
|
@ -100,6 +111,21 @@ type Store interface {
|
|||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, error)
|
||||
|
||||
// Member-to-member messaging (store-and-forward inbox).
|
||||
|
||||
// SendMessage leaves a note from→to in the recipient's inbox.
|
||||
SendMessage(from, to, body string) error
|
||||
// Inbox returns up to n messages addressed to username, newest first.
|
||||
Inbox(username string, n int) ([]Message, error)
|
||||
// UnreadCount reports how many unread messages username has waiting.
|
||||
UnreadCount(username string) (int, error)
|
||||
// MarkRead marks the given message ids read (scoped to username so a member
|
||||
// can only clear their own mail). Empty ids is a no-op.
|
||||
MarkRead(username string, ids []int64) error
|
||||
// OnlineUsers reports the set of usernames with an open session (no
|
||||
// ended_at), for the members directory presence dots.
|
||||
OnlineUsers() (map[string]bool, error)
|
||||
|
||||
// Custom domains mapped to a member's homepage (public_html).
|
||||
// MapDomain binds domain→username, returning ErrDomainTaken if it is
|
||||
// already claimed by someone else (re-binding to the same owner is a no-op).
|
||||
|
|
@ -471,6 +497,15 @@ CREATE TABLE IF NOT EXISTS news_articles (
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
@ -686,6 +721,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SendMessage(from, to, body string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`,
|
||||
from, to, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
|
||||
if n <= 0 {
|
||||
n = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, from_user, to_user, body, read, created_at
|
||||
FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
var read int
|
||||
var at string
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Read = read != 0
|
||||
m.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UnreadCount(username string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MarkRead(username string, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, username)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
_, err := s.db.Exec(q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) OnlineUsers() (map[string]bool, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
online := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
online[strings.ToLower(name)] = true
|
||||
}
|
||||
return online, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MapDomain(domain, username string) error {
|
||||
var owner string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
|
||||
|
|
|
|||
82
internal/store/store_messages_test.go
Normal file
82
internal/store/store_messages_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMessagingRoundtrip(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
_, _ = st.EnsureUser("alice", "member", "SHA256:aaa")
|
||||
_, _ = st.EnsureUser("bob", "member", "SHA256:bbb")
|
||||
|
||||
if n, err := st.UnreadCount("bob"); err != nil || n != 0 {
|
||||
t.Fatalf("fresh unread: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := st.SendMessage("alice", "bob", "second note"); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
|
||||
n, err := st.UnreadCount("bob")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("unread after send: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
inbox, err := st.Inbox("bob", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("inbox: %v", err)
|
||||
}
|
||||
if len(inbox) != 2 {
|
||||
t.Fatalf("want 2 messages, got %d", len(inbox))
|
||||
}
|
||||
// Newest first.
|
||||
if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" {
|
||||
t.Fatalf("unexpected newest message: %+v", inbox[0])
|
||||
}
|
||||
|
||||
// Mark only the first read; the other stays unread.
|
||||
if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil {
|
||||
t.Fatalf("markread: %v", err)
|
||||
}
|
||||
if n, _ := st.UnreadCount("bob"); n != 1 {
|
||||
t.Fatalf("want 1 unread after partial read, got %d", n)
|
||||
}
|
||||
|
||||
// MarkRead is scoped to the recipient: alice can't clear bob's mail.
|
||||
if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil {
|
||||
t.Fatalf("markread other: %v", err)
|
||||
}
|
||||
if n, _ := st.UnreadCount("bob"); n != 1 {
|
||||
t.Fatalf("cross-user markread leaked: unread=%d", n)
|
||||
}
|
||||
|
||||
// Empty ids is a no-op.
|
||||
if err := st.MarkRead("bob", nil); err != nil {
|
||||
t.Fatalf("markread empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineUsers(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
u, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
|
||||
id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub")
|
||||
|
||||
online, err := st.OnlineUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("online: %v", err)
|
||||
}
|
||||
if !online["carol"] {
|
||||
t.Fatal("carol should be online while her session is open")
|
||||
}
|
||||
|
||||
if err := st.EndSession(id); err != nil {
|
||||
t.Fatalf("end: %v", err)
|
||||
}
|
||||
online, _ = st.OnlineUsers()
|
||||
if online["carol"] {
|
||||
t.Fatal("carol should be offline after her session ends")
|
||||
}
|
||||
}
|
||||
421
plugins/members/members.go
Normal file
421
plugins/members/members.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
// Package members is the member directory + messaging plugin (the BBS "who" and
|
||||
// store-and-forward inbox). Members browse who else has an account, see who is
|
||||
// online now, finger a profile, leave a message, and read their own inbox.
|
||||
//
|
||||
// It is members-only (RequiresAuth) — guests have no identity to send from or
|
||||
// receive to. Messaging is store-and-forward via the store's messages table;
|
||||
// the same inbox is fed by the `ssh msg@host <user>` CLI route.
|
||||
package members
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (Plugin) ID() string { return "members" }
|
||||
func (Plugin) Title() string { return "Members" }
|
||||
func (Plugin) Description() string { return "Who's here · finger a profile · leave a message · inbox" }
|
||||
func (Plugin) RequiresAuth() bool { return true }
|
||||
|
||||
func (Plugin) New(user auth.User, ctx plugin.Context) tea.Model {
|
||||
return &model{user: user, ctx: ctx, state: stList}
|
||||
}
|
||||
|
||||
// state is which sub-screen is showing.
|
||||
type state int
|
||||
|
||||
const (
|
||||
stList state = iota
|
||||
stProfile
|
||||
stCompose
|
||||
stInbox
|
||||
)
|
||||
|
||||
// person is one directory row.
|
||||
type person struct {
|
||||
name string
|
||||
kind string
|
||||
online bool
|
||||
lastSeen time.Time
|
||||
seenOK bool
|
||||
}
|
||||
|
||||
type model struct {
|
||||
user auth.User
|
||||
ctx plugin.Context
|
||||
state state
|
||||
|
||||
people []person
|
||||
inbox []store.Message
|
||||
cursor int // list/inbox cursor
|
||||
target string // who we're fingering/composing to
|
||||
|
||||
draft string // compose buffer
|
||||
note string // transient status line
|
||||
err error
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
// --- loading ---------------------------------------------------------------
|
||||
|
||||
type loadedMsg struct {
|
||||
people []person
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *model) load() tea.Cmd {
|
||||
st := m.ctx.Store
|
||||
me := m.user.Name
|
||||
return func() tea.Msg {
|
||||
users, err := st.ListUsers(500)
|
||||
if err != nil {
|
||||
return loadedMsg{err: err}
|
||||
}
|
||||
online, _ := st.OnlineUsers()
|
||||
out := make([]person, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.Name == me {
|
||||
continue // don't list yourself in the directory
|
||||
}
|
||||
p := person{name: u.Name, kind: u.Kind, online: online[strings.ToLower(u.Name)]}
|
||||
if t, ok, _ := st.LastSeen(u.ID); ok {
|
||||
p.lastSeen, p.seenOK = t, true
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
// Online first, then most-recently-seen, then name.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].online != out[j].online {
|
||||
return out[i].online
|
||||
}
|
||||
if out[i].seenOK != out[j].seenOK {
|
||||
return out[i].seenOK
|
||||
}
|
||||
if out[i].seenOK && !out[i].lastSeen.Equal(out[j].lastSeen) {
|
||||
return out[i].lastSeen.After(out[j].lastSeen)
|
||||
}
|
||||
return out[i].name < out[j].name
|
||||
})
|
||||
return loadedMsg{people: out}
|
||||
}
|
||||
}
|
||||
|
||||
type inboxMsg struct {
|
||||
msgs []store.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *model) loadInbox() tea.Cmd {
|
||||
st := m.ctx.Store
|
||||
me := m.user.Name
|
||||
return func() tea.Msg {
|
||||
msgs, err := st.Inbox(me, 100)
|
||||
if err != nil {
|
||||
return inboxMsg{err: err}
|
||||
}
|
||||
// Opening the inbox marks everything read.
|
||||
var unread []int64
|
||||
for _, mm := range msgs {
|
||||
if !mm.Read {
|
||||
unread = append(unread, mm.ID)
|
||||
}
|
||||
}
|
||||
_ = st.MarkRead(me, unread)
|
||||
return inboxMsg{msgs: msgs}
|
||||
}
|
||||
}
|
||||
|
||||
type sentMsg struct{ err error }
|
||||
|
||||
func (m *model) send(to, body string) tea.Cmd {
|
||||
st := m.ctx.Store
|
||||
from := m.user.Name
|
||||
return func() tea.Msg { return sentMsg{err: st.SendMessage(from, to, body)} }
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd { return m.load() }
|
||||
|
||||
// --- update ----------------------------------------------------------------
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
return m, nil
|
||||
case loadedMsg:
|
||||
m.people, m.err = msg.people, msg.err
|
||||
if m.cursor >= len(m.people) {
|
||||
m.cursor = 0
|
||||
}
|
||||
return m, nil
|
||||
case inboxMsg:
|
||||
m.inbox, m.err = msg.msgs, msg.err
|
||||
return m, nil
|
||||
case sentMsg:
|
||||
if msg.err != nil {
|
||||
m.note = "send failed: " + msg.err.Error()
|
||||
} else {
|
||||
m.note = "✓ message sent to " + m.target
|
||||
m.state = stProfile
|
||||
}
|
||||
m.draft = ""
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.state == stCompose {
|
||||
return m.composeKey(k)
|
||||
}
|
||||
m.note = ""
|
||||
switch m.state {
|
||||
case stList:
|
||||
switch k.String() {
|
||||
case "q", "esc":
|
||||
return m, plugin.Exit
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.people)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "i":
|
||||
m.state = stInbox
|
||||
m.cursor = 0
|
||||
return m, m.loadInbox()
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "enter":
|
||||
if p := m.selected(); p != nil {
|
||||
m.target = p.name
|
||||
m.state = stProfile
|
||||
}
|
||||
case "m":
|
||||
if p := m.selected(); p != nil {
|
||||
m.target = p.name
|
||||
m.draft = ""
|
||||
m.state = stCompose
|
||||
}
|
||||
}
|
||||
case stProfile:
|
||||
switch k.String() {
|
||||
case "q", "esc", "backspace":
|
||||
m.state = stList
|
||||
case "m":
|
||||
m.draft = ""
|
||||
m.state = stCompose
|
||||
}
|
||||
case stInbox:
|
||||
switch k.String() {
|
||||
case "q", "esc", "backspace":
|
||||
m.state = stList
|
||||
return m, m.load() // refresh unread badge state
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.inbox)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// composeKey runs the minimal one-line message editor.
|
||||
func (m *model) composeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.state = stProfile
|
||||
m.draft = ""
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
body := strings.TrimSpace(m.draft)
|
||||
if body == "" {
|
||||
m.note = "type a message first (esc to cancel)"
|
||||
return m, nil
|
||||
}
|
||||
return m, m.send(m.target, body)
|
||||
case tea.KeyBackspace, tea.KeyDelete:
|
||||
if n := len(m.draft); n > 0 {
|
||||
r := []rune(m.draft)
|
||||
m.draft = string(r[:len(r)-1])
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeySpace:
|
||||
m.draft += " "
|
||||
return m, nil
|
||||
case tea.KeyRunes:
|
||||
m.draft += string(k.Runes)
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) selected() *person {
|
||||
if m.cursor < 0 || m.cursor >= len(m.people) {
|
||||
return nil
|
||||
}
|
||||
return &m.people[m.cursor]
|
||||
}
|
||||
|
||||
// --- view ------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
hdr = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
sel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
|
||||
on = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
off = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
warn = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
cur = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
frame = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
func (m *model) View() string {
|
||||
var s string
|
||||
switch m.state {
|
||||
case stProfile:
|
||||
s = m.profileView()
|
||||
case stCompose:
|
||||
s = m.composeView()
|
||||
case stInbox:
|
||||
s = m.inboxView()
|
||||
default:
|
||||
s = m.listView()
|
||||
}
|
||||
if m.note != "" {
|
||||
s += "\n" + warn.Render(m.note)
|
||||
}
|
||||
return frame.Render(s)
|
||||
}
|
||||
|
||||
func (m *model) listView() string {
|
||||
s := hdr.Render("Members") + dim.Render(" · who's here") + "\n\n"
|
||||
if m.err != nil {
|
||||
return s + warn.Render("error: "+m.err.Error())
|
||||
}
|
||||
if len(m.people) == 0 {
|
||||
return s + dim.Render("no members yet")
|
||||
}
|
||||
for i, p := range m.people {
|
||||
dot := off.Render("○")
|
||||
if p.online {
|
||||
dot = on.Render("●")
|
||||
}
|
||||
name := p.name
|
||||
c := " "
|
||||
if i == m.cursor {
|
||||
c = cur.Render("❯ ")
|
||||
name = sel.Render(name)
|
||||
}
|
||||
seen := "online"
|
||||
if !p.online {
|
||||
seen = "last " + relTime(p.lastSeen, p.seenOK)
|
||||
}
|
||||
row := fmt.Sprintf("%s%s %-20s %-8s %s", c, dot, name, p.kind, dim.Render(seen))
|
||||
s += row + "\n"
|
||||
}
|
||||
s += "\n" + dim.Render("↑/↓ move · enter finger · m message · i inbox · r refresh · q back")
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *model) profileView() string {
|
||||
p := m.find(m.target)
|
||||
s := hdr.Render("finger "+m.target) + "\n\n"
|
||||
if p == nil {
|
||||
return s + dim.Render("unknown member")
|
||||
}
|
||||
status := off.Render("offline") + dim.Render(" · last "+relTime(p.lastSeen, p.seenOK))
|
||||
if p.online {
|
||||
status = on.Render("online now")
|
||||
}
|
||||
home := "~" + p.name
|
||||
if m.ctx.Host != "" {
|
||||
home = "https://" + m.ctx.Host + "/~" + p.name
|
||||
}
|
||||
lines := []string{
|
||||
" Login: " + sel.Render(p.name) + " Kind: " + p.kind,
|
||||
" Status: " + status,
|
||||
" Home: " + dim.Render(home),
|
||||
}
|
||||
s += strings.Join(lines, "\n")
|
||||
s += "\n\n" + dim.Render("m message "+p.name+" · esc back")
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *model) composeView() string {
|
||||
s := hdr.Render("message "+m.target) + "\n\n"
|
||||
s += dim.Render("from "+m.user.Name+" → "+m.target) + "\n\n"
|
||||
s += " " + m.draft + cur.Render("▏") + "\n\n"
|
||||
s += dim.Render("enter send · esc cancel")
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *model) inboxView() string {
|
||||
s := hdr.Render("Inbox") + dim.Render(" · "+m.user.Name) + "\n\n"
|
||||
if m.err != nil {
|
||||
return s + warn.Render("error: "+m.err.Error())
|
||||
}
|
||||
if len(m.inbox) == 0 {
|
||||
return s + dim.Render("no messages — select a member and press m to send one") +
|
||||
"\n\n" + dim.Render("esc back")
|
||||
}
|
||||
for i, msg := range m.inbox {
|
||||
c := " "
|
||||
from := msg.From
|
||||
if i == m.cursor {
|
||||
c = cur.Render("❯ ")
|
||||
from = sel.Render(from)
|
||||
}
|
||||
s += fmt.Sprintf("%s%-16s %s\n", c, from, dim.Render(relTime(msg.At, true)+" ago"))
|
||||
s += " " + msg.Body + "\n"
|
||||
}
|
||||
s += "\n" + dim.Render("↑/↓ scroll · esc back")
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *model) find(name string) *person {
|
||||
for i := range m.people {
|
||||
if m.people[i].name == name {
|
||||
return &m.people[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// relTime renders a coarse "2h", "3d", "just now" style age. ok=false → "never".
|
||||
func relTime(t time.Time, ok bool) string {
|
||||
if !ok || t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue