refactor(ui): decouple commands from models

This commit is contained in:
ayn2op 2026-08-10 16:46:21 -04:00
parent f4621bc321
commit 1197e4b204
7 changed files with 71 additions and 69 deletions

View file

@ -171,7 +171,7 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd {
case tview.KeyMsg:
switch {
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Paste.Keybind):
return tview.Sequence(c.pasteImage(), c.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)))
return tview.Sequence(pasteImage(), c.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)))
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Newline.Keybind):
return c.forwardToTextArea(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
case keybind.Matches(msg, c.cfg.Keybinds.Composer.Send.Keybind):
@ -220,7 +220,7 @@ func (c *composer) Update(msg tview.Msg) tview.Cmd {
type imagePastedMsg []byte
func (c *composer) pasteImage() tview.Cmd {
func pasteImage() tview.Cmd {
return func() tview.Msg {
data := clipboard.Read(clipboard.FmtImage)
return imagePastedMsg(data)

View file

@ -1069,14 +1069,14 @@ func (ml *messagesList) open() tview.Cmd {
case total > 1:
return ml.showAttachmentsList(urls, selectedMessage.Attachments)
case len(urls) == 1:
return ml.openURL(urls[0])
return openURL(urls[0])
}
attachment := selectedMessage.Attachments[0]
if strings.HasPrefix(attachment.ContentType, "image/") {
return ml.openAttachment(attachment)
return openAttachment(attachment)
}
return ml.openURL(attachment.URL)
return openURL(attachment.URL)
}
func extractURLs(content string) []string {
@ -1148,9 +1148,9 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
var items []attachmentspicker.Item
for _, a := range attachments {
attachment := a
open := ml.openURL(attachment.URL)
open := openURL(attachment.URL)
if strings.HasPrefix(attachment.ContentType, "image/") {
open = ml.openAttachment(attachment)
open = openAttachment(attachment)
}
items = append(items, attachmentspicker.Item{
Label: attachment.Filename,
@ -1161,7 +1161,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
url := u
items = append(items, attachmentspicker.Item{
Label: url,
Open: ml.openURL(url),
Open: openURL(url),
})
}
ml.attachmentsPicker.SetItems(items)
@ -1178,7 +1178,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
return tview.SetFocus(ml.attachmentsPicker)
}
func (ml *messagesList) openAttachment(attachment discord.Attachment) tview.Cmd {
func openAttachment(attachment discord.Attachment) tview.Cmd {
return func() tview.Msg {
resp, err := http.Get(attachment.URL)
if err != nil {
@ -1220,7 +1220,7 @@ func (ml *messagesList) openAttachment(attachment discord.Attachment) tview.Cmd
}
}
func (ml *messagesList) openURL(url string) tview.Cmd {
func openURL(url string) tview.Cmd {
return func() tview.Msg {
if err := open.Start(url); err != nil {
slog.Error("failed to open URL", "err", err, "url", url)

View file

@ -272,17 +272,17 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.focused = msg.Model
return nil
case tview.InitMsg:
return tview.Batch(m.openState(), m.listen())
return tview.Batch(openState(m.state), listen(m.events))
case gateway.Event:
switch eventMsg := msg.(type) {
case *ws.RawEvent:
m.onRaw(eventMsg)
case *gateway.ReadyEvent:
return tview.Batch(m.onReady(eventMsg), m.listen())
return tview.Batch(m.onReady(eventMsg), listen(m.events))
case *gateway.MessageCreateEvent:
return tview.Batch(m.onMessageCreate(eventMsg), m.listen())
return tview.Batch(m.onMessageCreate(eventMsg), listen(m.events))
case *gateway.MessageUpdateEvent:
m.onMessageUpdate(eventMsg)
case *gateway.PresenceUpdateEvent:
@ -299,7 +299,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.onMessageReaction(eventMsg.ChannelID, eventMsg.MessageID)
case *gateway.GuildMembersChunkEvent:
return tview.Batch(m.onGuildMembersChunk(eventMsg), m.listen())
return tview.Batch(m.onGuildMembersChunk(eventMsg), listen(m.events))
case *gateway.GuildMemberRemoveEvent:
m.onGuildMemberRemove(eventMsg)
@ -311,7 +311,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case *read.UpdateEvent:
m.onReadUpdate(eventMsg)
}
return m.listen()
return listen(m.events)
case channelLoadedMsg:
node := m.guildsTree.CurrentNode()
if node == nil {
@ -359,7 +359,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case attachmentspicker.CancelMsg:
return m.closeAttachmentsPicker()
case QuitMsg:
return m.closeState()
return closeState(m.state)
case tview.KeyMsg:
switch {
case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind):
@ -382,7 +382,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return m.togglePicker()
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
return tview.Sequence(m.closeState(), m.logout())
return tview.Sequence(closeState(m.state), logout())
}
case tabSuggestMsg:
return m.composer.Update(msg)

View file

@ -5,12 +5,14 @@ import (
"log/slog"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
)
func (m *Model) openState() tview.Cmd {
func openState(state *ningen.State) tview.Cmd {
return func() tview.Msg {
if err := m.state.Open(context.Background()); err != nil {
if err := state.Open(context.Background()); err != nil {
slog.Error("failed to open chat state", "err", err)
return nil
}
@ -18,21 +20,21 @@ func (m *Model) openState() tview.Cmd {
}
}
func (m *Model) closeState() tview.Cmd {
if m.state == nil {
func closeState(state *ningen.State) tview.Cmd {
if state == nil {
return nil
}
return func() tview.Msg {
if err := m.state.Close(); err != nil {
if err := state.Close(); err != nil {
slog.Error("failed to close the session", "err", err)
}
return nil
}
}
func (m *Model) listen() tview.Cmd {
func listen(events <-chan gateway.Event) tview.Cmd {
return func() tview.Msg {
return <-m.events
return <-events
}
}
@ -50,7 +52,7 @@ type deleteMessageMsg discord.Message
type LogoutMsg struct{}
func (m *Model) logout() tview.Cmd {
func logout() tview.Cmd {
return func() tview.Msg {
return LogoutMsg{}
}

View file

@ -39,16 +39,16 @@ func NewModel(cfg *config.Config) *Model {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case errMsg:
return m.showErrorDialog(msg)
return showErrorDialog(msg)
case password.ErrMsg:
return m.showErrorDialog(msg)
return showErrorDialog(msg)
case copyErrorMsg:
return setClipboard(string(msg))
}
return m.Layers.Update(msg)
}
func (m *Model) showErrorDialog(err error) tview.Cmd {
func showErrorDialog(err error) tview.Cmd {
slog.Error("failed to login", "err", err)
message := err.Error()
return ui.ShowModal(message,

View file

@ -47,62 +47,62 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case tview.InitMsg:
m.setStatus("Connecting to Remote Auth Gateway...")
return m.connect()
return connect()
case tview.KeyMsg:
if msg.Key() == tcell.KeyEsc {
m.setStatus("Canceled")
return m.close()
return closeConn(m.conn)
}
return m.TextView.Update(msg)
case connCreateMsg:
m.conn = msg.conn
m.setStatus("Connected. Handshaking...")
return m.listen()
return listen(m.conn)
case connCloseMsg:
m.conn = nil
return nil
case helloMsg:
m.heartbeatInterval = time.Duration(msg.heartbeatInterval) * time.Millisecond
return tview.Batch(m.listen(), m.heartbeat(), m.generatePrivateKey())
return tview.Batch(listen(m.conn), heartbeat(m.heartbeatInterval), generatePrivateKey())
case privateKeyMsg:
m.privateKey = msg.privateKey
return tview.Batch(m.listen(), m.sendInit())
return tview.Batch(listen(m.conn), sendInit(m.conn, m.privateKey))
case nonceProofMsg:
return tview.Batch(m.listen(), m.sendNonceProof(msg.encryptedNonce))
return tview.Batch(listen(m.conn), sendNonceProof(m.conn, m.privateKey, msg.encryptedNonce))
case pendingRemoteInitMsg:
m.fingerprint = msg.fingerprint
return tview.Batch(m.listen(), m.generateQRCode(msg.fingerprint))
return tview.Batch(listen(m.conn), generateQRCode(msg.fingerprint))
case qrCodeMsg:
m.qrCode = msg.qrCode
m.setStatus("Scan this with the Discord mobile app to log in instantly.")
return m.listen()
return listen(m.conn)
case pendingTicketMsg:
return tview.Batch(m.listen(), m.decryptUserPayload(msg.encryptedUserPayload))
return tview.Batch(listen(m.conn), decryptUserPayload(m.privateKey, msg.encryptedUserPayload))
case userMsg:
name := msg.username
if msg.discriminator != "0" {
name += "#" + msg.discriminator
}
m.setStatus("Check your phone! Logging in as " + name)
return m.listen()
return listen(m.conn)
case pendingLoginMsg:
m.setStatus("Authenticating...")
return tview.Batch(m.close(), m.exchangeTicket(msg.ticket))
return tview.Batch(closeConn(m.conn), exchangeTicket(m.fingerprint, m.privateKey, msg.ticket))
case cancelMsg:
m.setStatus("Login canceled on mobile")
return m.close()
return closeConn(m.conn)
case heartbeatTickMsg:
if m.conn == nil {
return nil
}
return tview.Batch(m.heartbeat(), m.sendHeartbeat())
return tview.Batch(heartbeat(m.heartbeatInterval), sendHeartbeat(m.conn))
case errMsg:
m.setStatus(msg.Error())
return m.close()
return closeConn(m.conn)
}
return nil

View file

@ -32,7 +32,7 @@ type connCreateMsg struct {
type connCloseMsg struct{}
func (m *Model) connect() tview.Cmd {
func connect() tview.Cmd {
return func() tview.Msg {
headers := http.Headers()
headers.Set("User-Agent", http.BrowserUserAgent())
@ -45,10 +45,10 @@ func (m *Model) connect() tview.Cmd {
}
}
func (m *Model) close() tview.Cmd {
func closeConn(conn *websocket.Conn) tview.Cmd {
return func() tview.Msg {
if m.conn != nil {
if err := m.conn.Close(); err != nil {
if conn != nil {
if err := conn.Close(); err != nil {
return errMsg(err)
}
}
@ -79,13 +79,13 @@ type pendingLoginMsg struct {
type cancelMsg struct{}
func (m *Model) listen() tview.Cmd {
func listen(conn *websocket.Conn) tview.Cmd {
return func() tview.Msg {
if m.conn == nil {
if conn == nil {
return nil
}
_, data, err := m.conn.ReadMessage()
_, data, err := conn.ReadMessage()
if err != nil {
return errMsg(err)
}
@ -149,22 +149,22 @@ func (m *Model) listen() tview.Cmd {
type heartbeatTickMsg struct{}
func (m *Model) heartbeat() tview.Cmd {
func heartbeat(interval time.Duration) tview.Cmd {
return func() tview.Msg {
time.Sleep(m.heartbeatInterval)
time.Sleep(interval)
return heartbeatTickMsg{}
}
}
func (m *Model) sendHeartbeat() tview.Cmd {
func sendHeartbeat(conn *websocket.Conn) tview.Cmd {
return func() tview.Msg {
if m.conn == nil {
if conn == nil {
return nil
}
data := struct {
Op string `json:"op"`
}{"heartbeat"}
if err := m.conn.WriteJSON(data); err != nil {
if err := conn.WriteJSON(data); err != nil {
return errMsg(err)
}
return nil
@ -175,7 +175,7 @@ type privateKeyMsg struct {
privateKey *rsa.PrivateKey
}
func (m *Model) generatePrivateKey() tview.Cmd {
func generatePrivateKey() tview.Cmd {
return func() tview.Msg {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@ -185,12 +185,12 @@ func (m *Model) generatePrivateKey() tview.Cmd {
}
}
func (m *Model) sendInit() tview.Cmd {
func sendInit(conn *websocket.Conn, privateKey *rsa.PrivateKey) tview.Cmd {
return func() tview.Msg {
if m.privateKey == nil {
if privateKey == nil {
return errMsg(errors.New("missing private key"))
}
spki, err := x509.MarshalPKIXPublicKey(m.privateKey.Public())
spki, err := x509.MarshalPKIXPublicKey(privateKey.Public())
if err != nil {
return errMsg(err)
}
@ -199,21 +199,21 @@ func (m *Model) sendInit() tview.Cmd {
Op string `json:"op"`
EncodedPublicKey string `json:"encoded_public_key"`
}{"init", encodedPublicKey}
if err := m.conn.WriteJSON(data); err != nil {
if err := conn.WriteJSON(data); err != nil {
return errMsg(err)
}
return nil
}
}
func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd {
func sendNonceProof(conn *websocket.Conn, privateKey *rsa.PrivateKey, encryptedNonce string) tview.Cmd {
return func() tview.Msg {
decodedNonce, err := base64.StdEncoding.DecodeString(encryptedNonce)
if err != nil {
return errMsg(err)
}
decryptedNonce, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedNonce, nil)
decryptedNonce, err := rsa.DecryptOAEP(sha256.New(), nil, privateKey, decodedNonce, nil)
if err != nil {
return errMsg(err)
}
@ -223,7 +223,7 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd {
Op string `json:"op"`
Nonce string `json:"nonce"`
}{"nonce_proof", encodedNonce}
if err := m.conn.WriteJSON(data); err != nil {
if err := conn.WriteJSON(data); err != nil {
return errMsg(err)
}
return nil
@ -234,7 +234,7 @@ type qrCodeMsg struct {
qrCode *qrcode.QRCode
}
func (m *Model) generateQRCode(fingerprint string) tview.Cmd {
func generateQRCode(fingerprint string) tview.Cmd {
return func() tview.Msg {
content := "https://discord.com/ra/" + fingerprint
qrCode, err := qrcode.New(content, qrcode.Low)
@ -251,14 +251,14 @@ type userMsg struct {
username string
}
func (m *Model) decryptUserPayload(encryptedPayload string) tview.Cmd {
func decryptUserPayload(privateKey *rsa.PrivateKey, encryptedPayload string) tview.Cmd {
return func() tview.Msg {
decodedPayload, err := base64.StdEncoding.DecodeString(encryptedPayload)
if err != nil {
return errMsg(err)
}
decryptedPayload, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedPayload, nil)
decryptedPayload, err := rsa.DecryptOAEP(sha256.New(), nil, privateKey, decodedPayload, nil)
if err != nil {
return errMsg(err)
}
@ -272,12 +272,12 @@ func (m *Model) decryptUserPayload(encryptedPayload string) tview.Cmd {
}
}
func (m *Model) exchangeTicket(ticket string) tview.Cmd {
func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket string) tview.Cmd {
return func() tview.Msg {
headers := http.Headers()
headers.Set("Referer", "https://discord.com/login")
if m.fingerprint != "" {
headers.Set("X-Fingerprint", m.fingerprint)
if fingerprint != "" {
headers.Set("X-Fingerprint", fingerprint)
}
client := http.NewClient("")
@ -293,7 +293,7 @@ func (m *Model) exchangeTicket(ticket string) tview.Cmd {
return errMsg(err)
}
decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedToken, nil)
decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, privateKey, decodedToken, nil)
if err != nil {
return errMsg(err)
}