// Package ui is responsible for all things user interface. package ui import ( "fmt" "log" "path" "strings" "cwtch.im/cwtch/app" "cwtch.im/cwtch/event" cwtchModel "cwtch.im/cwtch/model" "git.openprivacy.ca/openprivacy/connectivity" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( welcomeMessage = ` _________ ________ ____ ______ / ____/ | / _/ __ \/ __ \/ ____/ / / / /| | / // /_/ / / / / __/ / /___/ ___ |_/ // _, _/ /_/ / /___ \____/_/ |_/___/_/ |_/_____/_____/ Metadata resistant messaging %s Run /start for the getting started guide Run /help to see all available commands` gettingStartedMessage = `===================== Getting started guide ===================== Welcome to Cairde, a terminal client for metadata resistant messaging built on the Cwtch protocol. In order to get started, you'll need to connect to the Tor network, initialise Cwtch and create your first profile. It's simple! (TODO: what is a profile?) 1. Type "/acn on" (without quotes) to start your Tor engines and initialise Cwtch. 2. Type "/profile create " to create a profile. Please replace <...> with your own name/passwords. Both passwords should match. WARNING: if you can't remember your password, you can't unlock your profile, so **write it down somehwere safe**. (TODO: how to invite contacts) (TODO: how to toggle contact via menu) (TODO: link to Cwtch docs for more info) ` ) // model is the core data structure for the entire programme type model struct { username string // system username userDir string // system user home directory acnState int // ACN state - online, offline (or in between...) menuState int // which buffer is currently selected menuBar []string // list of buffers in use (e.g. status, profile1) app app.Application // cwtch application manager acn connectivity.ACN // cwtch ACN connectivity manager width int // width of the TUI height int // height of the TUI showWelcomeMessage bool // whether or not we show the welcome message when turning on buffers []Buffer // all buffers statusBuffer Buffer // the central status buffer profiles profiles // all unlocked cwtch profiles profileState int // which profile is currently selected input textinput.Model // the central user input buffer hiddenInput string // the hidden input storage for e.g. passwords version string // the version of cairde debug bool // whether or not to run under debug mode } func NewModel(username, homeDir, version string, debug bool) model { // nolint:revive input := textinput.New() input.Prompt = "> " input.PromptStyle = inputPromptStyle input.Cursor.SetMode(cursor.CursorStatic) input.Placeholder = "Enter commands here..." input.TextStyle = inputTextStyle input.Focus() statusBuffer := Buffer{ BufferHandler: StatusBufferHandler{}, name: "status", inputChannel: make(chan string), viewportIsReady: false, persistence: true, // TODO: configurable } return model{ username: username, userDir: path.Join(homeDir, "/.cairde/"), acnState: offline, menuBar: []string{ strings.ToUpper(statusBuffer.name), }, menuState: 0, statusBuffer: statusBuffer, buffers: []Buffer{statusBuffer}, input: input, version: version, showWelcomeMessage: true, debug: debug, } } // receiveStatusCmd signals for lines from the statusBuffer to be rendered in // the statusViewport. This command is repeatedly called by the logic which // responds to a renderStatusMsg in Update. func (m model) receiveStatusCmd() tea.Msg { return renderStatusMsg{line: <-m.statusBuffer.inputChannel} } // sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the // statusBuffer. Unlike sendStatus, this method is an actual tea.Cmd. func (m model) sendStatusCmd(lines ...string) tea.Cmd { return func() tea.Msg { return sendStatusMsg{lines: lines} } } // sendStatus appends messages to the models status buffer directly. This // method should only be called from within a tea.Cmd as it is blocking and // does not return a tea.Msg. We need this for message passing in tea.Cmd when // we want to render messages to the status buffer but still have work to do. func (m model) sendStatus(lines ...string) { for _, line := range lines { logDebug(m, line) m.statusBuffer.inputChannel <- line } } func (m model) startProfileQueueCmd(onion string) tea.Cmd { return func() tea.Msg { profile := getProfile(m, onion) peer := m.app.GetPeer(onion) log.Printf("PEER ONION: %s", peer.GetOnion()) log.Printf("CONNECTION STATE: %v", peer.GetPeerState(onion)) for { message := profile.queue.Next() cid, _ := peer.FetchConversationInfo(message.Data[event.RemotePeer]) switch message.EventType { case event.NewMessageFromPeer: msg := unpackMessage(message.Data[event.Data]) log.Printf("RECEIVED MESSAGE: %v\n", msg) reply := string(packMessage(msg.Overlay, msg.Data)) peer.SendMessage(cid.ID, reply) case event.ContactCreated: log.Printf("AUTO APPROVING STRANGER: %v %v\n", cid, message.Data[event.RemotePeer]) peer.AcceptConversation(cid.ID) reply := string(packMessage(cwtchModel.OverlayChat, "Hello!")) peer.SendMessage(cid.ID, reply) } } } } func (m model) Init() tea.Cmd { return tea.Batch( textinput.Blink, m.receiveStatusCmd, func() tea.Msg { if err := m.statusBuffer.open(&m); err != nil { return cmdMsg{output: []string{err.Error()}} } return nil }, ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) m.statusBuffer.viewport, cmd = m.statusBuffer.viewport.Update(msg) cmds = append(cmds, cmd) for _, p := range m.profiles { p.statusViewport, cmd = p.statusViewport.Update(msg) cmds = append(cmds, cmd) } if m.showWelcomeMessage { withVersion := fmt.Sprintf(welcomeMessage, m.version) cmds = append(cmds, m.sendStatusCmd( strings.Split(withVersion, "\n")..., )) m.showWelcomeMessage = false } switch msg := msg.(type) { case tea.WindowSizeMsg: if msg.Width < 79 && msg.Height < 79 { break } m.width = msg.Width m.height = msg.Height m.input.Width = m.width - 5 if !m.statusBuffer.viewportIsReady { m.statusBuffer.viewport = newViewport(m.width, m.height-3) m.statusBuffer.viewportIsReady = true } else { m.statusBuffer.viewport.Width = m.width m.statusBuffer.viewport.Height = m.height - 3 } for _, p := range m.profiles { p.statusViewport.Width = m.width p.statusViewport.Height = m.height } case tea.KeyMsg: switch msg.String() { case "down": buffer := m.buffers[m.menuState] if len(buffer.inputHistory) == 0 { break } m.input.Reset() if buffer.inputHistoryCursor < len(buffer.inputHistory)-1 { buffer.inputHistoryCursor++ } m.buffers[m.menuState] = buffer lastCmd := buffer.inputHistory[buffer.inputHistoryCursor] m.input.SetValue(lastCmd) case "up": buffer := m.buffers[m.menuState] if len(buffer.inputHistory) == 0 { break } m.input.Reset() if buffer.inputHistoryCursor > 0 { buffer.inputHistoryCursor-- } m.buffers[m.menuState] = buffer lastCmd := buffer.inputHistory[buffer.inputHistoryCursor] m.input.SetValue(lastCmd) case "ctrl+c": cmds = append(cmds, func() tea.Msg { return turnAcnOff(&m, true) }) case "enter": cmdInput := m.input.Value() if len(cmdInput) == 0 { break } buffer := m.buffers[Status] if err := buffer.validate(m, cmdInput); err != nil { cmds = append(cmds, func() tea.Msg { return cmdMsg{output: []string{err.Error()}} }) break } buffer.persist(&m, cmdInput) cmds = append(cmds, func() tea.Msg { return buffer.handle(cmdInput[1:], m.hiddenInput) }) case "ctrl+n": m.menuState++ if m.menuState > len(m.menuBar)-1 { m.menuState = 0 } case "ctrl+p": m.menuState-- if m.menuState < 0 { m.menuState = len(m.menuBar) - 1 } case "ctrl+l": cmds = append(cmds, func() tea.Msg { return clearScreenMsg{} }) default: hidePasswordInput(&m) } case cmdMsg: m.input.Reset() cmds = append(cmds, m.sendStatusCmd(msg.output...)) case turnAcnOffMsg: switch m.acnState { case offline: if !msg.quitProgram { cmds = append(cmds, func() tea.Msg { return acnOfflineMsg{} }) return m, tea.Batch(cmds...) } case connecting: cmds = append(cmds, func() tea.Msg { return acnConnectingMsg{} }) return m, tea.Batch(cmds...) } m.input.Reset() cmds = append(cmds, func() tea.Msg { return turnAcnOff(&m, msg.quitProgram) }) case gracefulShutdownMsg: if err := m.statusBuffer.close(m); err != nil { panic(err) // TODO: ??? } return m, tea.Quit case acnOffMsg: if msg.quitProgram { return m, func() tea.Msg { return gracefulShutdownMsg{} } } cmds = append(cmds, m.sendStatusCmd("ACN successfully turned off")) m.acnState = offline case turnAcnOnMsg: switch m.acnState { case connecting: cmds = append(cmds, func() tea.Msg { return acnConnectingMsg{} }) return m, tea.Batch(cmds...) case connected: cmds = append(cmds, func() tea.Msg { return acnAlreadyOnlineMsg{} }) return m, tea.Batch(cmds...) } m.acnState = connecting m.input.Reset() cmds = append(cmds, func() tea.Msg { return turnAcnOn(&m) }) case AcnOnMsg: m.app = msg.app m.acn = msg.acn m.acnState = connected cmds = append(cmds, m.sendStatusCmd("ACN is up and running, all engines go")) case acnAlreadyOnlineMsg: m.input.Reset() cmds = append(cmds, m.sendStatusCmd("ACN is already online")) case acnOfflineMsg: m.input.Reset() cmds = append(cmds, m.sendStatusCmd("ACN is currently offline")) case acnConnectingMsg: m.input.Reset() cmds = append(cmds, m.sendStatusCmd("ACN still initialising, please hold")) case acnErrMsg: m.acnState = offline cmds = append(cmds, m.sendStatusCmd(msg.Error())) case showGettingStartedGuideMsg: m.input.Reset() cmds = append(cmds, m.sendStatusCmd( strings.Split(gettingStartedMessage, "\n")..., )) case profileCreateMsg: m.hiddenInput = "" m.input.Reset() if ok, msg := isConnected(m); !ok { cmds = append(cmds, func() tea.Msg { return msg }) break } m.app.CreateProfile(msg.name, msg.password, true) cmds = append(cmds, m.sendStatusCmd( fmt.Sprintf("Created new profile: %s", msg.name), )) profiles := m.app.ListProfiles() newProfile := profile{ name: msg.name, onion: profiles[len(profiles)-1], statusViewport: newViewport(m.width, m.height), } m.profiles = append(m.profiles, newProfile) // TODO: ActivateEngines went away, we need to fire up engines per onion address now // https://git.openprivacy.ca/cwtch.im/cwtch/commit/e311301d7264df7f510717f6b45e9f1ff339d323 cmds = append(cmds, func() tea.Msg { return startProfileCmd(&m, newProfile.onion) }) m.menuBar = append(m.menuBar, strings.ToUpper(msg.name)) case profileUnlockMsg: m.hiddenInput = "" m.input.Reset() if ok, msg := isConnected(m); !ok { cmds = append(cmds, func() tea.Msg { return msg }) break } profiles := unlockProfiles(m, msg.password) if len(profiles) > 0 { cmds = append(cmds, m.sendStatusCmd( fmt.Sprintf("Unlocked profile(s): %s", strings.Join(profiles.names(), " ")), )) } for _, profile := range profiles { m.menuBar = append(m.menuBar, strings.ToUpper(profile.name)) cmds = append(cmds, func() tea.Msg { return startProfileCmd(&m, profile.onion) }) } m.profiles = profiles case profileInfoMsg: m.input.Reset() if ok, msg := isConnected(m); !ok { cmds = append(cmds, func() tea.Msg { return msg }) break } if len(m.profiles) == 0 { cmds = append(cmds, m.sendStatusCmd("No profiles loaded")) break } profile := m.profiles[m.profileState] cmds = append(cmds, m.sendStatusCmd( fmt.Sprintf("Profile onion: %s", profile.onion), )) case startProfileQueuePollMsg: cmds = append(cmds, m.startProfileQueueCmd(msg.onion)) case sendStatusMsg: cmds = append(cmds, func() tea.Msg { for _, line := range msg.lines { logDebug(m, line) m.statusBuffer.inputChannel <- line } return nil }) case renderStatusMsg: m.statusBuffer.viewportLines = append(m.statusBuffer.viewportLines, renderLines(m.width, msg.line)...) m.statusBuffer.viewport.SetContent(strings.Join(m.statusBuffer.viewportLines, "\n")) m.statusBuffer.viewport.GotoBottom() cmds = append(cmds, m.receiveStatusCmd) case clearScreenMsg: m.input.Reset() m.statusBuffer.viewportLines = []string{} m.statusBuffer.viewport.SetContent("") m.statusBuffer.viewport.GotoTop() } return m, tea.Batch(cmds...) } func (m model) View() string { body := strings.Builder{} var chosenViewport viewport.Model switch m.menuState { case 0: chosenViewport = m.statusBuffer.viewport default: if m.menuState > len(m.profiles) { chosenViewport = m.statusBuffer.viewport break } chosenViewport = m.profiles[m.menuState-1].statusViewport } var renderedMenuBar []string for idx, menuItem := range m.menuBar { rendered := menuItemStyle.Render(menuItem) if m.menuState == idx { rendered = chosenMenuItemStyle.Render(menuItem) } renderedMenuBar = append(renderedMenuBar, rendered) } body.WriteString( lipgloss.JoinVertical( lipgloss.Bottom, viewportStyle. Width(m.width). Render(chosenViewport.View()), cmdBarStyle. Width(m.width). Render( lipgloss.JoinHorizontal( lipgloss.Left, renderCmdHelp(m)..., ), ), menuBarStyle. Width(m.width). Render( lipgloss.JoinHorizontal( lipgloss.Left, renderedMenuBar..., ), ), inputStyle. Width(m.width). Render(m.input.View()), ), ) return body.String() }