mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
538 lines
13 KiB
Go
538 lines
13 KiB
Go
// 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")
|
||
}
|
||
}
|