2a6b3830d5
- cmd/tui/main.go: entry point with --server, --token, --version flags
- cmd/tui/config.go: config from ~/.config/dumpster/config.yaml or env vars
- cmd/tui/auth.go: login flow, session cookie management
- cmd/tui/api.go: HTTP client for servers, channels, messages, voice
- cmd/tui/model.go: root bubbletea model with state management
- cmd/tui/theme.go: Gruvbox dark lipgloss styles
- cmd/tui/servers.go: server bar with [D] style initials
- cmd/tui/channels.go: channel list with #text and 🔊 voice
- cmd/tui/chat.go: IRC-style message viewport with colored usernames
- cmd/tui/input.go: message input with > _ prompt
- cmd/tui/members.go: member list with ●/◐/○ status
- cmd/tui/ws.go: WebSocket client for real-time events
- cmd/tui/voice.go: voice state tracking and mute toggle
- cmd/tui/keys.go: vim-style keybindings (j/k, g/G, Tab, /, etc)
- cmd/tui/views.go: multi-pane layout rendering
- Makefile: added build-tui and run-tui targets
- go.mod/go.sum: added charmbracelet dependencies
563 lines
11 KiB
Go
563 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
type focusArea int
|
|
|
|
const (
|
|
focusServers focusArea = iota
|
|
focusChannels
|
|
focusChat
|
|
focusInput
|
|
focusMembers
|
|
)
|
|
|
|
type model struct {
|
|
// Layout
|
|
width int
|
|
height int
|
|
|
|
// Data
|
|
user UserProfile
|
|
servers []Server
|
|
channels []Channel
|
|
messages []Message
|
|
members []MemberEntryData
|
|
|
|
// Navigation indices
|
|
currentServer int
|
|
currentChannel int // flat index in the grouped channel view
|
|
hoverServer int
|
|
hoverChannel int
|
|
|
|
// Components
|
|
viewport viewport.Model
|
|
input textinput.Model
|
|
|
|
// State
|
|
focus focusArea
|
|
showMembers bool
|
|
showHelp bool
|
|
loading bool
|
|
err string
|
|
statusMsg string
|
|
typing string // username currently typing
|
|
|
|
// Networking
|
|
api *APIClient
|
|
ws *WSConn
|
|
|
|
// Voice
|
|
voice VoiceState
|
|
|
|
// Channel ID for the currently displayed channel (to match WS events)
|
|
currentChannelID string
|
|
}
|
|
|
|
type initMsg struct{}
|
|
type serversLoadedMsg []Server
|
|
type channelsLoadedMsg []Channel
|
|
type messagesLoadedMsg []Message
|
|
type messageSentMsg struct{ msg Message }
|
|
type sendErrMsg struct{ err error }
|
|
type connectWSMsg struct {
|
|
ws *WSConn
|
|
token string
|
|
}
|
|
type channelLoadedData struct {
|
|
channels []Channel
|
|
idx int
|
|
}
|
|
|
|
func initialModel(api *APIClient) model {
|
|
vp := viewport.New(80, 20)
|
|
vp.SetContent(HelpStyle.Render(" Loading..."))
|
|
|
|
input := NewInput()
|
|
|
|
return model{
|
|
api: api,
|
|
input: input,
|
|
viewport: vp,
|
|
focus: focusServers,
|
|
loading: true,
|
|
showMembers: true,
|
|
}
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.loadInitData(),
|
|
)
|
|
}
|
|
|
|
func (m model) loadInitData() tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Load user
|
|
user, err := m.api.GetMe()
|
|
if err != nil {
|
|
log.Printf("loadInitData: GetMe: %v", err)
|
|
} else {
|
|
m.user = *user
|
|
}
|
|
|
|
// Load servers
|
|
servers, err := m.api.GetServers()
|
|
if err != nil {
|
|
return sendErrMsg{err}
|
|
}
|
|
|
|
return serversLoadedMsg(servers)
|
|
}
|
|
}
|
|
|
|
func (m model) loadChannels(serverID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
channels, err := m.api.GetChannels(serverID)
|
|
if err != nil {
|
|
return sendErrMsg{err}
|
|
}
|
|
return channelsLoadedMsg(channels)
|
|
}
|
|
}
|
|
|
|
func (m model) loadMessages(channelID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
messages, err := m.api.GetMessages(channelID, 50, "")
|
|
if err != nil {
|
|
return sendErrMsg{err}
|
|
}
|
|
return messagesLoadedMsg(messages)
|
|
}
|
|
}
|
|
|
|
func (m model) connectWS() tea.Cmd {
|
|
return func() tea.Msg {
|
|
ws, err := ConnectWS(m.api.BaseURL, m.api.SessionToken)
|
|
if err != nil {
|
|
return sendErrMsg{err}
|
|
}
|
|
ws.StartPing()
|
|
return connectWSMsg{ws: ws}
|
|
}
|
|
}
|
|
|
|
func (m model) sendMessage() tea.Cmd {
|
|
content := strings.TrimSpace(m.input.Value())
|
|
if content == "" || m.currentChannelID == "" {
|
|
return nil
|
|
}
|
|
|
|
channelID := m.currentChannelID
|
|
m.input.SetValue("")
|
|
|
|
return func() tea.Msg {
|
|
msg, err := m.api.SendMessage(channelID, content)
|
|
if err != nil {
|
|
return sendErrMsg{err}
|
|
}
|
|
return messageSentMsg{msg: *msg}
|
|
}
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.updateLayout()
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
return m.handleKey(msg)
|
|
|
|
case serversLoadedMsg:
|
|
m.servers = []Server(msg)
|
|
m.loading = false
|
|
if len(m.servers) > 0 {
|
|
m.currentServer = 0
|
|
cmds = append(cmds, m.loadChannels(m.servers[0].ID))
|
|
// Connect websocket
|
|
cmds = append(cmds, m.connectWS())
|
|
// Fetch user
|
|
cmds = append(cmds, func() tea.Msg {
|
|
user, err := m.api.GetMe()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return *user
|
|
})
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
|
|
case channelsLoadedMsg:
|
|
m.channels = []Channel(msg)
|
|
if len(m.channels) > 0 {
|
|
m.currentChannel = 0
|
|
ch := ChannelByFlatIndex(m.channels, 0)
|
|
if ch != nil {
|
|
m.currentChannelID = ch.ID
|
|
return m, m.loadMessages(ch.ID)
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case messagesLoadedMsg:
|
|
m.messages = []Message(msg)
|
|
// Render and scroll to bottom
|
|
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
|
|
m.viewport.SetContent(content)
|
|
m.viewport.GotoBottom()
|
|
return m, nil
|
|
|
|
case messageSentMsg:
|
|
// Don't add the message here; it will come back via WebSocket
|
|
return m, nil
|
|
|
|
case UserProfile:
|
|
m.user = msg
|
|
return m, nil
|
|
|
|
case connectWSMsg:
|
|
m.ws = msg.ws
|
|
// Start listening for WS events
|
|
return m, m.ws.Listen()
|
|
|
|
case wsEventMsg:
|
|
return m.handleWSEvent(msg)
|
|
|
|
case wsErrMsg:
|
|
// Reconnect on error
|
|
m.statusMsg = "Disconnected. Reconnecting..."
|
|
return m, m.connectWS()
|
|
|
|
case sendErrMsg:
|
|
m.err = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
|
|
// Pass to input if focused
|
|
if m.focus == focusInput {
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
if cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
// Global keys
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
if m.ws != nil {
|
|
m.ws.Close()
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
// When input is focused, handle input-specific keys
|
|
if m.focus == focusInput {
|
|
switch msg.String() {
|
|
case "enter":
|
|
return m, m.sendMessage()
|
|
case "esc":
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
return m, nil
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
// Navigation keys when not in input
|
|
switch msg.String() {
|
|
case "q":
|
|
if m.ws != nil {
|
|
m.ws.Close()
|
|
}
|
|
return m, tea.Quit
|
|
|
|
case "?":
|
|
m.showHelp = !m.showHelp
|
|
return m, nil
|
|
|
|
case "tab":
|
|
m.cycleFocus(1)
|
|
return m, nil
|
|
|
|
case "shift+tab":
|
|
m.cycleFocus(-1)
|
|
return m, nil
|
|
|
|
case "i":
|
|
if m.focus != focusInput {
|
|
m.focus = focusInput
|
|
m.input.Focus()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
|
|
case "m":
|
|
if m.voice.Connected {
|
|
m.voice.ToggleMute()
|
|
m.statusMsg = muteStatus(m.voice)
|
|
} else {
|
|
m.showMembers = !m.showMembers
|
|
}
|
|
return m, nil
|
|
|
|
case "j", "down":
|
|
return m, m.navigateDown()
|
|
|
|
case "k", "up":
|
|
return m, m.navigateUp()
|
|
|
|
case "g":
|
|
return m, m.navigateTop()
|
|
|
|
case "G":
|
|
return m, m.navigateBottom()
|
|
|
|
case "enter":
|
|
if m.focus == focusServers {
|
|
m.currentServer = m.hoverServer
|
|
if m.currentServer < len(m.servers) {
|
|
return m, m.loadChannels(m.servers[m.currentServer].ID)
|
|
}
|
|
} else if m.focus == focusChannels {
|
|
m.currentChannel = m.hoverChannel
|
|
ch := ChannelByFlatIndex(m.channels, m.currentChannel)
|
|
if ch != nil {
|
|
m.currentChannelID = ch.ID
|
|
return m, m.loadMessages(ch.ID)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) handleWSEvent(event wsEventMsg) (tea.Model, tea.Cmd) {
|
|
switch event.Type {
|
|
case "MESSAGE_CREATE":
|
|
msg, err := parseWSEventMessage(event.Data)
|
|
if err == nil && msg.ChannelID == m.currentChannelID {
|
|
m.messages = append(m.messages, *msg)
|
|
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
|
|
m.viewport.SetContent(content)
|
|
m.viewport.GotoBottom()
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
case "MESSAGE_UPDATE":
|
|
msg, err := parseWSEventMessage(event.Data)
|
|
if err == nil {
|
|
for i, existing := range m.messages {
|
|
if existing.ID == msg.ID {
|
|
m.messages[i] = *msg
|
|
break
|
|
}
|
|
}
|
|
if msg.ChannelID == m.currentChannelID {
|
|
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
|
|
m.viewport.SetContent(content)
|
|
}
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
case "MESSAGE_DELETE":
|
|
id, channelID := parseWSDeleteData(event.Data)
|
|
if channelID == m.currentChannelID {
|
|
for i, existing := range m.messages {
|
|
if existing.ID == id {
|
|
m.messages = append(m.messages[:i], m.messages[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
content := RenderMessages(m.messages, m.viewport.Width, m.user.Username)
|
|
m.viewport.SetContent(content)
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
case "TYPING_START":
|
|
username, channelID := parseWSTyping(event.Data)
|
|
if channelID == m.currentChannelID && username != m.user.Username {
|
|
m.typing = username
|
|
// Clear after a brief moment would need a tick; for MVP just show it
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
case "VOICE_JOIN":
|
|
var data wsVoiceData
|
|
if err := json.Unmarshal(event.Data, &data); err == nil {
|
|
m.voice.Participants = append(m.voice.Participants, VoiceParticipant{
|
|
UserID: data.UserID,
|
|
Username: data.Username,
|
|
State: "connected",
|
|
})
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
case "VOICE_LEAVE":
|
|
var data wsVoiceData
|
|
if err := json.Unmarshal(event.Data, &data); err == nil {
|
|
for i, p := range m.voice.Participants {
|
|
if p.UserID == data.UserID {
|
|
m.voice.Participants = append(m.voice.Participants[:i], m.voice.Participants[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return m, m.ws.Listen()
|
|
|
|
default:
|
|
return m, m.ws.Listen()
|
|
}
|
|
}
|
|
|
|
func (m *model) cycleFocus(direction int) {
|
|
areas := []focusArea{focusServers, focusChannels, focusChat}
|
|
if m.showMembers {
|
|
areas = append(areas, focusMembers)
|
|
}
|
|
|
|
current := 0
|
|
for i, a := range areas {
|
|
if a == m.focus {
|
|
current = i
|
|
break
|
|
}
|
|
}
|
|
|
|
next := (current + direction + len(areas)) % len(areas)
|
|
m.focus = areas[next]
|
|
}
|
|
|
|
func (m *model) navigateDown() tea.Cmd {
|
|
switch m.focus {
|
|
case focusServers:
|
|
if m.hoverServer < len(m.servers)-1 {
|
|
m.hoverServer++
|
|
}
|
|
case focusChannels:
|
|
if m.hoverChannel < ChannelCount(m.channels)-1 {
|
|
m.hoverChannel++
|
|
}
|
|
case focusChat:
|
|
m.viewport.ScrollDown(1)
|
|
case focusMembers:
|
|
// no-op for MVP
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *model) navigateUp() tea.Cmd {
|
|
switch m.focus {
|
|
case focusServers:
|
|
if m.hoverServer > 0 {
|
|
m.hoverServer--
|
|
}
|
|
case focusChannels:
|
|
if m.hoverChannel > 0 {
|
|
m.hoverChannel--
|
|
}
|
|
case focusChat:
|
|
m.viewport.ScrollUp(1)
|
|
case focusMembers:
|
|
// no-op for MVP
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *model) navigateTop() tea.Cmd {
|
|
switch m.focus {
|
|
case focusServers:
|
|
m.hoverServer = 0
|
|
case focusChannels:
|
|
m.hoverChannel = 0
|
|
case focusChat:
|
|
m.viewport.GotoTop()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *model) navigateBottom() tea.Cmd {
|
|
switch m.focus {
|
|
case focusServers:
|
|
m.hoverServer = len(m.servers) - 1
|
|
case focusChannels:
|
|
m.hoverChannel = ChannelCount(m.channels) - 1
|
|
case focusChat:
|
|
m.viewport.GotoBottom()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *model) updateLayout() {
|
|
// Server bar: 6 wide
|
|
// Channel list: 22 wide
|
|
// Member list: 20 wide (optional)
|
|
// Chat: remainder
|
|
// Input: 3 lines at bottom
|
|
// Status bar: 1 line
|
|
|
|
serverBarWidth := 8
|
|
channelWidth := 24
|
|
memberWidth := 20
|
|
inputHeight := 3
|
|
statusHeight := 1
|
|
|
|
chatHeight := m.height - inputHeight - statusHeight - 2
|
|
if chatHeight < 5 {
|
|
chatHeight = 5
|
|
}
|
|
|
|
chatWidth := m.width - serverBarWidth - channelWidth - 2
|
|
if m.showMembers {
|
|
chatWidth -= memberWidth
|
|
}
|
|
if chatWidth < 20 {
|
|
chatWidth = 20
|
|
}
|
|
|
|
m.viewport.Width = chatWidth - 2
|
|
m.viewport.Height = chatHeight - 1
|
|
m.input.Width = m.width - serverBarWidth - channelWidth - 6
|
|
}
|
|
|
|
func muteStatus(v VoiceState) string {
|
|
if v.Muted {
|
|
return "🔇 Muted"
|
|
}
|
|
return "🔊 Unmuted"
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if m.width == 0 || m.height == 0 {
|
|
return "Loading..."
|
|
}
|
|
|
|
if m.loading {
|
|
return TitleStyle.Width(m.width).Height(m.height).Render(
|
|
"🗑 Dumpster Chat\n\n" + HelpStyle.Render("Loading..."))
|
|
}
|
|
|
|
return renderLayout(m)
|
|
}
|