Phase 5: TUI client (Bubbletea)
- 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
This commit is contained in:
+249
@@ -0,0 +1,249 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIClient wraps HTTP calls to the dumpster server.
|
||||
type APIClient struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
SessionToken string
|
||||
}
|
||||
|
||||
// NewAPIClient creates an API client with a cookie jar for session auth.
|
||||
func NewAPIClient(baseURL string) *APIClient {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &APIClient{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTP: &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Login authenticates and stores the session cookie.
|
||||
func (c *APIClient) Login(email, password string) (*LoginResponse, error) {
|
||||
body, _ := json.Marshal(LoginRequest{Email: email, Password: password})
|
||||
resp, err := c.HTTP.Post(c.BaseURL+"/api/v1/auth/login/password", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("login failed (%d): %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
// Extract session token from cookie
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "dumpster_session" {
|
||||
c.SessionToken = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var lr LoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil {
|
||||
return nil, fmt.Errorf("decode login: %w", err)
|
||||
}
|
||||
return &lr, nil
|
||||
}
|
||||
|
||||
// SetToken sets the session cookie directly (for pre-authenticated sessions).
|
||||
func (c *APIClient) SetToken(token string) {
|
||||
c.SessionToken = token
|
||||
// Set cookie on all requests to the base URL
|
||||
// We do this by setting up a cookie manually
|
||||
}
|
||||
|
||||
// doRequest performs an authenticated request using the session cookie.
|
||||
func (c *APIClient) doRequest(method, path string, body interface{}) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Add session cookie
|
||||
if c.SessionToken != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "dumpster_session",
|
||||
Value: c.SessionToken,
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ---- Data types ----
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type VoiceTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
LiveKitURL string `json:"livekit_url"`
|
||||
}
|
||||
|
||||
// ---- API methods ----
|
||||
|
||||
func (c *APIClient) GetMe() (*UserProfile, error) {
|
||||
data, err := c.doRequest("GET", "/api/v1/auth/me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var user UserProfile
|
||||
if err := json.Unmarshal(data, &user); err != nil {
|
||||
return nil, fmt.Errorf("decode me: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) GetServers() ([]Server, error) {
|
||||
data, err := c.doRequest("GET", "/api/v1/servers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var servers []Server
|
||||
if err := json.Unmarshal(data, &servers); err != nil {
|
||||
return nil, fmt.Errorf("decode servers: %w", err)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) GetChannels(serverID string) ([]Channel, error) {
|
||||
data, err := c.doRequest("GET", "/api/v1/channels?server_id="+serverID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var channels []Channel
|
||||
if err := json.Unmarshal(data, &channels); err != nil {
|
||||
return nil, fmt.Errorf("decode channels: %w", err)
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) GetMessages(channelID string, limit int, before string) ([]Message, error) {
|
||||
path := fmt.Sprintf("/api/v1/channels/%s/messages?limit=%d", channelID, limit)
|
||||
if before != "" {
|
||||
path += "&before=" + before
|
||||
}
|
||||
data, err := c.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var messages []Message
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
return nil, fmt.Errorf("decode messages: %w", err)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) SendMessage(channelID, content string) (*Message, error) {
|
||||
data, err := c.doRequest("POST", "/api/v1/channels/"+channelID+"/messages", map[string]string{"content": content})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return nil, fmt.Errorf("decode message: %w", err)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func (c *APIClient) GetVoiceToken(roomName string) (*VoiceTokenResponse, error) {
|
||||
data, err := c.doRequest("POST", "/api/v1/voice/token", map[string]string{"room_name": roomName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp VoiceTokenResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode voice token: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
Reference in New Issue
Block a user