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) }