498 lines
11 KiB
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()
|
|
}
|