This repository has been archived on 2024-07-28. You can view files and clone it, but cannot push or open issues or pull requests.
cairde/model.go

380 lines
8.2 KiB
Go

package main
import (
"fmt"
"path"
"strings"
"cwtch.im/cwtch/app"
"git.openprivacy.ca/openprivacy/connectivity"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
welcomeMessage = `
_________ ________ ____ ______
/ ____/ | / _/ __ \/ __ \/ ____/
/ / / /| | / // /_/ / / / / __/
/ /___/ ___ |_/ // _, _/ /_/ / /___
\____/_/ |_/___/_/ |_/_____/_____/
Metadata resistant messaging
pre-alpha v0.1.0
Run /start for the getting started guide
Use the /help command to see the command listing
`
gettingStartedMessage = `
== Getting started guide ==
Welcome to Cairde, a text based user interface (TUI) for metadata resistant
messaging. In order to get started, you'll need to connect to the Tor network,
initialise Cwtch and create your first profile. It's easy!
1. Type /connect 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.
TODO:
* how to invite contacts
* how to toggle contact via menu
`
)
type model struct {
username string
userDir string
connState int
menuState int
menuBar []string
app app.Application
acn connectivity.ACN
width int
height int
showWelcomeMessage bool
statusViewport viewport.Model
profiles profiles
profileState int
input textinput.Model
hiddenInput string
statusBuffer chan string
}
func newModel(username, homeDir string) model {
input := textinput.New()
input.Prompt = ""
input.SetCursorMode(textinput.CursorStatic)
input.Focus()
return model{
username: username,
userDir: path.Join(homeDir, "/.cairde/"),
connState: offline,
menuBar: []string{"status"},
menuState: 0,
input: input,
statusBuffer: make(chan string),
showWelcomeMessage: true,
}
}
func (m model) receiveStatusCmd() tea.Msg {
return renderStatusMsg{line: <-m.statusBuffer}
}
// sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the
// statusbuffer. Unlinke 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 {
m.statusBuffer <- line
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
m.receiveStatusCmd,
)
}
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.statusViewport, cmd = m.statusViewport.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 {
cmds = append(cmds, m.sendStatusCmd(
strings.Split(welcomeMessage, "\n")...,
))
m.showWelcomeMessage = false
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width - 2
m.height = msg.Height - 2
m.statusViewport.Width = m.width
m.statusViewport.Height = m.height - 3
for _, p := range m.profiles {
p.statusViewport.Width = m.width
p.statusViewport.Height = m.height - 3
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
cmds = append(cmds, func() tea.Msg {
return attemptShutdown(&m)
})
case "enter":
cmdInput := m.input.Value()
if len(cmdInput) == 0 || string(cmdInput[0]) != "/" {
break
}
cmds = append(cmds, func() tea.Msg {
return handleCommand(cmdInput[1:], m.hiddenInput)
})
case "ctrl+n":
m.menuState += 1
if m.menuState > len(m.menuBar)-1 {
m.menuState = 0
}
case "ctrl+p":
m.menuState -= 1
if m.menuState < 0 {
m.menuState = len(m.menuBar) - 1
}
default:
hidePasswordInput(&m)
}
case cmdMsg:
m.input.Reset()
cmds = append(cmds, m.sendStatusCmd(msg.output...))
case offlineMsg:
m.input.Reset()
cmds = append(cmds, m.sendStatusCmd("currently offline, run /connect"))
case stillConnectingMsg:
m.input.Reset()
cmds = append(cmds, m.sendStatusCmd("conn: initialising, please hold"))
case startMsg:
cmds = append(cmds, m.sendStatusCmd(
strings.Split(gettingStartedMessage, "\n")...,
))
m.input.Reset()
case shutdownMsg:
m.input.Reset()
return m, tea.Quit
case createProfileMsg:
m.hiddenInput = ""
m.input.Reset()
if isConnected, msg := ensureConnected(m); !isConnected {
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: viewport.New(
m.width,
m.height-3,
),
}
m.profiles = append(m.profiles, newProfile)
cmds = append(cmds, startProfileCmd(m, newProfile.onion))
m.menuBar = append(m.menuBar, msg.name)
case profileUnlockMsg:
m.hiddenInput = ""
m.input.Reset()
if isConnected, msg := ensureConnected(m); !isConnected {
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, profile.name)
cmds = append(cmds, startProfileCmd(m, profile.onion))
}
m.profiles = profiles
case profileInfoMsg:
m.input.Reset()
if isConnected, msg := ensureConnected(m); !isConnected {
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 sendStatusMsg:
cmds = append(cmds, func() tea.Msg {
for _, line := range msg.lines {
m.statusBuffer <- line
}
return nil
})
case renderStatusMsg:
content := renderLines(msg.line)
existing := strings.TrimSpace(m.statusViewport.View())
if len(existing) != 0 {
content = existing + "\n" + renderLines(msg.line)
}
m.statusViewport.SetContent(content)
m.statusViewport.GotoBottom()
cmds = append(cmds, m.receiveStatusCmd)
case clearMsg:
m.input.Reset()
m.statusViewport.SetContent("")
m.statusViewport.GotoTop()
case connectMsg:
m.input.Reset()
cmds = append(cmds, func() tea.Msg {
return connInit(&m)
})
case appInitialisedMsg:
m.app = msg.app
m.acn = msg.acn
m.connState = connected
m.app.ActivateEngines(true, true, false)
case errMsg:
cmds = append(cmds, m.sendStatusCmd(msg.Error()))
}
return m, tea.Batch(cmds...)
}
func (m model) View() string {
body := strings.Builder{}
var renderedMenuBar []string
for idx, menuItem := range m.menuBar {
rendered := menuButtonStyle.Render(menuItem)
if m.menuState == idx {
rendered = chosenMenuButtonStyle.Render(menuItem)
}
renderedMenuBar = append(renderedMenuBar, rendered)
}
var chosenViewport viewport.Model
switch m.menuState {
case 0:
chosenViewport = m.statusViewport
default:
if m.menuState > len(m.profiles) {
chosenViewport = m.statusViewport
break
}
chosenViewport = m.profiles[m.menuState-1].statusViewport
}
body.WriteString(
containerStyle.
Width(m.width).
Height(m.height).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
viewportStyle.
Width(m.width).
Render(chosenViewport.View()),
lipgloss.JoinHorizontal(
lipgloss.Left,
renderedMenuBar...,
),
inputStyle.
Width(m.width).
Render(m.input.View()),
),
),
)
return body.String()
}