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
James Mills 2 weeks ago
parent 801493a85b
commit 95663345d4
  1. 2
      .gitignore
  2. 20
      Makefile
  3. 40
      cmd/salty-chat/chat.go
  4. 1
      cmd/salty-chat/main.go
  5. 8
      cmd/salty-chat/root.go
  6. 3
      cmd/salty-chat/send.go
  7. 3
      identity.go
  8. 3
      identity_test.go
  9. 299
      internal/app/app.go
  10. 232
      internal/app/dialog.go
  11. 42
      internal/app/utils.go
  12. 32
      internal/app/views.go
  13. 173
      internal/tui/tui.go
  14. 10
      internal/tui/utils.go
  15. BIN
      internal/web/app.wasm
  16. 16
      log
  17. 6
      lookup.go
  18. 7
      types.go

2
.gitignore vendored

@ -3,11 +3,13 @@
*.bak
*.key
*.swp
*.log
**/.envrc
**/.DS_Store
**/coverage.out
/log
/msgs
/dist
/certs

@ -46,18 +46,34 @@ dev : certs pwa ## Build debug version of salty-chat (CLI and TUI) and saltyd (
--tls-cert ./certs/home.arpa/cert.pem
cli: ## Build the salty-chat command-line client and tui
ifeq ($(DEBUG), 1)
@echo "Building in debug mode..."
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \
-ldflags "-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/salty-chat/
else
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \
-ldflags "-w \
-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/salty-chat/
endif
server: generate pwa ## Build the saltyd server and broker (also includes the PWA)
ifeq ($(DEBUG), 1)
@echo "Building in debug mode..."
@$(GOCMD) build -tags "embed netgo static_build" -installsuffix netgo \
-ldflags "-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/saltyd/
else
@$(GOCMD) build -tags "embed netgo static_build" -installsuffix netgo \
-ldflags "-w \
-X $(shell go list).Version=$(VERSION) \
-X $(shell go list).Commit=$(COMMIT)" \
./cmd/saltyd/
endif
build: cli server ## Build the cli and the server
@ -93,10 +109,10 @@ fmt: ## Format sources fiels
@$(GOCMD) fmt ./...
test: ## Run test suite
@CGO_ENABLED=1 $(GOCMD) test -v -cover -race ./...
@CGO_ENABLED=1 $(GOCMD) test -v -race -cover -coverprofile=coverage.out ./...
coverage: ## Get test coverage report
@CGO_ENABLED=1 $(GOCMD) test -v -cover -race -cover -coverprofile=coverage.out ./...
@CGO_ENABLED=1 $(GOCMD) test -v -race -cover -coverprofile=coverage.out ./...
@$(GOCMD) tool cover -html=coverage.out
clean: ## Remove untracked files

@ -9,17 +9,17 @@ import (
"github.com/spf13/viper"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal/tui"
"go.mills.io/saltyim/internal/app"
)
var chatCmd = &cobra.Command{
Use: "chat <user>",
Use: "chat [user]",
Aliases: []string{"talk"},
Short: "Creates a chat with a specific user",
Long: `This command creates a chat with the specified user by discovering
and subscribing to your endpoint and prompts for input and sends encrypted
messages to the user via their discovered endpoint.`,
Args: cobra.MinimumNArgs(1),
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
identity := viper.GetString("identity")
@ -39,9 +39,14 @@ messages to the user via their discovered endpoint.`,
}
// XXX: What if me.IsZero()
chat(me, identity, state, args[0])
if len(args) == 1 {
chat(me, identity, state, args[0])
} else {
chat(me, identity, state, "")
}
},
}
var defaultCmd = chatCmd
func init() {
rootCmd.AddCommand(chatCmd)
@ -63,20 +68,29 @@ func chat(me *saltyim.Addr, identity, state, user string) {
}()
// Set terminal title
tui.SetTerminalTitle("Salty IM with %s", user)
// Initialize necessary channels.
inCh := make(chan string)
outCh := make(chan string)
if user != "" {
app.SetTerminalTitle("Salty IM with %s", user)
} else {
app.SetTerminalTitle("Salty IM")
}
// Initialize ui.
ui, err := tui.NewChatTUI(cli, user)
// Initialize the app
app, err := app.NewApp(cli)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating chat: %s\n", err)
os.Exit(2)
}
if user != "" {
if err := app.ChatWith(user); err != nil {
fmt.Fprintf(os.Stderr, "error creating chat with %q: %s\n", user, err)
os.Exit(2)
}
}
// Run the ui loop
go ui.RunChat(inCh, outCh)
ui.SetScreen(inCh, outCh)
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error running chat with: %s\n", err)
os.Exit(2)
}
}

@ -1,3 +1,4 @@
// Package main implements the `salty-chat` command-line (CLI) tool and a terminal user interface (TUI)
package main
func main() {

@ -10,6 +10,7 @@ import (
sync "github.com/sasha-s/go-deadlock"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.mills.io/saltyim"
@ -48,6 +49,13 @@ See https://salty.im for more details.`,
// and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cmd, _, err := rootCmd.Find(os.Args[1:])
// default cmd if no cmd is given
if err == nil && strings.Fields(cmd.Use)[0] == strings.Fields(rootCmd.Use)[0] && cmd.Flags().Parse(os.Args[1:]) != pflag.ErrHelp {
args := append([]string{strings.Fields(defaultCmd.Use)[0]}, os.Args[1:]...)
rootCmd.SetArgs(args)
}
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)

@ -3,7 +3,6 @@ package main
import (
"fmt"
"io"
"io/ioutil"
"os"
"strings"
@ -77,7 +76,7 @@ func send(me *saltyim.Addr, identity, user string, args ...string) {
var msg string
if len(args) == 0 {
data, err := ioutil.ReadAll(io.LimitReader(os.Stdin, maxMessageSize))
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxMessageSize))
if err != nil {
fmt.Fprintf(os.Stderr, "error reading message from stdin: %s\n", err)
os.Exit(2)

@ -5,7 +5,6 @@ import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
@ -102,7 +101,7 @@ func GetIdentity(options ...IdentityOption) (*Identity, error) {
return ident, fmt.Errorf("error opening identity %q: %w", ident.Source(), err)
}
defer id.Close()
identityBytes, err := ioutil.ReadAll(id)
identityBytes, err := io.ReadAll(id)
if err != nil {
return ident, fmt.Errorf("error opening identity %q: %w", ident.Source(), err)
}

@ -1,7 +1,6 @@
package saltyim
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
@ -19,7 +18,7 @@ func TestIdentity(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
dir, err := ioutil.TempDir("", "salty")
dir, err := os.MkdirTemp("", "salty")
require.NoError(err)
defer os.RemoveAll(dir)

@ -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,4 +1,4 @@
package tui
package app
import (
"regexp"
@ -45,8 +45,7 @@ func NewChatInput(palette map[string]string, title string, handler InputHandler)
chatInput := tview.NewInputField()
chatInput.SetTitle(title).
SetTitleColor(hexToTCell(palette["title"]))
chatInput.SetFieldBackgroundColor(tcell.ColorBlack).
SetFieldWidth(0).
chatInput.SetFieldWidth(0).
SetFieldTextColor(hexToTCell(palette["text"])).
SetFieldBackgroundColor(hexToTCell(palette["background"])).
SetDoneFunc(func(key tcell.Key) {
@ -64,3 +63,30 @@ func NewChatInput(palette map[string]string, title string, handler InputHandler)
return chatInput
}
// NewChatForm initializes and returns a 'chatForm' component
// that handles user input and creates a new chat with another user.
func NewChatForm(palette map[string]string, title string, acceptFn func(text string), cancelFn func()) *tview.Form {
var address string
form := tview.NewForm().
AddInputField("Address:", "", 0, nil, func(text string) { address = text }).
AddButton("Go", func() { acceptFn(address) }).
AddButton("Cancel", cancelFn)
form.SetFieldTextColor(hexToTCell(palette["text"])).
SetFieldBackgroundColor(hexToTCell(palette["background"]))
form.SetBorder(true).
SetTitleColor(hexToTCell(palette["title"])).
SetTitle("Start new chat with").
SetTitleAlign(tview.AlignCenter)
form.SetButtonBackgroundColor(hexToTCell(palette["background"]))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch key := event.Key(); key {
case tcell.KeyEsc:
cancelFn()
return nil
}
return event
})
return form
}

@ -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...))
}

BIN
internal/web/app.wasm (Stored with Git LFS)

Binary file not shown.

16
log

@ -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"

@ -4,7 +4,7 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"strings"
@ -25,7 +25,7 @@ type Lookuper interface {
}
func fetchConfig(addr string) (Config, Capabilities, error) {
// Attempt using hash
log.Debugf("Fetching Well-Known Config: GET %s", addr)
res, err := Request(http.MethodGet, addr, nil, nil)
if err != nil {
return Config{}, Capabilities{}, err
@ -283,7 +283,7 @@ func (l *ProxyLookup) LookupAddr(user string) (*Addr, error) {
}
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}

@ -3,7 +3,6 @@ package saltyim
import (
"encoding/json"
"io"
"io/ioutil"
"go.mills.io/salty"
)
@ -18,7 +17,7 @@ type RegisterRequest struct {
// and returns the resulting `RegisterRequest` and key used to sign the request on success
// otherwise an empty object and en error on failure.
func NewRegisterRequest(r io.Reader) (req RegisterRequest, signer string, err error) {
body, err := ioutil.ReadAll(r)
body, err := io.ReadAll(r)
if err != nil {
return
}
@ -42,7 +41,7 @@ type SendRequest struct {
// and returns the resulting `SendRequest` and key used to sign the request on success
// otherwise an empty object and en error on failure.
func NewSendRequest(r io.Reader) (req SendRequest, signer string, err error) {
body, err := ioutil.ReadAll(r)
body, err := io.ReadAll(r)
if err != nil {
return
}
@ -65,7 +64,7 @@ type AvatarRequest struct {
// and returns the resulting `AvatarRequest` and key used to sign the request on success
// otherwise an empty object and en error on failure.
func NewAvatarRequest(r io.Reader) (req AvatarRequest, signer string, err error) {
body, err := ioutil.ReadAll(r)
body, err := io.ReadAll(r)
if err != nil {
return
}

Loading…
Cancel
Save