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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// RunAuth performs interactive login and returns the session token.
|
||||
func RunAuth(api *APIClient) (string, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Println(TitleStyle.Render("🗑 Dumpster Chat TUI"))
|
||||
fmt.Println(HelpStyle.Render(" Log in to continue\n"))
|
||||
|
||||
fmt.Print(" Email: ")
|
||||
email, _ := reader.ReadString('\n')
|
||||
email = strings.TrimSpace(email)
|
||||
|
||||
fmt.Print(" Password: ")
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
// Fallback to plain read
|
||||
fmt.Scanln(&email)
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
fmt.Println() // newline after password
|
||||
|
||||
password := strings.TrimSpace(string(pwBytes))
|
||||
|
||||
if email == "" || password == "" {
|
||||
return "", fmt.Errorf("email and password are required")
|
||||
}
|
||||
|
||||
lr, err := api.Login(email, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
_ = lr // user ID available if needed
|
||||
fmt.Println(SuccessStyle.Render(" ✓ Logged in successfully"))
|
||||
|
||||
return api.SessionToken, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChannelList renders the channel sidebar for a server.
|
||||
func ChannelList(channels []Channel, activeIdx, hoverIdx, width, height int) string {
|
||||
if len(channels) == 0 {
|
||||
return ChannelListStyle.
|
||||
Width(width).
|
||||
Height(height).
|
||||
Render(HelpStyle.Render(" No channels"))
|
||||
}
|
||||
|
||||
// Group channels by category
|
||||
type catGroup struct {
|
||||
name string
|
||||
channels []Channel
|
||||
}
|
||||
catOrder := make([]string, 0)
|
||||
catMap := make(map[string]*catGroup)
|
||||
|
||||
for _, ch := range channels {
|
||||
cat := ch.Category
|
||||
if cat == "" {
|
||||
cat = "general"
|
||||
}
|
||||
if _, ok := catMap[cat]; !ok {
|
||||
catMap[cat] = &catGroup{name: cat}
|
||||
catOrder = append(catOrder, cat)
|
||||
}
|
||||
catMap[cat].channels = append(catMap[cat].channels, ch)
|
||||
}
|
||||
|
||||
// Stable sort categories
|
||||
sort.Strings(catOrder)
|
||||
|
||||
// Build flat list for index mapping
|
||||
var lines []string
|
||||
flatIdx := 0
|
||||
|
||||
for _, catName := range catOrder {
|
||||
group := catMap[catName]
|
||||
lines = append(lines, CategoryStyle.Render(strings.ToUpper(group.name)))
|
||||
|
||||
for _, ch := range group.channels {
|
||||
prefix := " "
|
||||
if ch.Type == "voice" {
|
||||
prefix = VoiceChannelPrefix.Render("🔊 ")
|
||||
} else {
|
||||
prefix = TextChannelPrefix.Render("# ")
|
||||
}
|
||||
|
||||
style := ChannelInactive
|
||||
if flatIdx == activeIdx {
|
||||
style = ChannelActive
|
||||
} else if flatIdx == hoverIdx {
|
||||
style = ChannelHover
|
||||
}
|
||||
|
||||
label := ch.Name
|
||||
if len(label) > width-6 {
|
||||
label = label[:width-9] + "..."
|
||||
}
|
||||
lines = append(lines, style.Render(prefix+label))
|
||||
flatIdx++
|
||||
}
|
||||
}
|
||||
|
||||
return ChannelListStyle.
|
||||
Width(width).
|
||||
Height(height).
|
||||
Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// ChannelFlatIndex converts a (category, channel index in category) to flat index.
|
||||
func ChannelFlatIndex(channels []Channel, targetID string) int {
|
||||
catOrder := getCatOrder(channels)
|
||||
flatIdx := 0
|
||||
for _, catName := range catOrder {
|
||||
for _, ch := range groupByCategory(channels)[catName] {
|
||||
if ch.ID == targetID {
|
||||
return flatIdx
|
||||
}
|
||||
flatIdx++
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ChannelByFlatIndex returns the channel at a flat index in the grouped view.
|
||||
func ChannelByFlatIndex(channels []Channel, flatIdx int) *Channel {
|
||||
catOrder := getCatOrder(channels)
|
||||
idx := 0
|
||||
for _, catName := range catOrder {
|
||||
for _, ch := range groupByCategory(channels)[catName] {
|
||||
if idx == flatIdx {
|
||||
return &ch
|
||||
}
|
||||
idx++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupByCategory(channels []Channel) map[string][]Channel {
|
||||
m := make(map[string][]Channel)
|
||||
for _, ch := range channels {
|
||||
cat := ch.Category
|
||||
if cat == "" {
|
||||
cat = "general"
|
||||
}
|
||||
m[cat] = append(m[cat], ch)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getCatOrder(channels []Channel) []string {
|
||||
seen := make(map[string]bool)
|
||||
var order []string
|
||||
for _, ch := range channels {
|
||||
cat := ch.Category
|
||||
if cat == "" {
|
||||
cat = "general"
|
||||
}
|
||||
if !seen[cat] {
|
||||
seen[cat] = true
|
||||
order = append(order, cat)
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
return order
|
||||
}
|
||||
|
||||
// ChannelCount returns total number of channels (for navigation limits).
|
||||
func ChannelCount(channels []Channel) int {
|
||||
return len(channels)
|
||||
}
|
||||
|
||||
// formatChannelHeader renders the channel header for the chat pane.
|
||||
func formatChannelHeader(ch Channel) string {
|
||||
icon := "#"
|
||||
if ch.Type == "voice" {
|
||||
icon = "🔊"
|
||||
}
|
||||
return fmt.Sprintf("%s %s", icon, ch.Name)
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// RenderMessages renders the chat message list for the viewport.
|
||||
func RenderMessages(messages []Message, width int, currentUser string) string {
|
||||
if len(messages) == 0 {
|
||||
return HelpStyle.Render(" No messages yet. Press 'i' to type a message.")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
for _, msg := range messages {
|
||||
ts := formatTimestamp(msg.CreatedAt)
|
||||
|
||||
// Determine display name
|
||||
displayName := msg.AuthorName
|
||||
if msg.DisplayName != nil && *msg.DisplayName != "" {
|
||||
displayName = *msg.DisplayName
|
||||
}
|
||||
|
||||
// Color the username
|
||||
userColor := UsernameColor(msg.AuthorName)
|
||||
styledUser := UsernameStyle.Foreground(userColor).Render(displayName)
|
||||
|
||||
// Content
|
||||
content := msg.Content
|
||||
|
||||
// Show edited tag
|
||||
editedTag := ""
|
||||
if msg.EditedAt != nil {
|
||||
editedTag = EditedTag.Render(" (edited)")
|
||||
}
|
||||
|
||||
// IRC-style: [HH:MM] <user> message
|
||||
tsRendered := TimestampStyle.Render("[" + ts + "]")
|
||||
prefix := fmt.Sprintf("%s <%s> ", tsRendered, styledUser)
|
||||
|
||||
// Word wrap the content
|
||||
contentWidth := width - lipgloss.Width(prefix) - 2
|
||||
if contentWidth < 10 {
|
||||
contentWidth = 40
|
||||
}
|
||||
wrappedContent := wordWrap(content, contentWidth)
|
||||
|
||||
// First line has the prefix, continuation lines are indented
|
||||
contentLines := strings.Split(wrappedContent, "\n")
|
||||
for i, cl := range contentLines {
|
||||
if i == 0 {
|
||||
lines = append(lines, prefix+cl+editedTag)
|
||||
} else {
|
||||
indent := strings.Repeat(" ", lipgloss.Width(prefix))
|
||||
lines = append(lines, indent+cl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatTimestamp parses the created_at string and returns HH:MM.
|
||||
func formatTimestamp(ts string) string {
|
||||
// Try various formats the server might return
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999Z",
|
||||
"2006-01-02T15:04:05.999999-07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05Z",
|
||||
}
|
||||
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, ts); err == nil {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to extract HH:MM from the string
|
||||
if len(ts) >= 16 {
|
||||
return ts[11:16]
|
||||
}
|
||||
return "??:??"
|
||||
}
|
||||
|
||||
// wordWrap wraps text to the given width.
|
||||
func wordWrap(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
words := strings.Fields(text)
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
currentLine := words[0]
|
||||
|
||||
for _, word := range words[1:] {
|
||||
if len(currentLine)+1+len(word) > width {
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
currentLine += " " + word
|
||||
}
|
||||
}
|
||||
lines = append(lines, currentLine)
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `yaml:"server_url"`
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
func configDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "dumpster")
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
return filepath.Join(configDir(), "config.yaml")
|
||||
}
|
||||
|
||||
// LoadConfig loads config from file, then overlays env vars.
|
||||
func LoadConfig() (*Config, error) {
|
||||
cfg := &Config{
|
||||
ServerURL: "http://localhost:8080",
|
||||
}
|
||||
|
||||
// Try to load from file
|
||||
data, err := os.ReadFile(configPath())
|
||||
if err == nil {
|
||||
_ = yaml.Unmarshal(data, cfg)
|
||||
}
|
||||
|
||||
// Env var overrides
|
||||
if v := os.Getenv("DUMPSTER_SERVER"); v != "" {
|
||||
cfg.ServerURL = v
|
||||
}
|
||||
if v := os.Getenv("DUMPSTER_TOKEN"); v != "" {
|
||||
cfg.Token = v
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes the config to disk.
|
||||
func SaveConfig(cfg *Config) error {
|
||||
dir := configDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return os.WriteFile(configPath(), data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// NewInput creates a configured text input for message composition.
|
||||
func NewInput() textinput.Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Type a message..."
|
||||
ti.Focus()
|
||||
ti.CharLimit = 4000
|
||||
ti.Width = 60
|
||||
|
||||
ti.PromptStyle = lipgloss.NewStyle().Foreground(aqua).Bold(true)
|
||||
ti.TextStyle = lipgloss.NewStyle().Foreground(fg)
|
||||
ti.Cursor.Style = lipgloss.NewStyle().Foreground(aqua)
|
||||
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(fgF)
|
||||
|
||||
return ti
|
||||
}
|
||||
|
||||
// InputUpdate handles key events for the input when focused.
|
||||
func InputUpdate(input textinput.Model, msg tea.KeyMsg) (textinput.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
// Send message - handled by parent model
|
||||
return input, nil
|
||||
case "esc":
|
||||
// Unfocus - handled by parent model
|
||||
return input, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
input, cmd = input.Update(msg)
|
||||
return input, cmd
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Top key.Binding
|
||||
Bottom key.Binding
|
||||
Tab key.Binding
|
||||
ShiftTab key.Binding
|
||||
FocusInput key.Binding
|
||||
Unfocus key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Send key.Binding
|
||||
MemberToggle key.Binding
|
||||
ChannelPicker key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("k/↑", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("j/↓", "down"),
|
||||
),
|
||||
Top: key.NewBinding(
|
||||
key.WithKeys("g"),
|
||||
key.WithHelp("g", "top"),
|
||||
),
|
||||
Bottom: key.NewBinding(
|
||||
key.WithKeys("G"),
|
||||
key.WithHelp("G", "bottom"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next pane"),
|
||||
),
|
||||
ShiftTab: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "prev pane"),
|
||||
),
|
||||
FocusInput: key.NewBinding(
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "focus input"),
|
||||
),
|
||||
Unfocus: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "unfocus"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "help"),
|
||||
),
|
||||
Send: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "send"),
|
||||
),
|
||||
MemberToggle: key.NewBinding(
|
||||
key.WithKeys("m"),
|
||||
key.WithHelp("m", "toggle members"),
|
||||
),
|
||||
ChannelPicker: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "channels"),
|
||||
),
|
||||
}
|
||||
|
||||
// helpItems returns relevant help items based on the current focus.
|
||||
func helpItems(focused focusArea) []key.Binding {
|
||||
switch focused {
|
||||
case focusInput:
|
||||
return []key.Binding{keys.Send, keys.Unfocus}
|
||||
default:
|
||||
return []key.Binding{
|
||||
keys.Up, keys.Down, keys.Top, keys.Bottom,
|
||||
keys.Tab, keys.FocusInput, keys.MemberToggle,
|
||||
keys.ChannelPicker, keys.Help, keys.Quit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Flags
|
||||
serverFlag := flag.String("server", "", "Server URL (e.g. https://chat.example.com)")
|
||||
tokenFlag := flag.String("token", "", "Session token (skips login)")
|
||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
fmt.Printf("dumpster-tui %s (%s)\n", version, commit)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Apply flags over config
|
||||
if *serverFlag != "" {
|
||||
cfg.ServerURL = *serverFlag
|
||||
}
|
||||
if *tokenFlag != "" {
|
||||
cfg.Token = *tokenFlag
|
||||
}
|
||||
|
||||
// Create API client
|
||||
api := NewAPIClient(cfg.ServerURL)
|
||||
|
||||
// Auth flow
|
||||
if cfg.Token == "" {
|
||||
token, err := RunAuth(api)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Authentication failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg.Token = token
|
||||
|
||||
// Save token for future sessions
|
||||
if err := SaveConfig(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not save config: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
api.SetToken(cfg.Token)
|
||||
}
|
||||
|
||||
// Verify auth
|
||||
user, err := api.GetMe()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Session invalid or expired: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Try logging in again.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(SuccessStyle.Render(fmt.Sprintf(" ✓ Logged in as %s", user.Username)))
|
||||
|
||||
// Start TUI
|
||||
m := initialModel(api)
|
||||
m.user = *user
|
||||
|
||||
p := tea.NewProgram(m,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
lipgloss.SetHasDarkBackground(true)
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// MemberEntry renders a single member in the member list.
|
||||
func MemberEntry(username string, status string) string {
|
||||
var statusDot string
|
||||
switch status {
|
||||
case "online":
|
||||
statusDot = StatusOnline.Render("●")
|
||||
case "idle":
|
||||
statusDot = StatusIdle.Render("◐")
|
||||
default:
|
||||
statusDot = StatusOffline.Render("○")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" %s %s", statusDot, username)
|
||||
}
|
||||
|
||||
// MemberList renders the member sidebar.
|
||||
func MemberList(user UserProfile, members []MemberEntryData, width, height int, visible bool) string {
|
||||
if !visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, CategoryStyle.Render("ONLINE"))
|
||||
|
||||
if user.Username != "" {
|
||||
lines = append(lines, MemberEntry(
|
||||
user.DisplayName,
|
||||
"online",
|
||||
))
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
status := m.Status
|
||||
if status == "" {
|
||||
status = "offline"
|
||||
}
|
||||
lines = append(lines, MemberEntry(m.DisplayName, status))
|
||||
}
|
||||
|
||||
if len(members) == 0 && user.Username != "" {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, HelpStyle.Render(" No other members"))
|
||||
}
|
||||
|
||||
return MemberListStyle.
|
||||
Width(width).
|
||||
Height(height).
|
||||
Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// MemberEntryData is a lightweight member representation.
|
||||
type MemberEntryData struct {
|
||||
UserID string
|
||||
Username string
|
||||
DisplayName string
|
||||
Status string
|
||||
}
|
||||
|
||||
// VoiceParticipants renders the voice participant list (used in voice channels).
|
||||
func VoiceParticipants(participants []VoiceParticipant, width int) string {
|
||||
if len(participants) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, CategoryStyle.Render("VOICE"))
|
||||
|
||||
for _, p := range participants {
|
||||
icon := "🔊"
|
||||
if p.State != "connected" {
|
||||
icon = "🔇"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s %s", icon, p.Username))
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServerItem renders a single server in the server bar.
|
||||
func ServerItem(server Server, isActive, isHovered bool, width int) string {
|
||||
// Show first letter(s) of server name as icon
|
||||
name := server.Name
|
||||
if len(name) > 2 {
|
||||
name = name[:2]
|
||||
}
|
||||
// Use icon if available
|
||||
if server.Icon != "" {
|
||||
name = server.Icon
|
||||
}
|
||||
name = strings.ToUpper(name)
|
||||
|
||||
style := ServerInactive
|
||||
if isActive {
|
||||
style = ServerActive
|
||||
} else if isHovered {
|
||||
style = ServerHover
|
||||
}
|
||||
|
||||
return style.Width(width - 2).Render(fmt.Sprintf(" %s ", name))
|
||||
}
|
||||
|
||||
// ServerBar renders the vertical server list.
|
||||
func ServerBar(servers []Server, activeIdx, hoverIdx, height int) string {
|
||||
if len(servers) == 0 {
|
||||
return ServerBarStyle.
|
||||
Width(6).
|
||||
Height(height).
|
||||
Render(HelpStyle.Render("No\nservers"))
|
||||
}
|
||||
|
||||
var items []string
|
||||
for i, s := range servers {
|
||||
items = append(items, ServerItem(s, i == activeIdx, i == hoverIdx, 6))
|
||||
}
|
||||
|
||||
bar := strings.Join(items, "\n")
|
||||
return ServerBarStyle.
|
||||
Width(6).
|
||||
Height(height).
|
||||
Render(bar)
|
||||
}
|
||||
|
||||
// ServerInitials returns 2-char initials for a server name.
|
||||
func ServerInitials(name string) string {
|
||||
if len(name) == 0 {
|
||||
return "??"
|
||||
}
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) >= 2 {
|
||||
return strings.ToUpper(string([]rune(parts[0])[0:1]) + string([]rune(parts[1])[0:1]))
|
||||
}
|
||||
if len(name) >= 2 {
|
||||
return strings.ToUpper(name[:2])
|
||||
}
|
||||
return strings.ToUpper(name) + " "
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Gruvbox dark palette
|
||||
var (
|
||||
bg = lipgloss.Color("#282828")
|
||||
bgS = lipgloss.Color("#3c3836")
|
||||
bgT = lipgloss.Color("#504945")
|
||||
fg = lipgloss.Color("#ebdbb2")
|
||||
fgS = lipgloss.Color("#d5c4a1")
|
||||
fgF = lipgloss.Color("#a89984")
|
||||
red = lipgloss.Color("#fb4934")
|
||||
grn = lipgloss.Color("#b8bb26")
|
||||
org = lipgloss.Color("#fe8019")
|
||||
aqua = lipgloss.Color("#8ec07c")
|
||||
yel = lipgloss.Color("#fabd2f")
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
// Layout panes
|
||||
ServerBarStyle = lipgloss.NewStyle().
|
||||
Background(bgS).
|
||||
Foreground(fg).
|
||||
Padding(0, 1).
|
||||
Border(lipgloss.NormalBorder(), false, true, false, false).
|
||||
BorderForeground(bgT)
|
||||
|
||||
ChannelListStyle = lipgloss.NewStyle().
|
||||
Background(bgS).
|
||||
Foreground(fg).
|
||||
Padding(0, 1)
|
||||
|
||||
ChatPaneStyle = lipgloss.NewStyle().
|
||||
Background(bg).
|
||||
Foreground(fg).
|
||||
Padding(0, 1)
|
||||
|
||||
MemberListStyle = lipgloss.NewStyle().
|
||||
Background(bgS).
|
||||
Foreground(fg).
|
||||
Padding(0, 1)
|
||||
|
||||
InputPaneStyle = lipgloss.NewStyle().
|
||||
Background(bgS).
|
||||
Foreground(fg).
|
||||
Padding(0, 1)
|
||||
|
||||
// Focus indicators
|
||||
FocusedBorder = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(aqua)
|
||||
|
||||
UnfocusedBorder = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(bgT)
|
||||
|
||||
// Server list
|
||||
ServerActive = lipgloss.NewStyle().
|
||||
Background(aqua).
|
||||
Foreground(bg).
|
||||
Bold(true).
|
||||
Padding(0, 1).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
ServerInactive = lipgloss.NewStyle().
|
||||
Background(bgS).
|
||||
Foreground(fgF).
|
||||
Padding(0, 1).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
ServerHover = lipgloss.NewStyle().
|
||||
Background(bgT).
|
||||
Foreground(fg).
|
||||
Padding(0, 1).
|
||||
Align(lipgloss.Center)
|
||||
|
||||
// Channel list
|
||||
CategoryStyle = lipgloss.NewStyle().
|
||||
Foreground(fgF).
|
||||
Bold(true).
|
||||
PaddingTop(1)
|
||||
|
||||
ChannelActive = lipgloss.NewStyle().
|
||||
Foreground(fg).
|
||||
Bold(true).
|
||||
PaddingLeft(1)
|
||||
|
||||
ChannelInactive = lipgloss.NewStyle().
|
||||
Foreground(fgF).
|
||||
PaddingLeft(1)
|
||||
|
||||
ChannelHover = lipgloss.NewStyle().
|
||||
Foreground(fg).
|
||||
PaddingLeft(1)
|
||||
|
||||
VoiceChannelPrefix = lipgloss.NewStyle().Foreground(aqua)
|
||||
TextChannelPrefix = lipgloss.NewStyle().Foreground(fgF)
|
||||
|
||||
// Chat
|
||||
MessageStyle = lipgloss.NewStyle().
|
||||
Foreground(fg)
|
||||
|
||||
TimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(fgF)
|
||||
|
||||
UsernameStyle = lipgloss.NewStyle().
|
||||
Bold(true)
|
||||
|
||||
MentionStyle = lipgloss.NewStyle().
|
||||
Foreground(aqua).
|
||||
Bold(true)
|
||||
|
||||
EditedTag = lipgloss.NewStyle().
|
||||
Foreground(fgF).
|
||||
Italic(true)
|
||||
|
||||
// Status indicators
|
||||
StatusOnline = lipgloss.NewStyle().Foreground(grn)
|
||||
StatusIdle = lipgloss.NewStyle().Foreground(yel)
|
||||
StatusOffline = lipgloss.NewStyle().Foreground(fgF)
|
||||
|
||||
// Voice
|
||||
VoiceStatusStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#1d2021")).
|
||||
Foreground(aqua).
|
||||
Padding(0, 1)
|
||||
|
||||
// Error/success
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(red).
|
||||
Bold(true)
|
||||
|
||||
SuccessStyle = lipgloss.NewStyle().
|
||||
Foreground(grn).
|
||||
Bold(true)
|
||||
|
||||
// Help
|
||||
HelpStyle = lipgloss.NewStyle().
|
||||
Foreground(fgF).
|
||||
Italic(true)
|
||||
|
||||
// Title
|
||||
TitleStyle = lipgloss.NewStyle().
|
||||
Foreground(aqua).
|
||||
Bold(true).
|
||||
Align(lipgloss.Center)
|
||||
)
|
||||
|
||||
// UsernameColor returns a deterministic color for a username based on its content.
|
||||
func UsernameColor(username string) lipgloss.Color {
|
||||
colors := []lipgloss.Color{red, grn, org, aqua, yel, lipgloss.Color("#d3869b"), lipgloss.Color("#83a598")}
|
||||
hash := 0
|
||||
for _, c := range username {
|
||||
hash += int(c)
|
||||
}
|
||||
return colors[hash%len(colors)]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func renderLayout(m model) string {
|
||||
serverBarWidth := 8
|
||||
channelWidth := 24
|
||||
memberWidth := 20
|
||||
statusHeight := 1
|
||||
|
||||
chatHeight := m.height - statusHeight - 2
|
||||
if chatHeight < 5 {
|
||||
chatHeight = 5
|
||||
}
|
||||
|
||||
chatWidth := m.width - serverBarWidth - channelWidth
|
||||
if m.showMembers {
|
||||
chatWidth -= memberWidth
|
||||
}
|
||||
if chatWidth < 20 {
|
||||
chatWidth = 20
|
||||
}
|
||||
|
||||
// ---- Server bar (left column) ----
|
||||
serverBar := ServerBar(m.servers, m.currentServer, m.hoverServer, m.height-statusHeight)
|
||||
|
||||
// ---- Channel list ----
|
||||
channelList := ChannelList(m.channels, m.currentChannel, m.hoverChannel, channelWidth-2, m.height-statusHeight)
|
||||
|
||||
// Apply focus border to channel list
|
||||
if m.focus == focusChannels {
|
||||
channelList = FocusedBorder.Render(channelList)
|
||||
}
|
||||
|
||||
// ---- Chat pane ----
|
||||
// Channel header
|
||||
ch := ChannelByFlatIndex(m.channels, m.currentChannel)
|
||||
headerText := "# general"
|
||||
if ch != nil {
|
||||
headerText = formatChannelHeader(*ch)
|
||||
}
|
||||
|
||||
header := TitleStyle.
|
||||
Width(chatWidth - 2).
|
||||
Render(headerText)
|
||||
|
||||
// Typing indicator
|
||||
typingBar := ""
|
||||
if m.typing != "" {
|
||||
typingBar = HelpStyle.Render(" " + m.typing + " is typing...")
|
||||
}
|
||||
|
||||
// Voice status
|
||||
voiceBar := ""
|
||||
if m.voice.Connected {
|
||||
voiceBar = VoiceStatusStyle.
|
||||
Width(chatWidth - 2).
|
||||
Render(m.voice.StatusText())
|
||||
}
|
||||
|
||||
// Input
|
||||
inputStyle := InputPaneStyle.Width(chatWidth - 2)
|
||||
inputView := inputStyle.Render("> " + m.input.View())
|
||||
|
||||
// Chat viewport
|
||||
chatPane := lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
m.viewport.View(),
|
||||
)
|
||||
|
||||
if typingBar != "" {
|
||||
chatPane = lipgloss.JoinVertical(lipgloss.Left, chatPane, typingBar)
|
||||
}
|
||||
if voiceBar != "" {
|
||||
chatPane = lipgloss.JoinVertical(lipgloss.Left, chatPane, voiceBar)
|
||||
}
|
||||
|
||||
chatPane = lipgloss.JoinVertical(lipgloss.Left, chatPane, inputView)
|
||||
|
||||
// Focus border for chat
|
||||
if m.focus == focusChat || m.focus == focusInput {
|
||||
chatPane = FocusedBorder.Width(chatWidth).Render(chatPane)
|
||||
}
|
||||
|
||||
// ---- Member list ----
|
||||
var memberView string
|
||||
if m.showMembers {
|
||||
memberView = MemberList(m.user, m.members, memberWidth-2, m.height-statusHeight, true)
|
||||
if m.focus == focusMembers {
|
||||
memberView = FocusedBorder.Render(memberView)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status bar ----
|
||||
statusParts := []string{}
|
||||
if m.err != "" {
|
||||
statusParts = append(statusParts, ErrorStyle.Render("⚠ "+m.err))
|
||||
} else if m.statusMsg != "" {
|
||||
statusParts = append(statusParts, SuccessStyle.Render(m.statusMsg))
|
||||
} else if m.typing != "" {
|
||||
statusParts = append(statusParts, HelpStyle.Render(m.typing+" is typing..."))
|
||||
}
|
||||
|
||||
// Show current user
|
||||
if m.user.Username != "" {
|
||||
right := UsernameStyle.Render(m.user.Username)
|
||||
statusParts = append(statusParts, lipgloss.PlaceHorizontal(
|
||||
m.width-lipgloss.Width(right)-2, lipgloss.Right, right))
|
||||
}
|
||||
|
||||
statusBar := lipgloss.NewStyle().
|
||||
Background(bgT).
|
||||
Foreground(fg).
|
||||
Width(m.width).
|
||||
Padding(0, 1).
|
||||
Render(strings.Join(statusParts, " "))
|
||||
|
||||
// ---- Help overlay ----
|
||||
if m.showHelp {
|
||||
return renderHelp(m)
|
||||
}
|
||||
|
||||
// ---- Compose layout ----
|
||||
var rows []string
|
||||
|
||||
// Top row: servers | channels | chat | members
|
||||
topRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
serverBar,
|
||||
channelList,
|
||||
chatPane,
|
||||
)
|
||||
if m.showMembers && memberView != "" {
|
||||
topRow = lipgloss.JoinHorizontal(lipgloss.Top, topRow, memberView)
|
||||
}
|
||||
|
||||
rows = append(rows, topRow)
|
||||
rows = append(rows, statusBar)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func renderHelp(m model) string {
|
||||
helpText := []string{
|
||||
"",
|
||||
TitleStyle.Render("🗑 Dumpster Chat — Help"),
|
||||
"",
|
||||
" Navigation",
|
||||
" ──────────",
|
||||
" j/k or ↑/↓ Navigate up/down",
|
||||
" g / G Jump to top/bottom",
|
||||
" Tab Cycle focus between panes",
|
||||
" Enter Select server/channel",
|
||||
"",
|
||||
" Chat",
|
||||
" ──────────",
|
||||
" i Focus message input",
|
||||
" Enter Send message (in input)",
|
||||
" Esc Unfocus input",
|
||||
"",
|
||||
" Panels",
|
||||
" ──────────",
|
||||
" m Toggle member list / mute (in voice)",
|
||||
" ? Toggle this help",
|
||||
" q / Ctrl+C Quit",
|
||||
"",
|
||||
HelpStyle.Render(" Press ? to close"),
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Background(bg).
|
||||
Foreground(fg).
|
||||
Width(m.width).
|
||||
Height(m.height).
|
||||
Padding(1, 2).
|
||||
Render(strings.Join(helpText, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
// Voice state tracking for the TUI client.
|
||||
|
||||
// VoiceState holds the current voice connection state.
|
||||
type VoiceState struct {
|
||||
Connected bool
|
||||
RoomName string
|
||||
LiveKitURL string
|
||||
Token string
|
||||
Muted bool
|
||||
Deafened bool
|
||||
Participants []VoiceParticipant
|
||||
}
|
||||
|
||||
// VoiceParticipant is someone in the current voice channel.
|
||||
type VoiceParticipant struct {
|
||||
UserID string
|
||||
Username string
|
||||
State string // connected, disconnected
|
||||
}
|
||||
|
||||
// VoiceStatusText returns a human-readable status for the status bar.
|
||||
func (vs *VoiceState) StatusText() string {
|
||||
if !vs.Connected {
|
||||
return ""
|
||||
}
|
||||
muteIcon := "🔊"
|
||||
if vs.Muted {
|
||||
muteIcon = "🔇"
|
||||
}
|
||||
return muteIcon + " " + vs.RoomName
|
||||
}
|
||||
|
||||
// ToggleMute flips the muted state.
|
||||
func (vs *VoiceState) ToggleMute() {
|
||||
vs.Muted = !vs.Muted
|
||||
}
|
||||
|
||||
// ToggleDeafen flips the deafened state.
|
||||
func (vs *VoiceState) ToggleDeafen() {
|
||||
vs.Deafened = !vs.Deafened
|
||||
if vs.Deafened {
|
||||
vs.Muted = true
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WSEvent represents a raw websocket event from the server.
|
||||
type WSEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocket connection manager.
|
||||
type WSConn struct {
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
// ConnectWS dials the websocket endpoint with the session cookie.
|
||||
func ConnectWS(serverURL, token string) (*WSConn, error) {
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Switch to ws/wss scheme
|
||||
if u.Scheme == "https" {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = "/ws"
|
||||
q := u.Query()
|
||||
q.Set("token", token)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
header := make(map[string][]string)
|
||||
header["Cookie"] = []string{"dumpster_session=" + token}
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws := &WSConn{
|
||||
conn: conn,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Listen starts reading messages and sends them as tea.Msgs to the program.
|
||||
// It returns a tea.Cmd that listens for the next message.
|
||||
func (ws *WSConn) Listen() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, raw, err := ws.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return wsErrMsg{err: err}
|
||||
}
|
||||
|
||||
var event WSEvent
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
return wsErrMsg{err: err}
|
||||
}
|
||||
|
||||
return wsEventMsg(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a JSON event to the server.
|
||||
func (ws *WSConn) Send(eventType string, data interface{}) error {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"type": eventType,
|
||||
"data": data,
|
||||
}
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ws.conn.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
|
||||
// Close closes the websocket connection.
|
||||
func (ws *WSConn) Close() {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
if !ws.closed {
|
||||
ws.closed = true
|
||||
ws.conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
ws.conn.Close()
|
||||
close(ws.done)
|
||||
}
|
||||
}
|
||||
|
||||
// StartPing sends periodic pings to keep the connection alive.
|
||||
func (ws *WSConn) StartPing() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ws.mu.Lock()
|
||||
if !ws.closed {
|
||||
_ = ws.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
case <-ws.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---- Message types for bubbletea ----
|
||||
|
||||
type wsEventMsg WSEvent
|
||||
|
||||
type wsErrMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// Parse helpers for ws event data
|
||||
|
||||
type wsMessageData 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 wsPresenceData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type wsTypingData struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type wsVoiceData struct {
|
||||
RoomName string `json:"room_name"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// parseWSEventMessage extracts a Message from event data.
|
||||
func parseWSEventMessage(data json.RawMessage) (*Message, error) {
|
||||
var m Message
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// parseWSDeleteData extracts the message ID from a delete event.
|
||||
func parseWSDeleteData(data json.RawMessage) (id string, channelID string) {
|
||||
var d struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
log.Printf("ws: failed to parse delete data: %v", err)
|
||||
return
|
||||
}
|
||||
return d.ID, d.ChannelID
|
||||
}
|
||||
|
||||
// parseWSTyping extracts typing event data.
|
||||
func parseWSTyping(data json.RawMessage) (username string, channelID string) {
|
||||
var d struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return
|
||||
}
|
||||
return d.Username, d.ChannelID
|
||||
}
|
||||
|
||||
// trimURLScheme strips http(s):// for display
|
||||
func trimURLScheme(s string) string {
|
||||
s = strings.TrimPrefix(s, "https://")
|
||||
s = strings.TrimPrefix(s, "http://")
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user