2023-01-11 22:58:07 +00:00
|
|
|
// Package app implements a terminal user interface (tui)
|
|
|
|
package app
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
|
|
"github.com/gdamore/tcell/v2/encoding"
|
|
|
|
"github.com/posener/formatter"
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
sync "github.com/sasha-s/go-deadlock"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"go.yarn.social/lextwt"
|
|
|
|
|
2023-02-27 22:37:41 +00:00
|
|
|
"go.salty.im/saltyim"
|
2023-01-11 22:58:07 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
bufferStatus = "Status"
|
|
|
|
)
|
|
|
|
|
|
|
|
// App is a terminal user inerface (TUI) using tview/tcell to provide an interface
|
|
|
|
// for a saltyim.Client (cli) client and provides a multi-chat experience allowing
|
|
|
|
// the user to switch between active chats.
|
|
|
|
type App struct {
|
|
|
|
sync.RWMutex
|
|
|
|
*tview.Application
|
|
|
|
|
2023-01-26 22:30:16 +00:00
|
|
|
cli *saltyim.Client
|
|
|
|
|
2023-01-11 22:58:07 +00:00
|
|
|
user string
|
2023-01-26 22:30:16 +00:00
|
|
|
addr saltyim.Addr
|
2023-01-11 22:58:07 +00:00
|
|
|
|
|
|
|
layout *tview.Flex
|
|
|
|
views *tview.Pages
|
|
|
|
chats *tview.List
|
|
|
|
buffers *tview.Pages
|
|
|
|
bufferMap map[string]*tview.TextView
|
|
|
|
input *tview.InputField
|
|
|
|
|
|
|
|
// I/O channels
|
|
|
|
in chan string
|
|
|
|
out chan string
|
|
|
|
|
|
|
|
// Configurations.
|
|
|
|
palette map[string]string
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewApp initializes a new tui chat app
|
|
|
|
func NewApp(cli *saltyim.Client) (*App, error) {
|
|
|
|
app := &App{
|
|
|
|
Application: tview.NewApplication(),
|
|
|
|
|
|
|
|
cli: cli,
|
|
|
|
|
|
|
|
bufferMap: make(map[string]*tview.TextView),
|
|
|
|
|
|
|
|
in: make(chan string),
|
|
|
|
out: make(chan string),
|
|
|
|
|
|
|
|
palette: map[string]string{
|
|
|
|
"background": "000000",
|
|
|
|
"border": "4C5C68",
|
|
|
|
"title": "46494C",
|
|
|
|
"date": "E76F51",
|
|
|
|
"text": "577399",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
app.doLayout()
|
|
|
|
app.setKeyBindings()
|
|
|
|
|
|
|
|
return app, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) doLayout() {
|
|
|
|
inputTitle := fmt.Sprintf("Connected to %s as %s", app.cli.Me().Endpoint(), app.cli.Me())
|
|
|
|
app.input = NewChatInput(app.palette, inputTitle, app.messageHandler())
|
|
|
|
|
|
|
|
app.chats = tview.NewList()
|
|
|
|
app.chats.SetBorder(true).SetTitle("Chats")
|
|
|
|
app.chats.ShowSecondaryText(false)
|
|
|
|
app.chats.SetSelectedFunc(func(idx int, mainText string, secondaryText string, shortcut rune) {
|
|
|
|
app.buffers.SwitchToPage(mainText)
|
|
|
|
app.SetFocus(app.input)
|
|
|
|
app.user = mainText
|
|
|
|
})
|
|
|
|
|
|
|
|
app.bufferMap[bufferStatus] = NewChatBox(app.palette, bufferStatus)
|
|
|
|
app.buffers = tview.NewPages().AddPage(bufferStatus, app.bufferMap[bufferStatus], true, true).SwitchToPage(bufferStatus)
|
|
|
|
app.chats.AddItem(bufferStatus, "", 0, nil)
|
|
|
|
|
|
|
|
// Layout the widgets in flex view.
|
|
|
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
|
|
AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
|
|
|
|
AddItem(app.buffers, 0, 1, false).
|
|
|
|
AddItem(app.chats, 40, 1, false),
|
|
|
|
0, 2, false).
|
|
|
|
AddItem(app.input, 3, 0, true)
|
|
|
|
|
|
|
|
app.layout = layout
|
|
|
|
|
|
|
|
app.views = tview.NewPages().AddPage("main", layout, true, true).SwitchToPage("main")
|
|
|
|
app.SetRoot(app.views, true)
|
|
|
|
app.SetFocus(app.input)
|
|
|
|
app.EnableMouse(true)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) setKeyBindings() {
|
|
|
|
app.layout.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
|
|
switch key := event.Key(); key {
|
|
|
|
case tcell.KeyEsc:
|
|
|
|
app.Stop()
|
|
|
|
case tcell.KeyCtrlN:
|
|
|
|
form := NewChatForm(
|
|
|
|
app.palette,
|
|
|
|
"Start new chat with",
|
|
|
|
func(address string) {
|
|
|
|
app.views.RemovePage("newchat")
|
|
|
|
app.views.SwitchToPage("main")
|
|
|
|
if err := app.ChatWith(address); err != nil {
|
|
|
|
dialog := NewMessageDialog(ErrorDialog)
|
|
|
|
dialog.SetTitle("Error")
|
|
|
|
dialog.SetMessage(fmt.Sprintf("error occurred adding new chat %s: %s", address, err))
|
|
|
|
dialog.SetDoneFunc(func() {
|
|
|
|
app.views.RemovePage("error")
|
|
|
|
app.views.SwitchToPage("main")
|
|
|
|
})
|
|
|
|
app.views.AddAndSwitchToPage("error", dialog, true)
|
|
|
|
}
|
|
|
|
}, func() {
|
|
|
|
app.views.RemovePage("newchat")
|
|
|
|
app.views.SwitchToPage("main")
|
|
|
|
},
|
|
|
|
)
|
|
|
|
app.views.AddPage("newchat", floatingModal(form, 40, 7), true, true).SwitchToPage("newchat")
|
|
|
|
}
|
|
|
|
return event
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) messageHandler() InputHandler {
|
|
|
|
return func(message string) {
|
|
|
|
app.out <- message
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) inputLoop(ctx context.Context) {
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case in := <-app.in:
|
|
|
|
s, err := lextwt.ParseSalty(in)
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
switch s := s.(type) {
|
|
|
|
|
|
|
|
case *lextwt.SaltyEvent:
|
|
|
|
// Ignored for now
|
|
|
|
|
|
|
|
case *lextwt.SaltyText:
|
|
|
|
buffer := app.getOrSetBuffer(s.User.String())
|
|
|
|
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
_, _, width, _ := buffer.GetInnerRect()
|
|
|
|
f := formatter.Formatter{
|
|
|
|
Writer: buf,
|
|
|
|
Indent: []byte("> "),
|
|
|
|
Width: width - 1,
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := f.Write([]byte(s.LiteralText())); err != nil {
|
|
|
|
log.WithError(err).Error("error formatting message")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
app.RLock()
|
|
|
|
app.QueueUpdateDraw(func() {
|
|
|
|
fmt.Fprintf(buffer,
|
|
|
|
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
|
|
|
|
app.palette["date"],
|
|
|
|
s.Timestamp.DateTime().Local().Format(saltyim.DateTimeFormat),
|
|
|
|
getUserColor(s.User.String()).Hex(),
|
|
|
|
s.User.String(),
|
|
|
|
app.palette["text"],
|
|
|
|
buf.String(),
|
|
|
|
)
|
|
|
|
})
|
|
|
|
app.RUnlock()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) getOrSetBuffer(name string) *tview.TextView {
|
|
|
|
buffer, ok := app.bufferMap[name]
|
|
|
|
if !ok {
|
|
|
|
buffer = NewChatBox(app.palette, name)
|
|
|
|
app.bufferMap[name] = buffer
|
|
|
|
app.chats.AddItem(name, "", 0, nil)
|
|
|
|
app.chats.SetCurrentItem(app.chats.GetItemCount())
|
|
|
|
app.buffers.AddPage(name, buffer, true, true)
|
|
|
|
app.user = name
|
|
|
|
}
|
|
|
|
return buffer
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) outputLoop(ctx context.Context) {
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case msg := <-app.out:
|
|
|
|
if strings.TrimSpace(msg) == "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := app.cli.Send(app.user, msg); err != nil {
|
|
|
|
// TODO: Handle errors more gracefully
|
|
|
|
log.WithError(err).Fatal("error sending message")
|
|
|
|
}
|
|
|
|
|
|
|
|
buffer := app.getOrSetBuffer(app.user)
|
|
|
|
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
_, _, width, _ := buffer.GetInnerRect()
|
|
|
|
f := formatter.Formatter{
|
|
|
|
Writer: buf,
|
|
|
|
Indent: []byte("> "),
|
|
|
|
Width: width - 1,
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := f.Write([]byte(msg)); err != nil {
|
|
|
|
log.WithError(err).Error("error formatting message")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
app.RLock()
|
|
|
|
app.QueueUpdateDraw(func() {
|
|
|
|
fmt.Fprintf(buffer,
|
|
|
|
"[#%s]%s [#%x]%s\n[#%s]%s\n\n",
|
|
|
|
app.palette["date"],
|
|
|
|
time.Now().Format(saltyim.DateTimeFormat),
|
|
|
|
getUserColor(app.cli.Me().String()).Hex(),
|
|
|
|
app.cli.Me().String(),
|
|
|
|
app.palette["text"],
|
|
|
|
buf.String(),
|
|
|
|
)
|
|
|
|
})
|
|
|
|
app.RUnlock()
|
|
|
|
|
|
|
|
//app.in <- string(saltyim.PackMessage(app.cli.Me(), msg))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (app *App) readLoop(ctx context.Context) {
|
2023-01-27 23:19:52 +00:00
|
|
|
ch := app.cli.Subscribe(ctx)
|
2023-01-11 22:58:07 +00:00
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case msg := <-ch:
|
|
|
|
app.in <- msg.Text
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ChatWith sets the current chat with a specified user
|
|
|
|
func (app *App) ChatWith(user string) error {
|
|
|
|
addr, err := saltyim.LookupAddr(user)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("error looking up addr %q: %w", user, err)
|
|
|
|
}
|
|
|
|
app.addr = addr
|
|
|
|
app.user = user
|
|
|
|
app.getOrSetBuffer(user)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Run runs the main chat loop
|
|
|
|
func (app *App) Run() error {
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
encoding.Register()
|
|
|
|
|
|
|
|
go app.readLoop(ctx)
|
|
|
|
go app.inputLoop(ctx)
|
|
|
|
go app.outputLoop(ctx)
|
|
|
|
|
|
|
|
return app.Application.Run()
|
|
|
|
}
|