380 lines
8.2 KiB
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()
|
|
}
|