From 1197e4b20406393ff63a5ca9d0f31d36341ea860 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Mon, 10 Aug 2026 16:46:21 -0400 Subject: [PATCH] refactor(ui): decouple commands from models --- internal/ui/chat/composer.go | 4 +-- internal/ui/chat/messages_list.go | 16 ++++----- internal/ui/chat/model.go | 14 ++++---- internal/ui/chat/msg.go | 18 ++++++----- internal/ui/login/model.go | 6 ++-- internal/ui/login/qr/model.go | 28 ++++++++-------- internal/ui/login/qr/msg.go | 54 +++++++++++++++---------------- 7 files changed, 71 insertions(+), 69 deletions(-) diff --git a/internal/ui/chat/composer.go b/internal/ui/chat/composer.go index b0e1db8..6ee41b4 100644 --- a/internal/ui/chat/composer.go +++ b/internal/ui/chat/composer.go @@ -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) diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 448ccbe..524979e 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -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) diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index d8ade3e..13a951e 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -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) diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index ab216bd..ebc962f 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -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{} } diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index 21372a2..421e2d1 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -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, diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index ec6dd21..2df0983 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -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 diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index e11f954..b9ff282 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -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) }