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/ui/model.go

498 lines
11 KiB
Go

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"
)
const (
welcomeMessage = `
_________ ________ ____ ______
/ ____/ | / _/ __ \/ __ \/ ____/
/ / / /| | / // /_/ / / / / __/
/ /___/ ___ |_/ // _, _/ /_/ / /___
\____/_/ |_/___/_/ |_/_____/_____/
Metadata resistant messaging
Pre-alpha v0.1.0
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)
`
)
type model struct {
username string
userDir string
acnState int
menuState int
menuBar []string
app app.Application
acn connectivity.ACN
width int
height int
showWelcomeMessage bool
statusViewport viewport.Model
statusViewportLines []string
statusViewportReady bool
profiles profiles
profileState int
input textinput.Model
hiddenInput string
statusBuffer chan string
debug bool
}
func NewModel(username, homeDir 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()
return model{
username: username,
userDir: path.Join(homeDir, "/.cairde/"),
acnState: offline,
menuBar: []string{"STATUS"},
menuState: 0,
input: input,
statusBuffer: make(chan string),
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}
}
// 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 {
logToFile(m, line)
m.statusBuffer <- 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 (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:
if msg.Width < 79 && msg.Height < 79 {
break
}
m.width = msg.Width
m.height = msg.Height
m.input.Width = m.width - 5
if !m.statusViewportReady {
m.statusViewport = newViewport(m.width, m.height-3)
m.statusViewportReady = true
} else {
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
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
cmds = append(cmds, func() tea.Msg {
return turnAcnOff(&m, true)
})
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++
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 acnOffMsg:
if msg.quitProgram {
return m, tea.Quit
}
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.app.ActivateEngines(true, true, true)
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:
cmds = append(cmds, m.sendStatusCmd(
strings.Split(gettingStartedMessage, "\n")...,
))
m.input.Reset()
case createProfileMsg:
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)
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 {
logToFile(m, line)
m.statusBuffer <- line
}
return nil
})
case renderStatusMsg:
m.statusViewportLines = append(m.statusViewportLines, renderLines(m.width, msg.line)...)
m.statusViewport.SetContent(strings.Join(m.statusViewportLines, "\n"))
m.statusViewport.GotoBottom()
cmds = append(cmds, m.receiveStatusCmd)
case clearScreenMsg:
m.input.Reset()
m.statusViewportLines = []string{}
m.statusViewport.SetContent("")
m.statusViewport.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.statusViewport
default:
if m.menuState > len(m.profiles) {
chosenViewport = m.statusViewport
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()
}