cairde/ui/model.go

558 lines
14 KiB
Go

// 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 <name> <password> <password>" 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()
}