Adds support for multiple chats (#174)
Closes #146 See attached screenshots. Co-authored-by: James Mills <prologic@shortcircuit.net.au> Co-authored-by: James Mills <1290234+prologic@users.noreply.github.com> Reviewed-on: #174 Reviewed-by: xuu <xuu@noreply@mills.io>pull/177/head
parent
801493a85b
commit
95663345d4
@ -0,0 +1,299 @@ |
||||
// 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" |
||||
|
||||
"go.mills.io/saltyim" |
||||
) |
||||
|
||||
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 |
||||
|
||||
cli *saltyim.Client |
||||
user string |
||||
addr *saltyim.Addr |
||||
|
||||
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) { |
||||
ch := app.cli.Subscribe(ctx, "", "", "") |
||||
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() |
||||
} |
@ -0,0 +1,232 @@ |
||||
package app |
||||
|
||||
import ( |
||||
"strings" |
||||
|
||||
"github.com/gdamore/tcell/v2" |
||||
"github.com/rivo/tview" |
||||
) |
||||
|
||||
const ( |
||||
dialogPadding = 2 |
||||
dialogFormHeight = 3 |
||||
emptySpaceParts = 2 |
||||
|
||||
// InfoDialog is an information dialog type
|
||||
InfoDialog = 0 + iota |
||||
|
||||
// ErrorDialog is an error dialog type
|
||||
ErrorDialog |
||||
) |
||||
|
||||
// MessageDialog represents message dialog primitive.
|
||||
type MessageDialog struct { |
||||
*tview.Box |
||||
// layout message dialog layout
|
||||
layout *tview.Flex |
||||
// message view
|
||||
textview *tview.TextView |
||||
// dialog form buttons
|
||||
form *tview.Form |
||||
// message dialog X
|
||||
x int |
||||
// message dialog Y
|
||||
y int |
||||
// message dialog width
|
||||
width int |
||||
// message dialog heights
|
||||
height int |
||||
// dialog type info and error
|
||||
// type will change the default background color for the dialog
|
||||
messageType int |
||||
// background color
|
||||
bgColor tcell.Color |
||||
// message dialog text message to display.
|
||||
message string |
||||
// callback for whwen user clicked on the the button or presses "enter" or "esc"
|
||||
doneHandler func() |
||||
} |
||||
|
||||
// NewMessageDialog returns a new message dialog primitive.
|
||||
func NewMessageDialog(dtype int) *MessageDialog { |
||||
dialog := &MessageDialog{ |
||||
Box: tview.NewBox(), |
||||
messageType: dtype, |
||||
bgColor: tcell.ColorSteelBlue, |
||||
} |
||||
|
||||
dialog.textview = tview.NewTextView(). |
||||
SetDynamicColors(true). |
||||
SetWrap(true). |
||||
SetTextAlign(tview.AlignLeft) |
||||
|
||||
dialog.form = tview.NewForm(). |
||||
AddButton("OK", nil). |
||||
SetButtonsAlign(tview.AlignCenter) |
||||
|
||||
dialog.layout = tview.NewFlex().SetDirection(tview.FlexRow) |
||||
dialog.layout.AddItem(dialog.textview, 0, 0, true) |
||||
dialog.layout.AddItem(dialog.form, dialogFormHeight, 0, true) |
||||
dialog.layout.SetBorder(true) |
||||
|
||||
dialog.setColor() |
||||
|
||||
return dialog |
||||
} |
||||
|
||||
// SetBorder sets dialogs border - no effect always true.
|
||||
func (d *MessageDialog) SetBorder(status bool) {} |
||||
|
||||
// SetType sets dialog type to info or error.
|
||||
func (d *MessageDialog) SetType(dtype int) { |
||||
if dtype >= 0 && dtype <= 2 { |
||||
d.messageType = dtype |
||||
d.setColor() |
||||
} |
||||
} |
||||
|
||||
// SetTitle sets title for this primitive.
|
||||
func (d *MessageDialog) SetTitle(title string) { |
||||
d.layout.SetTitle(title) |
||||
} |
||||
|
||||
// SetBackgroundColor sets dialog background color.
|
||||
func (d *MessageDialog) SetBackgroundColor(color tcell.Color) { |
||||
d.bgColor = color |
||||
d.setColor() |
||||
} |
||||
|
||||
// SetMessage sets the dialog message to display.
|
||||
func (d *MessageDialog) SetMessage(message string) { |
||||
d.message = "\n" + message |
||||
d.textview.Clear() |
||||
d.textview.SetText(d.message) |
||||
d.textview.ScrollToBeginning() |
||||
d.setRect() |
||||
} |
||||
|
||||
// Focus is called when this primitive receives focus.
|
||||
func (d *MessageDialog) Focus(delegate func(p tview.Primitive)) { |
||||
delegate(d.form) |
||||
} |
||||
|
||||
// HasFocus returns whether or not this primitive has focus.
|
||||
func (d *MessageDialog) HasFocus() bool { |
||||
return d.form.HasFocus() |
||||
} |
||||
|
||||
// SetRect sets rect for this primitive.
|
||||
func (d *MessageDialog) SetRect(x, y, width, height int) { |
||||
d.x = x |
||||
d.y = y |
||||
d.width = width |
||||
d.height = height |
||||
d.setRect() |
||||
} |
||||
|
||||
// SetTextColor sets dialog's message text color.
|
||||
func (d *MessageDialog) SetTextColor(color tcell.Color) { |
||||
d.textview.SetTextColor(color) |
||||
} |
||||
|
||||
// Draw draws this primitive onto the screen.
|
||||
func (d *MessageDialog) Draw(screen tcell.Screen) { |
||||
d.Box.DrawForSubclass(screen, d) |
||||
x, y, width, height := d.Box.GetInnerRect() |
||||
d.layout.SetRect(x, y, width, height) |
||||
d.layout.Draw(screen) |
||||
} |
||||
|
||||
// InputHandler returns input handler function for this primitive.
|
||||
func (d *MessageDialog) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { |
||||
return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { |
||||
if event.Key() == tcell.KeyDown || event.Key() == tcell.KeyUp || event.Key() == tcell.KeyPgDn || event.Key() == tcell.KeyPgUp { // nolint:lll
|
||||
if textHandler := d.textview.InputHandler(); textHandler != nil { |
||||
textHandler(event, setFocus) |
||||
|
||||
return |
||||
} |
||||
} |
||||
if formHandler := d.form.InputHandler(); formHandler != nil { |
||||
formHandler(event, setFocus) |
||||
|
||||
return |
||||
} |
||||
}) |
||||
} |
||||
|
||||
// MouseHandler returns the mouse handler for this primitive.
|
||||
func (d *MessageDialog) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { // nolint:lll
|
||||
return d.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) { // nolint:lll,nonamedreturns
|
||||
// Pass mouse events on to the form.
|
||||
consumed, capture = d.form.MouseHandler()(action, event, setFocus) |
||||
if !consumed && action == tview.MouseLeftClick && d.InRect(event.Position()) { |
||||
setFocus(d) |
||||
consumed = true |
||||
} |
||||
|
||||
return consumed, capture |
||||
}) |
||||
} |
||||
|
||||
// SetDoneFunc sets callback function for when user clicked on
|
||||
// the the button or presses "enter" or "esc".
|
||||
func (d *MessageDialog) SetDoneFunc(handler func()) *MessageDialog { |
||||
d.doneHandler = handler |
||||
enterButton := d.form.GetButton(d.form.GetButtonCount() - 1) |
||||
enterButton.SetSelectedFunc(handler) |
||||
|
||||
return d |
||||
} |
||||
|
||||
func (d *MessageDialog) setColor() { |
||||
var bgColor tcell.Color |
||||
|
||||
switch d.messageType { |
||||
case InfoDialog: |
||||
bgColor = d.bgColor |
||||
case ErrorDialog: |
||||
bgColor = tcell.ColorOrangeRed |
||||
} |
||||
|
||||
d.form.SetBackgroundColor(bgColor) |
||||
d.textview.SetBackgroundColor(bgColor) |
||||
d.layout.SetBackgroundColor(bgColor) |
||||
} |
||||
|
||||
func (d *MessageDialog) setRect() { |
||||
maxHeight := d.height |
||||
maxWidth := d.width // nolint:ifshort
|
||||
messageHeight := len(strings.Split(d.message, "\n")) |
||||
messageWidth := getMessageWidth(d.message) |
||||
|
||||
layoutHeight := messageHeight |
||||
|
||||
if maxHeight > layoutHeight+dialogFormHeight { |
||||
d.height = layoutHeight + dialogFormHeight + dialogPadding |
||||
} else { |
||||
d.height = maxHeight |
||||
layoutHeight = d.height - dialogFormHeight - dialogPadding |
||||
} |
||||
|
||||
if maxHeight > d.height { |
||||
emptyHeight := (maxHeight - d.height) / emptySpaceParts |
||||
d.y += emptyHeight |
||||
} |
||||
|
||||
if d.width > messageWidth { |
||||
d.width = messageWidth + dialogPadding |
||||
} |
||||
|
||||
if maxWidth > d.width { |
||||
emptyWidth := (maxWidth - d.width) / emptySpaceParts |
||||
d.x += emptyWidth |
||||
} |
||||
|
||||
d.layout.Clear() |
||||
|
||||
d.layout.AddItem(d.textview, layoutHeight, 0, true) |
||||
d.layout.AddItem(d.form, dialogFormHeight, 0, true) |
||||
|
||||
d.Box.SetRect(d.x, d.y, d.width, d.height) |
||||
} |
@ -0,0 +1,42 @@ |
||||
package app |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"github.com/gdamore/tcell/v2" |
||||
"github.com/rivo/tview" |
||||
colorhash "github.com/taigrr/go-colorhash" |
||||
) |
||||
|
||||
func getUserColor(s string) tcell.Color { |
||||
c := colorhash.HashString(s) |
||||
return tcell.NewHexColor(int32(c)) |
||||
} |
||||
|
||||
func floatingModal(p tview.Primitive, width, height int) tview.Primitive { |
||||
return tview.NewFlex(). |
||||
AddItem(nil, 0, 1, false). |
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow). |
||||
AddItem(nil, 0, 1, false). |
||||
AddItem(p, height, 1, true). |
||||
AddItem(nil, 0, 1, false), width, 1, true). |
||||
AddItem(nil, 0, 1, false) |
||||
} |
||||
|
||||
// getMessageWidth returns width size for dialogs based on messages.
|
||||
func getMessageWidth(message string) int { |
||||
var messageWidth int |
||||
for _, msg := range strings.Split(message, "\n") { |
||||
if len(msg) > messageWidth { |
||||
messageWidth = len(msg) |
||||
} |
||||
} |
||||
|
||||
return messageWidth |
||||
} |
||||
|
||||
// SetTerminalTitle sets the Terminal/Console's title
|
||||
func SetTerminalTitle(format string, args ...interface{}) { |
||||
fmt.Printf("\033]0;%s\007", fmt.Sprintf(format, args...)) |
||||
} |
@ -1,173 +0,0 @@ |
||||
package tui |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"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" |
||||
colorhash "github.com/taigrr/go-colorhash" |
||||
"go.yarn.social/lextwt" |
||||
|
||||
"go.mills.io/saltyim" |
||||
) |
||||
|
||||
func getUserColor(s string) tcell.Color { |
||||
c := colorhash.HashString(s) |
||||
return tcell.NewHexColor(int32(c)) |
||||
} |
||||
|
||||
type ChatTUI struct { |
||||
mu sync.RWMutex |
||||
|
||||
cli *saltyim.Client |
||||
user string |
||||
addr *saltyim.Addr |
||||
|
||||
// Configurations.
|
||||
palette map[string]string |
||||
} |
||||
|
||||
// NewChatTUI initializes a new chatApp.
|
||||
// Sets up connection with broker, and initializes UI.
|
||||
func NewChatTUI(cli *saltyim.Client, user string) (*ChatTUI, error) { |
||||
addr, err := saltyim.LookupAddr(user) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error looking up user %s: %s", user, err) |
||||
} |
||||
|
||||
return &ChatTUI{ |
||||
cli: cli, |
||||
user: user, |
||||
addr: addr, |
||||
|
||||
palette: map[string]string{ |
||||
"background": "000000", |
||||
"border": "4C5C68", |
||||
"title": "46494C", |
||||
"date": "E76F51", |
||||
"text": "577399", |
||||
}, |
||||
}, nil |
||||
} |
||||
|
||||
// newMessageHandler returns an InputHandler that handles outgoing messages.
|
||||
func (c *ChatTUI) newMessageHandler(outCh chan<- string) InputHandler { |
||||
handler := func(message string) { |
||||
c.mu.RLock() |
||||
outCh <- message |
||||
c.mu.RUnlock() |
||||
} |
||||
|
||||
return handler |
||||
} |
||||
|
||||
// updateChatBox updates chatBox component with incoming messages.
|
||||
func (c *ChatTUI) updateChatBox(inCh <-chan string, app *tview.Application, |
||||
chatBox *tview.TextView) { |
||||
|
||||
for in := range inCh { |
||||
s, err := lextwt.ParseSalty(in) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
|
||||
switch s := s.(type) { |
||||
|
||||
case *lextwt.SaltyEvent: |
||||
// Ignored for now
|
||||
|
||||
case *lextwt.SaltyText: |
||||
if s.User.String() != c.cli.Me().String() && s.User.String() != c.user { |
||||
continue |
||||
} |
||||
|
||||
buf := &bytes.Buffer{} |
||||
_, _, width, _ := chatBox.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 |
||||
} |
||||
|
||||
c.mu.RLock() |
||||
app.QueueUpdateDraw(func() { |
||||
fmt.Fprintf(chatBox, |
||||
"[#%s]%s [#%x]%s\n[#%s]%s\n\n", |
||||
c.palette["date"], |
||||
s.Timestamp.DateTime().Local().Format(saltyim.DateTimeFormat), |
||||
getUserColor(s.User.String()).Hex(), |
||||
s.User.String(), |
||||
c.palette["text"], |
||||
buf.String(), |
||||
) |
||||
}) |
||||
c.mu.RUnlock() |
||||
} |
||||
} |
||||
} |
||||
|
||||
// setScreen initializes the layout and UI components.
|
||||
func (c *ChatTUI) SetScreen(inCh <-chan string, outCh chan<- string) { |
||||
encoding.Register() |
||||
|
||||
app := tview.NewApplication() |
||||
|
||||
chatTitle := fmt.Sprintf("Chatting to %s via %s", c.user, c.addr.Endpoint().String()) |
||||
inputTitle := fmt.Sprintf("Connected to %s as %s", c.cli.Me().Endpoint(), c.cli.Me()) |
||||
|
||||
// Generate UI components.
|
||||
c.mu.RLock() |
||||
chatBox := NewChatBox(c.palette, chatTitle) |
||||
inputField := NewChatInput(c.palette, inputTitle, c.newMessageHandler(outCh)) |
||||
c.mu.RUnlock() |
||||
|
||||
// Layout the widgets in flex view.
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow). |
||||
AddItem(chatBox, 0, 1, false). |
||||
AddItem(inputField, 3, 0, true) |
||||
|
||||
go c.updateChatBox(inCh, app, chatBox) |
||||
|
||||
err := app.SetRoot(flex, true).SetFocus(inputField).EnableMouse(true).Run() |
||||
if err != nil { |
||||
log.Fatal(err) |
||||
} |
||||
} |
||||
|
||||
// Open bi-directional stream between client and server.
|
||||
func (c *ChatTUI) RunChat(inCh chan<- string, outCh <-chan string) { |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Receives incoming messages on a separate goroutine to be non-blocking.
|
||||
go func() { |
||||
for msg := range c.cli.Subscribe(ctx, "", "", "") { |
||||
inCh <- msg.Text |
||||
} |
||||
}() |
||||
|
||||
// Forward/send outgoing messages.
|
||||
for msg := range outCh { |
||||
if strings.TrimSpace(msg) == "" { |
||||
continue |
||||
} |
||||
|
||||
if err := c.cli.Send(c.user, msg); err != nil { |
||||
log.WithError(err).Fatal("error sending message") |
||||
} |
||||
|
||||
inCh <- string(saltyim.PackMessage(c.cli.Me(), msg)) |
||||
} |
||||
} |
@ -1,10 +0,0 @@ |
||||
package tui |
||||
|
||||
import ( |
||||
"fmt" |
||||
) |
||||
|
||||
// SetTerminalTitle sets the Terminal/Console's title
|
||||
func SetTerminalTitle(format string, args ...interface{}) { |
||||
fmt.Printf("\033]0;%s\007", fmt.Sprintf(format, args...)) |
||||
} |
Binary file not shown.
@ -1,16 +0,0 @@ |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="me: &saltyim.Addr{User:\"\", Domain:\"\", key:(*keys.EdX25519PublicKey)(nil), endpoint:(*url.URL)(nil), discoveredDomain:\"\", avatar:\"\", capabilities:saltyim.Capabilities{AcceptEncoding:\"\"}, checkedAvatar:false}" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Looking up SRV record for _salty._tcp.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Using StandardResolver, looking up SRV _salty._tcp.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered salty services salty.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="map[Accept-Encoding:[br, gzip, deflate] Accept-Ranges:[bytes] Access-Control-Allow-Headers:[*] Access-Control-Allow-Origin:[*] Access-Control-Expose-Headers:[*] Content-Length:[142] Content-Type:[application/json] Date:[Mon, 04 Apr 2022 11:34:00 GMT] Last-Modified:[Mon, 04 Apr 2022 10:33:30 GMT] Set-Cookie:[csrf_token=f+OFHBvwJohmepf54/V3bPPhodKF7nAjC/hVszFmrCw=; Max-Age=31536000] Vary:[Accept-Encoding Cookie] X-Salty-Accept-Encoding:[br, gzip, deflate]]" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered endpoint: https://salty.home.arpa/inbox/01FZT261JV2N2N8C5WCGA0GTZQ" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered capability: accept-encoding: br, gzip, deflate" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Using identity foo.key with public key kex1py4x3mew0qyu2m47avjjxkuqx2a5d0k8dgsrfr6vxrf6cz7u3g0srawylg" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Salty Addr is @" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Endpoint is <nil>" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Looking up SRV record for _salty._tcp.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Using StandardResolver, looking up SRV _salty._tcp.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered salty services salty.home.arpa" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="map[Accept-Encoding:[br, gzip, deflate] Accept-Ranges:[bytes] Access-Control-Allow-Headers:[*] Access-Control-Allow-Origin:[*] Access-Control-Expose-Headers:[*] Content-Length:[142] Content-Type:[application/json] Date:[Mon, 04 Apr 2022 11:34:00 GMT] Last-Modified:[Mon, 04 Apr 2022 11:02:43 GMT] Set-Cookie:[csrf_token=+elnJfkI7HVkUIMSaMflOkSs6LJQwyb/luoOVrFogSk=; Max-Age=31536000] Vary:[Accept-Encoding Cookie] X-Salty-Accept-Encoding:[br, gzip, deflate]]" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered endpoint: https://salty.home.arpa/inbox/01FZT3VH6538Q611ND6DH8RSQC" |
||||
time="2022-04-04T21:34:00+10:00" level=debug msg="Discovered capability: accept-encoding: br, gzip, deflate" |
Loading…
Reference in new issue