agentbbs/plugins/members/members.go
Anthony Ettinger 042166b05f members: show joined date in admin + public lists; 'last active: X ago'
Admin Users view appends 'joined YYYY-MM-DD'. Public member directory
shows 'last active: N days ago' for offline members plus the joined date,
both sourced from store.User.CreatedAt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 02:40:24 +00:00

538 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, marked: map[string]bool{}}
}
// 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
joined time.Time
}
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 (single)
marked map[string]bool // group selection (member name → selected)
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)], joined: u.CreatedAt}
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 {
n int
err error
}
// send delivers body to the current compose recipients (the group selection if
// any, else the single m.target) in one shot.
func (m *model) send(body string) tea.Cmd {
st := m.ctx.Store
from := m.user.Name
to := m.recipients()
return func() tea.Msg {
n, err := st.SendMessageMulti(from, to, body)
return sentMsg{n: n, err: err}
}
}
// recipients is who a compose will be sent to: the group selection (sorted, the
// directory order) when one exists, otherwise just m.target.
func (m *model) recipients() []string {
if len(m.marked) == 0 {
if m.target == "" {
return nil
}
return []string{m.target}
}
var out []string
for _, p := range m.people {
if m.marked[p.name] {
out = append(out, p.name)
}
}
return out
}
// recipientLabel summarizes the compose audience for the header.
func (m *model) recipientLabel() string {
r := m.recipients()
switch len(r) {
case 0:
return "(nobody)"
case 1:
return r[0]
case 2, 3:
return strings.Join(r, ", ")
default:
return fmt.Sprintf("%d members", len(r))
}
}
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 {
if msg.n == 1 {
m.note = "✓ message sent to " + m.target
} else {
m.note = fmt.Sprintf("✓ message sent to %d members", msg.n)
}
m.marked = map[string]bool{} // clear the group after sending
m.state = stList
}
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 " ":
// Toggle the member under the cursor in/out of the group selection.
if p := m.selected(); p != nil {
if m.marked[p.name] {
delete(m.marked, p.name)
} else {
m.marked[p.name] = true
}
}
case "a":
// Select all, or clear if everyone is already selected.
if len(m.marked) == len(m.people) {
m.marked = map[string]bool{}
} else {
m.marked = make(map[string]bool, len(m.people))
for _, p := range m.people {
m.marked[p.name] = true
}
}
case "enter":
if p := m.selected(); p != nil {
m.target = p.name
m.state = stProfile
}
case "m":
// With a group selected, compose to all of them; otherwise to the
// member under the cursor.
if len(m.marked) > 0 {
m.draft = ""
m.state = stCompose
} else 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:
// Group compose has no single profile to return to.
if len(m.marked) > 0 {
m.state = stList
} else {
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(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("●")
}
box := dim.Render("[ ]")
if m.marked[p.name] {
box = on.Render("[x]")
}
name := p.name
c := " "
if i == m.cursor {
c = cur.Render(" ")
name = sel.Render(name)
}
seen := "online now"
if !p.online {
seen = "last active: " + relTimeLong(p.lastSeen, p.seenOK)
}
if !p.joined.IsZero() {
seen += " · joined " + p.joined.Format("2006-01-02")
}
row := fmt.Sprintf("%s%s %s %-20s %-8s %s", c, box, dot, name, p.kind, dim.Render(seen))
s += row + "\n"
}
if n := len(m.marked); n > 0 {
s += "\n" + on.Render(fmt.Sprintf("%d selected", n)) +
dim.Render(" — m to message the group")
}
s += "\n" + dim.Render("↑/↓ move · space select · a all · m message · enter finger · i inbox · 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 {
to := m.recipientLabel()
s := hdr.Render("message "+to) + "\n\n"
s += dim.Render("from "+m.user.Name+" → "+to) + "\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))
}
}
// relTimeLong renders a spelled-out "3 days ago" / "2 hours ago" / "just now"
// age for the public directory. ok=false → "never".
func relTimeLong(t time.Time, ok bool) string {
if !ok || t.IsZero() {
return "never"
}
d := time.Since(t)
plural := func(n int, unit string) string {
if n == 1 {
return fmt.Sprintf("1 %s ago", unit)
}
return fmt.Sprintf("%d %ss ago", n, unit)
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d.Minutes()), "minute")
case d < 24*time.Hour:
return plural(int(d.Hours()), "hour")
default:
return plural(int(d.Hours()/24), "day")
}
}