From 8a7e1568aea52b43f92807dc93f7f6c425913c97 Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Sun, 9 Jan 2022 04:30:22 +0200 Subject: [PATCH] feat: BIG changes * use commands constants * use a separate goroutine to read from connection * refactor connection reading and ui updating * add sidepanel for users in channel * other cleanups --- .gitignore | 3 + client/client.go | 69 +++++++++--- constants.go => commands/commands.go | 3 +- parser/parser.go | 12 ++- ui/app/app.go | 15 ++- ui/app/cmds.go | 6 +- ui/mainscreen/cmds.go | 32 +++--- ui/mainscreen/input.go | 16 +-- ui/mainscreen/mainscreen.go | 155 ++++++++++++++++++++------- ui/mainscreen/sidepanel.go | 60 +++++++++++ ui/mainscreen/styles.go | 6 +- 11 files changed, 280 insertions(+), 97 deletions(-) rename constants.go => commands/commands.go (99%) create mode 100644 ui/mainscreen/sidepanel.go diff --git a/.gitignore b/.gitignore index 441ef88..6e64071 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # binary gorc + +# vscode stuff +.vscode diff --git a/client/client.go b/client/client.go index 63099a4..a73b027 100644 --- a/client/client.go +++ b/client/client.go @@ -24,12 +24,35 @@ import ( "strings" ) -type Client struct { - // tls connection - TlsConn *tls.Conn +type User struct { + // Nickname of user + Nick string +} +type Channel struct { + // Channel topic + Topic string + + // Channel messages history + History string + + // Users in this channel + Users []User +} + +type Client struct { // tcp connection - TcpConn *net.TCPConn + TcpConn net.Conn + + // Channel to receive quit msg + Quit chan struct{} + + Host string + Port string + Nickname string + MOTD string // TODO: + Channels map[string]Channel // TODO: + ActiveChannel string } const CRLF = "\r\n" @@ -46,8 +69,12 @@ func NewClient(host string, port string, tlsEnabled bool) Client { } return Client{ - TlsConn: conn, - TcpConn: nil, + TcpConn: conn, + Host: host, + Port: port, + Quit: make(chan struct{}), + ActiveChannel: host, + Channels: make(map[string]Channel), } } @@ -62,27 +89,39 @@ func NewClient(host string, port string, tlsEnabled bool) Client { } return Client{ - TlsConn: nil, - TcpConn: conn, + TcpConn: conn, + Host: host, + Port: port, + Quit: make(chan struct{}), + ActiveChannel: host, + Channels: make(map[string]Channel), } } -func (c Client) Register(nick string, password string, channel string) { +func (c *Client) Register(nick string, password string, channel string) { c.SendCommand("CAP", "LS") if password != "" { c.SendCommand("PASS", password) } // TODO: check if nickname has spaces and remove them c.SendCommand("NICK", nick) + c.Nickname = nick c.SendCommand("USER", nick, "0", "*", nick) // TODO: CAP REQ :whatever capability the client recognizes and supports c.SendCommand("CAP", "REQ", ":message-tags") c.SendCommand("CAP", "END") - c.SendCommand("JOIN", channel) + // joining a channel when registering is optional + if channel != "" { + c.SendCommand("JOIN", channel) + c.ActiveChannel = channel + c.Channels[c.ActiveChannel] = Channel{} + } else { + c.Channels[c.ActiveChannel] = Channel{} + } } func (c Client) SendCommand(cmd string, params ...string) { - if c.TlsConn == nil && c.TcpConn == nil { + if c.TcpConn == nil { // TODO: properly handle the error instead of Fatal-ing log.Fatal("Attempted to write data to nil connection") } @@ -103,8 +142,8 @@ func (c Client) SendCommand(cmd string, params ...string) { paramsString = " " + strings.Join(params[:len(params)-1], " ") paramsString += lastParam - // if we have exactly 1 param - } else if len(params) == 1 { + // if we have exactly 1 param and it's not empty + } else if len(params) == 1 && params[0] != "" { // if this 1 param contains spaces, we prepend a colon if strings.Contains(params[0], " ") { paramsString = " :" + params[0] @@ -113,9 +152,7 @@ func (c Client) SendCommand(cmd string, params ...string) { } } - if c.TlsConn != nil { - c.TlsConn.Write([]byte(cmd + paramsString + CRLF)) - } else if c.TcpConn != nil { + if c.TcpConn != nil { c.TcpConn.Write([]byte(cmd + paramsString + CRLF)) } else { // TODO: properly handle the error instead of Fatal-ing diff --git a/constants.go b/commands/commands.go similarity index 99% rename from constants.go rename to commands/commands.go index f1b6535..4d4243c 100644 --- a/constants.go +++ b/commands/commands.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License along // with this program; if not, see https://www.gnu.org/licenses. -package main +package commands /* --- IRC Ports --- */ const ( @@ -71,6 +71,7 @@ const ( USERHOST = "USERHOST" // Get information about user with a given nickname. can take up to 5 nickanmes. // Left behind... + PING = "PING" PONG = "PONG" // Respond to a server PING ) diff --git a/parser/parser.go b/parser/parser.go index 5f81b25..3518d01 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -51,14 +51,16 @@ func parseTags(rawTags string) Tags { return tags } -func ParseIRCMessage(line string) IRCMessage { +func ParseIRCMessage(line string) (IRCMessage, bool) { ircMessage := IRCMessage{} multipleSpacesRegex := regexp.MustCompile("[^\\S\\t]+") trailingParamRegex := regexp.MustCompile("[^\\S\\t]+:") whitespaceRegex := regexp.MustCompile("^[^\\S\\t]+$") if len(line) <= 0 { - return IRCMessage{} + // TODO: do something here ??? + // log.Println("empty message") + return IRCMessage{}, false } if line[0] == '@' { @@ -94,12 +96,12 @@ func ParseIRCMessage(line string) IRCMessage { } // if we have 1 param and it starts with a colon - if len(parameters) == 1 && parameters[0][0] == ':' { - parameters[0] = parameters[0][1:] + if len(params) == 1 && params[0][0] == ':' { + parameters[0] = params[0][1:] } ircMessage.Parameters = parameters } - return ircMessage + return ircMessage, true } diff --git a/ui/app/app.go b/ui/app/app.go index 6ef1a96..a1a6bfe 100644 --- a/ui/app/app.go +++ b/ui/app/app.go @@ -92,21 +92,18 @@ func (s State) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tlsEnabled := s.UI.Login.TLS s.UI.CurrentScreen = MainScreen - s.UI.MainScreen.CurrentChannel = channel // create new client with the provided host and port (*s.Client) = client.NewClient(host, port, tlsEnabled) s.Client.Register(nickname, password, channel) - if tlsEnabled { - r := bufio.NewReaderSize(s.Client.TlsConn, 512) - s.UI.MainScreen.Reader = r - } else { - r := bufio.NewReaderSize(s.Client.TcpConn, 512) - s.UI.MainScreen.Reader = r - } + // 512 bytes as a base + 8192 additional bytes for tags + r := bufio.NewReaderSize(s.Client.TcpConn, 8192+512) + s.UI.MainScreen.ConnReader = r - return s, mainscreen.InitialRead + go s.UI.MainScreen.ReadLoop() + + return s, textinput.Blink } // switch between which screen is currently active and update its state diff --git a/ui/app/cmds.go b/ui/app/cmds.go index 9bddaf5..67a31c0 100644 --- a/ui/app/cmds.go +++ b/ui/app/cmds.go @@ -19,10 +19,10 @@ package app import tea "github.com/charmbracelet/bubbletea" func (s *State) Quit() tea.Msg { - if s.Client.TlsConn != nil { - s.Client.SendCommand("QUIT") - s.Client.TlsConn.Close() + if s.Client.Quit != nil { + close(s.Client.Quit) } + if s.Client.TcpConn != nil { s.Client.SendCommand("QUIT") s.Client.TcpConn.Close() diff --git a/ui/mainscreen/cmds.go b/ui/mainscreen/cmds.go index 3a33e89..4245862 100644 --- a/ui/mainscreen/cmds.go +++ b/ui/mainscreen/cmds.go @@ -18,27 +18,29 @@ package mainscreen import ( "log" + "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/illusionman1212/gorc/parser" ) -type InitialReadMsg struct{} +func (s *State) ReadLoop() { + for { + select { + case <-s.Client.Quit: + return + default: + msg, err := s.ConnReader.ReadString('\n') + if err != nil { + log.Fatal(err) + } -func InitialRead() tea.Msg { - return InitialReadMsg{} -} - -type ReceivedIRCCommandMsg struct { - Msg string -} - -func (s State) readFromServer() tea.Msg { - msg, err := s.Reader.ReadString('\n') - if err != nil { - log.Print(err) + msg = strings.Replace(msg, "\r\n", "", 1) + if ircMessage, valid := parser.ParseIRCMessage(msg); valid { + s.HandleCommand(ircMessage) + } + } } - - return ReceivedIRCCommandMsg{Msg: msg} } type SendPrivMsg struct { diff --git a/ui/mainscreen/input.go b/ui/mainscreen/input.go index 843c214..9bd196f 100644 --- a/ui/mainscreen/input.go +++ b/ui/mainscreen/input.go @@ -29,17 +29,15 @@ type InputState struct { Client *client.Client } -func NewInputBox(client *client.Client) InputState { +func NewInputBox() InputState { input := textinput.NewModel() input.Placeholder = "Send a message..." input.Focus() - state := InputState{ - Input: input, - Style: InputboxStyle.Copy(), - Client: client, - } - return state + return InputState{ + Input: input, + Style: InputboxStyle.Copy(), + } } func (s InputState) Update(msg tea.Msg) (InputState, tea.Cmd) { @@ -49,6 +47,10 @@ func (s InputState) Update(msg tea.Msg) (InputState, tea.Cmd) { switch key { case "enter": value := s.Input.Value() + if len(value) == 0 { + return s, nil + } + s.Input.Reset() return s, s.SendingPrivMsg(value) diff --git a/ui/mainscreen/mainscreen.go b/ui/mainscreen/mainscreen.go index 87930a5..5aa335b 100644 --- a/ui/mainscreen/mainscreen.go +++ b/ui/mainscreen/mainscreen.go @@ -26,6 +26,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/illusionman1212/gorc/client" + "github.com/illusionman1212/gorc/commands" "github.com/illusionman1212/gorc/parser" "github.com/illusionman1212/gorc/ui" ) @@ -38,36 +39,31 @@ const ( ) type State struct { - Content string - Reader *bufio.Reader + ConnReader *bufio.Reader Client *client.Client - Viewport viewport.Model + Viewport *viewport.Model Style lipgloss.Style FocusIndex Window - // a channel could be either a server channel - // or a username of a user - CurrentChannel string - - InputBox InputState + InputBox InputState + SidePanel *SidePanelState } func NewMainScreen(client *client.Client) State { - newViewport := viewport.Model{ + newViewport := &viewport.Model{ HighPerformanceRendering: false, Wrap: true, } - state := State{ - Content: "", - Reader: nil, + return State{ + ConnReader: nil, Client: client, Viewport: newViewport, Style: MessagesStyle.Copy(), FocusIndex: InputBox, - InputBox: NewInputBox(client), + InputBox: NewInputBox(), + SidePanel: NewSidePanel(client), } - return state } func (s State) Update(msg tea.Msg) (State, tea.Cmd) { @@ -75,20 +71,36 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case InitialReadMsg: - return s, s.readFromServer - case ReceivedIRCCommandMsg: - message := parser.ParseIRCMessage(msg.Msg) - s.HandleCommand(message) - - return s, s.readFromServer case SendPrivMsg: - s.Content += msg.Msg + client.CRLF - s.Client.SendCommand("PRIVMSG", s.CurrentChannel, msg.Msg) + // for sending slash commands + // TODO: make this better + if msg.Msg[0] == '/' { + substrs := strings.SplitN(msg.Msg[1:], " ", 2) + command := substrs[0] + var params []string + if len(substrs) > 1 { + params = strings.Split(substrs[1], " ") + } + if strings.ToUpper(command) == commands.JOIN { + s.Client.ActiveChannel = params[0] + s.Client.Channels[params[0]] = client.Channel{} + s.Client.SendCommand(command, params...) + return s, nil + } + s.Client.SendCommand(command, params...) + } else { + // TODO: dont send command when activechannel == c.host + fullMsg := s.Client.Nickname + ": " + msg.Msg + if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { + c.History += fullMsg + client.CRLF - s.Viewport.SetContent(s.Content) + s.Client.Channels[s.Client.ActiveChannel] = c + } + s.Client.SendCommand(commands.PRIVMSG, s.Client.ActiveChannel, msg.Msg) + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + } - return s, s.readFromServer + return s, nil case tea.KeyMsg: key := msg.String() switch key { @@ -125,9 +137,12 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { } } + // TODO: maybe we still need this, not sure + // s.Viewport.SetContent(s.Client.ActiveChannel.History) + switch s.FocusIndex { case Viewport: - s.Viewport, cmd = s.Viewport.Update(msg) + *s.Viewport, cmd = s.Viewport.Update(msg) cmds = append(cmds, cmd) case InputBox: s.InputBox, cmd = s.InputBox.Update(msg) @@ -139,31 +154,91 @@ func (s State) Update(msg tea.Msg) (State, tea.Cmd) { func (s *State) SetSize(width, height int) { s.InputBox.SetSize(width) - s.Viewport.Width = width - s.Style.GetVerticalFrameSize() + s.SidePanel.SetSize(width, height, s.InputBox.Style.Copy().GetVerticalFrameSize()) + + // -1 is for some extra invisible margin or padding or whatever (idk what it is tbh) + newViewportWidth := (width * 8 / 10) - s.Style.GetHorizontalBorderSize() - 1 + newViewportHeight := height - s.InputBox.Style.GetVerticalFrameSize() - s.Style.GetVerticalBorderSize() - 1 + // copying the existing style is important here otherwise we'll end up with artifacting - s.Style = s.Style.Copy().Width(width - s.Style.GetVerticalFrameSize()) - s.Viewport.Height = height - s.InputBox.Style.GetHorizontalFrameSize() - s.Style.GetHorizontalFrameSize() - s.Style = s.Style.Copy().Height(height - s.InputBox.Style.GetHorizontalFrameSize() - s.Style.GetHorizontalFrameSize()) + s.Viewport.Width = newViewportWidth + s.Style = s.Style.Width(newViewportWidth) + s.Viewport.Height = newViewportHeight + s.Style = s.Style.Height(newViewportHeight) // we need to re-set the content because words wrap differently on different sizes - s.Viewport.SetContent(s.Content) + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) } func (s *State) HandleCommand(msg parser.IRCMessage) { - fullMsg := fmt.Sprintf("%s %s %s %s", msg.Tags, msg.Source, msg.Command, strings.Join(msg.Parameters, " ")) - s.Content += fullMsg - - s.Viewport.SetContent(s.Content) - // TODO: handle different commands switch msg.Command { - case "PING": - s.Client.SendCommand("PONG", msg.Parameters[0]) - break + case commands.PING: + s.Client.SendCommand(commands.PONG, msg.Parameters[0]) + case commands.PRIVMSG: + nick := strings.SplitN(msg.Source, "!", 2)[0] + channel := msg.Parameters[0] + msgContent := msg.Parameters[1] + fullMsg := fmt.Sprintf("%s: %s", nick, msgContent) + + if c, ok := s.Client.Channels[channel]; ok { + c.History += fullMsg + client.CRLF + + s.Client.Channels[channel] = c + } + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + case commands.JOIN: + nick := strings.SplitN(msg.Source, "!", 2)[0] + channel := msg.Parameters[0] + + fullMsg := fmt.Sprintf("== %s has joined", nick) + if c, ok := s.Client.Channels[channel]; ok { + c.History += fullMsg + client.CRLF + + s.Client.Channels[channel] = c + } + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + case commands.QUIT: + nick := strings.SplitN(msg.Source, "!", 2)[0] + reason := msg.Parameters[0] + fullMsg := fmt.Sprintf("== %s has quit (%s)", nick, reason) + if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { + c.History += fullMsg + client.CRLF + + s.Client.Channels[s.Client.ActiveChannel] = c + } + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + case commands.RPL_NAMREPLY: + // TODO: put the users into their respective channels + // client := msg.Parameters[0] + // chanSymbol := msg.Parameters[1] + // channel := msg.Parameters[2] + nicks := strings.Split(msg.Parameters[3], " ") + + s.SidePanel.Nicks = append(s.SidePanel.Nicks, nicks...) + case commands.RPL_ENDOFNAMES: + fullMsg := fmt.Sprintf("%s %s %s %s", msg.Tags, msg.Source, msg.Command, strings.Join(msg.Parameters, " ")) + if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { + c.History += fullMsg + client.CRLF + + s.Client.Channels[s.Client.ActiveChannel] = c + } + + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) + default: + fullMsg := fmt.Sprintf("%s %s %s %s", msg.Tags, msg.Source, msg.Command, strings.Join(msg.Parameters, " ")) + if c, ok := s.Client.Channels[s.Client.ActiveChannel]; ok { + c.History += fullMsg + client.CRLF + + s.Client.Channels[s.Client.ActiveChannel] = c + } + + s.Viewport.SetContent(s.Client.Channels[s.Client.ActiveChannel].History) } } func (s State) View() string { - screen := lipgloss.JoinVertical(0, s.Style.Render(s.Viewport.View()), s.InputBox.View()) + top := lipgloss.JoinHorizontal(lipgloss.Right, s.Style.Render(s.Viewport.View()), s.SidePanel.View()) + screen := lipgloss.JoinVertical(0, top, s.InputBox.View()) return ui.MainStyle.Render(screen) } diff --git a/ui/mainscreen/sidepanel.go b/ui/mainscreen/sidepanel.go new file mode 100644 index 0000000..0df0546 --- /dev/null +++ b/ui/mainscreen/sidepanel.go @@ -0,0 +1,60 @@ +// gorc project +// Copyright (C) 2021 IllusionMan1212 +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see https://www.gnu.org/licenses. + +package mainscreen + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/illusionman1212/gorc/client" +) + +type SidePanelState struct { + // TODO: make a viewport and use that to display the names + // OR: we could use a list + Style lipgloss.Style + Nicks []string + Viewport viewport.Model + Content string +} + +func NewSidePanel(client *client.Client) *SidePanelState { + return &SidePanelState{ + Style: SidePanelStyle.Copy(), + } +} + +func (s SidePanelState) Update(msg tea.Msg) (SidePanelState, tea.Cmd) { + return s, nil +} + +func (s *SidePanelState) SetSize(width, height, inputboxHeight int) { + s.Style = s.Style.Copy().Width(width * 2 / 10) + // -1 is for some extra invisible margin or padding or whatever (idk what it is tbh) + s.Style = s.Style.Copy().Height(height - inputboxHeight - s.Style.GetVerticalBorderSize() - 1) +} + +func (s SidePanelState) View() string { + separator := strings.Repeat("-", s.Style.GetWidth()) + "\n" + header := fmt.Sprintf("Users in this channel (%d)\n", len(s.Nicks)) + separator + nicks := strings.Join(s.Nicks, "\n") + + return s.Style.Render(header + nicks) +} diff --git a/ui/mainscreen/styles.go b/ui/mainscreen/styles.go index 51b749d..2322548 100644 --- a/ui/mainscreen/styles.go +++ b/ui/mainscreen/styles.go @@ -29,7 +29,11 @@ var ( BorderForeground(lipgloss.Color("105")) MessagesStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), true). - Width(ui.MainStyle.GetWidth()). + Width(ui.MainStyle.GetWidth() * 8 / 10). + BorderForeground(lipgloss.Color("#EEE")) + SidePanelStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), true). + Width(ui.MainStyle.GetWidth() * 2 / 10). BorderForeground(lipgloss.Color("#EEE")) InputBoxHeight = 1