diff --git a/Makefile b/Makefile index 576316a..8d0fcb3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-web build-server run dev docker-up docker-down clean +.PHONY: build build-web build-server build-tui run run-tui dev docker-up docker-down clean # Build everything build: build-web build-server @@ -7,6 +7,10 @@ build: build-web build-server build-server: CGO_ENABLED=0 go build -o dumpster-server ./cmd/server +# Build the TUI client +build-tui: + CGO_ENABLED=0 go build -o dumpster-tui ./cmd/tui + # Build the web frontend build-web: cd web && npm run build @@ -15,6 +19,10 @@ build-web: run: build-server ./dumpster-server +# Run the TUI client +run-tui: build-tui + ./dumpster-tui + # Run web dev server (in a separate terminal) dev-web: cd web && npm run dev @@ -37,6 +45,6 @@ docker-clean: # Clean build artifacts clean: - rm -f dumpster-server + rm -f dumpster-server dumpster-tui rm -rf web/dist rm -rf web/node_modules diff --git a/cmd/tui/api.go b/cmd/tui/api.go new file mode 100644 index 0000000..e354339 --- /dev/null +++ b/cmd/tui/api.go @@ -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 +} diff --git a/cmd/tui/auth.go b/cmd/tui/auth.go new file mode 100644 index 0000000..761e089 --- /dev/null +++ b/cmd/tui/auth.go @@ -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 +} diff --git a/cmd/tui/channels.go b/cmd/tui/channels.go new file mode 100644 index 0000000..63aa2d9 --- /dev/null +++ b/cmd/tui/channels.go @@ -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) +} diff --git a/cmd/tui/chat.go b/cmd/tui/chat.go new file mode 100644 index 0000000..5484f22 --- /dev/null +++ b/cmd/tui/chat.go @@ -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] 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") +} diff --git a/cmd/tui/config.go b/cmd/tui/config.go new file mode 100644 index 0000000..8fbd013 --- /dev/null +++ b/cmd/tui/config.go @@ -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) +} diff --git a/cmd/tui/input.go b/cmd/tui/input.go new file mode 100644 index 0000000..d030ac0 --- /dev/null +++ b/cmd/tui/input.go @@ -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 +} diff --git a/cmd/tui/keys.go b/cmd/tui/keys.go new file mode 100644 index 0000000..e43574a --- /dev/null +++ b/cmd/tui/keys.go @@ -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, + } + } +} diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..3b0a062 --- /dev/null +++ b/cmd/tui/main.go @@ -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) + } +} diff --git a/cmd/tui/members.go b/cmd/tui/members.go new file mode 100644 index 0000000..1df9e5e --- /dev/null +++ b/cmd/tui/members.go @@ -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")) +} diff --git a/cmd/tui/model.go b/cmd/tui/model.go new file mode 100644 index 0000000..7f874fc --- /dev/null +++ b/cmd/tui/model.go @@ -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) +} diff --git a/cmd/tui/servers.go b/cmd/tui/servers.go new file mode 100644 index 0000000..cb583f9 --- /dev/null +++ b/cmd/tui/servers.go @@ -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) + " " +} diff --git a/cmd/tui/theme.go b/cmd/tui/theme.go new file mode 100644 index 0000000..74aedcd --- /dev/null +++ b/cmd/tui/theme.go @@ -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)] +} diff --git a/cmd/tui/views.go b/cmd/tui/views.go new file mode 100644 index 0000000..d815078 --- /dev/null +++ b/cmd/tui/views.go @@ -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")) +} diff --git a/cmd/tui/voice.go b/cmd/tui/voice.go new file mode 100644 index 0000000..53d7619 --- /dev/null +++ b/cmd/tui/voice.go @@ -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 + } +} diff --git a/cmd/tui/ws.go b/cmd/tui/ws.go new file mode 100644 index 0000000..79fff2e --- /dev/null +++ b/cmd/tui/ws.go @@ -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 +} diff --git a/go.mod b/go.mod index 2b97ec4..4779972 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,19 @@ module git.dustin.coffee/hobokenchicken/dumpsterChat go 1.26.4 require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/jackc/pgx/v5 v5.10.0 + github.com/livekit/protocol v1.48.1-0.20260624204523-bd5703442db6 + github.com/livekit/server-sdk-go/v2 v2.16.7 github.com/minio/minio-go/v7 v7.2.1 golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -17,12 +24,21 @@ require ( buf.build/go/protoyaml v0.7.0 // indirect cel.dev/expr v0.25.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dennwc/iters v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gammazero/deque v1.2.1 // indirect @@ -37,16 +53,20 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // indirect - github.com/livekit/protocol v1.48.1-0.20260624204523-bd5703442db6 // indirect github.com/livekit/psrpc v0.7.2 // indirect - github.com/livekit/server-sdk-go/v2 v2.16.7 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/magefile/mage v1.17.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nats.go v1.52.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect @@ -74,10 +94,12 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect github.com/redis/go-redis/v9 v9.20.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchtv/twirp v8.1.3+incompatible // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect @@ -96,5 +118,4 @@ require ( google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 974a474..e34c82f 100644 --- a/go.sum +++ b/go.sum @@ -6,25 +6,70 @@ buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo= github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -40,12 +85,14 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -61,14 +108,12 @@ github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5 github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= @@ -83,14 +128,34 @@ github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/server-sdk-go/v2 v2.16.7 h1:oYnp2o3YTBdL4xVkq5NpLwqnCijZVfVJU9ddFl+BW/Y= github.com/livekit/server-sdk-go/v2 v2.16.7/go.mod h1:B3qlhVBZ4olBWRN/KxokLZE2d4LujNk4n2yYDL3+u2s= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= @@ -99,6 +164,12 @@ github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= @@ -127,15 +198,19 @@ github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY= github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8= github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU= github.com/pion/webrtc/v4 v4.2.14 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc= github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -148,10 +223,16 @@ github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQ github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -169,20 +250,34 @@ github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJX github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -193,8 +288,12 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=