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:
@@ -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 everything
|
||||||
build: build-web build-server
|
build: build-web build-server
|
||||||
@@ -7,6 +7,10 @@ build: build-web build-server
|
|||||||
build-server:
|
build-server:
|
||||||
CGO_ENABLED=0 go build -o dumpster-server ./cmd/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 the web frontend
|
||||||
build-web:
|
build-web:
|
||||||
cd web && npm run build
|
cd web && npm run build
|
||||||
@@ -15,6 +19,10 @@ build-web:
|
|||||||
run: build-server
|
run: build-server
|
||||||
./dumpster-server
|
./dumpster-server
|
||||||
|
|
||||||
|
# Run the TUI client
|
||||||
|
run-tui: build-tui
|
||||||
|
./dumpster-tui
|
||||||
|
|
||||||
# Run web dev server (in a separate terminal)
|
# Run web dev server (in a separate terminal)
|
||||||
dev-web:
|
dev-web:
|
||||||
cd web && npm run dev
|
cd web && npm run dev
|
||||||
@@ -37,6 +45,6 @@ docker-clean:
|
|||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
rm -f dumpster-server
|
rm -f dumpster-server dumpster-tui
|
||||||
rm -rf web/dist
|
rm -rf web/dist
|
||||||
rm -rf web/node_modules
|
rm -rf web/node_modules
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ module git.dustin.coffee/hobokenchicken/dumpsterChat
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
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/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
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
|
github.com/minio/minio-go/v7 v7.2.1
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
|
golang.org/x/term v0.44.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -17,12 +24,21 @@ require (
|
|||||||
buf.build/go/protoyaml v0.7.0 // indirect
|
buf.build/go/protoyaml v0.7.0 // indirect
|
||||||
cel.dev/expr v0.25.2 // indirect
|
cel.dev/expr v0.25.2 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 // 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/benbjohnson/clock v1.3.5 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/dennwc/iters v1.2.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/frostbyte73/core v0.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/gammazero/deque v1.2.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/compress v1.18.6 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/klauspost/crc32 v1.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/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||||
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // 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/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/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/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.52.0 // indirect
|
github.com/nats-io/nats.go v1.52.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.16 // 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/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
|
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.20.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/rs/xid v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // 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
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
go.uber.org/atomic v1.11.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/grpc v1.81.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
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 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
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 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
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 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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.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.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 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 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
|
||||||
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||||
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
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/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
|
||||||
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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.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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
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.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
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/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 h1:oYnp2o3YTBdL4xVkq5NpLwqnCijZVfVJU9ddFl+BW/Y=
|
||||||
github.com/livekit/server-sdk-go/v2 v2.16.7/go.mod h1:B3qlhVBZ4olBWRN/KxokLZE2d4LujNk4n2yYDL3+u2s=
|
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 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||||
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
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 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
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 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw=
|
||||||
github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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=
|
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/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 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
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/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 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY=
|
||||||
github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U=
|
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 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
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 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8=
|
||||||
github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU=
|
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 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc=
|
||||||
github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak=
|
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.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 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 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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=
|
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/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 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
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 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
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 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
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 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
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/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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
|||||||
Reference in New Issue
Block a user