package saltyim import ( "bytes" "context" "fmt" "strings" "sync" "github.com/dim13/crc24" "github.com/gdamore/tcell/v2" "github.com/keys-pub/keys" "github.com/posener/formatter" "github.com/rivo/tview" log "github.com/sirupsen/logrus" "go.mills.io/salty" "go.yarn.social/lextwt" ) const ( dateTimeFormat = "2006-01-02 15:04:05" ) func getUserColor(s string) tcell.Color { c := crc24.Sum([]byte(s)) return tcell.NewRGBColor( int32(c>>0x10), int32(c>>0x08), int32(c), ) } type chatClient struct { mu sync.RWMutex key *keys.EdX25519Key uri string me Addr inbox string user string config Config // Configurations. palette map[string]string } // NewChatClient initializes a new chatClient. // Sets up connection with broker, and initializes UI. func NewChatClient(key *keys.EdX25519Key, uri string, me Addr, inbox, user string) (*chatClient, error) { config, err := Lookup(user) if err != nil { return nil, fmt.Errorf("error: failed to lookup user %q: %w", user, err) } return &chatClient{ key: key, uri: uri, me: me, inbox: inbox, user: user, config: config, palette: map[string]string{ "background": "000000", "border": "4C5C68", "title": "46494C", "date": "E76F51", "text": "577399", }, }, nil } // newMessageHandler returns an InputHandler that handles outgoing messages. func (cc *chatClient) newMessageHandler(outCh chan<- string) InputHandler { handler := func(message string) { cc.mu.RLock() outCh <- message cc.mu.RUnlock() } return handler } // updateChatBox updates chatBox component with incoming messages. func (cc *chatClient) 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() != cc.me.String() && s.User.String() != cc.user { continue } buf := &bytes.Buffer{} f := formatter.Formatter{ Writer: buf, Indent: []byte("> "), Width: 80, } if _, err := f.Write([]byte(s.LiteralText())); err != nil { log.WithError(err).Error("error formatting message") continue } cc.mu.RLock() app.QueueUpdateDraw(func() { fmt.Fprintf(chatBox, "[#%s]%s [#%x]%s\n[#%s]%s\n\n", cc.palette["date"], s.Timestamp.DateTime().Local().Format(dateTimeFormat), getUserColor(s.User.String()).Hex(), s.User.String(), cc.palette["text"], buf.String(), ) }) cc.mu.RUnlock() } } } // setScreen initializes the layout and UI components. func (cc *chatClient) SetScreen(inCh <-chan string, outCh chan<- string) { app := tview.NewApplication() title := fmt.Sprintf( "Chatting as %s via %s/%s with %s via %s", cc.me, cc.uri, cc.inbox, cc.user, cc.config.Endpoint, ) // Generate UI components. cc.mu.RLock() chatBox := NewChatBox(cc.palette, title) inputField := NewChatInput(cc.palette, cc.newMessageHandler(outCh)) cc.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 cc.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 (cc *chatClient) 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 Read(ctx, cc.key, cc.uri, cc.inbox, "", "") { inCh <- msg } }() // Forward/send outgoing messages. for msg := range outCh { if strings.TrimSpace(msg) == "" { continue } packedMsg := PackMessage(cc.me, msg) b, err := salty.Encrypt(cc.key, packedMsg, []string{cc.config.Key}) if err != nil { log.Fatalf("error encrypting message: %v", err) } err = Send(cc.config.Endpoint, string(b)) if err != nil { log.Fatalf("error sending message: %v", err) } inCh <- string(packedMsg) } }