refactor(ui): use value messages in models

This commit is contained in:
ayn2op 2026-04-13 18:49:26 -04:00
parent df7e3d0220
commit c998580e0e
16 changed files with 83 additions and 111 deletions

2
go.mod
View file

@ -11,7 +11,7 @@ require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/andybalholm/brotli v1.2.1
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97
github.com/ayn2op/tview v0.0.0-20260413170751-99e767e84007
github.com/ayn2op/tview v0.0.0-20260413180352-1d55ccf1cda2
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260311205148-176ad9b9440f
github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4

4
go.sum
View file

@ -14,8 +14,8 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80=
github.com/ayn2op/tview v0.0.0-20260413170751-99e767e84007 h1:9FrbhP2x4pl7zF5HbBmNLhMbSF/IsnBiZusmrUkwqKg=
github.com/ayn2op/tview v0.0.0-20260413170751-99e767e84007/go.mod h1:xgkca32kuEswK7Kzkfk3Pk24WHcKPjqK4xGmDi9QqOY=
github.com/ayn2op/tview v0.0.0-20260413180352-1d55ccf1cda2 h1:p9/lWit1O2j2DUrhiRKN6djcZM0J3vnAITdIAfUVtq0=
github.com/ayn2op/tview v0.0.0-20260413180352-1d55ccf1cda2/go.mod h1:xgkca32kuEswK7Kzkfk3Pk24WHcKPjqK4xGmDi9QqOY=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -49,7 +49,7 @@ func (ap *attachmentsPicker) close() tview.Cmd {
func (ap *attachmentsPicker) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *picker.SelectedMsg:
case picker.SelectedMsg:
index, ok := msg.Reference.(int)
if !ok {
return nil
@ -59,7 +59,7 @@ func (ap *attachmentsPicker) Update(msg tview.Msg) tview.Cmd {
}
ap.items[index].open()
return ap.close()
case *picker.CancelMsg:
case picker.CancelMsg:
return ap.close()
}

View file

@ -29,7 +29,7 @@ func newChannelsPicker(cfg *config.Config, chat *Model) *channelsPicker {
func (cp *channelsPicker) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *picker.SelectedMsg:
case picker.SelectedMsg:
channelID, ok := msg.Reference.(discord.ChannelID)
if !ok || !channelID.IsValid() {
return nil
@ -55,7 +55,7 @@ func (cp *channelsPicker) Update(msg tview.Msg) tview.Cmd {
}
cp.chat.closePicker()
return selectCmd
case *picker.CancelMsg:
case picker.CancelMsg:
cp.chat.closePicker()
return nil
}

View file

@ -385,7 +385,7 @@ func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Cmd {
if guildID := channel.GuildID; guildID.IsValid() {
gt.chat.messagesList.requestGuildMembers(guildID, messages)
}
return &channelLoadedMsg{Channel: channel, Messages: messages}
return channelLoadedMsg{Channel: channel, Messages: messages}
}
}
@ -405,9 +405,9 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
func (gt *guildsTree) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *tview.TreeViewSelectedMsg:
case tview.TreeViewSelectedMsg:
return gt.onSelected(msg.Node)
case *tview.KeyMsg:
case tview.KeyMsg:
handler := gt.TreeView.Update
switch {
case keybind.Matches(msg, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind):

View file

@ -54,7 +54,7 @@ type messageInput struct {
typingTimer *time.Timer
}
type tabSuggestMsg struct{ tcell.EventTime }
type tabSuggestMsg struct{}
var _ help.KeyMap = (*messageInput)(nil)
@ -108,9 +108,9 @@ func (mi *messageInput) stopTypingTimer() {
func (mi *messageInput) Update(msg tview.Msg) tview.Cmd {
handler := mi.TextArea.Update
switch msg := msg.(type) {
case *tabSuggestMsg:
case tabSuggestMsg:
return mi.tabSuggest()
case *tview.KeyMsg:
case tview.KeyMsg:
switch {
case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
mi.paste()
@ -512,7 +512,7 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) tview.Cmd
mi.chat.messagesList.setFetchingChunk(true, 0)
mi.chat.state.MemberState.SearchMember(gID, name)
mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent())
return &tabSuggestMsg{}
return tabSuggestMsg{}
}
}

View file

@ -846,7 +846,7 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
func (ml *messagesList) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *tview.KeyMsg:
case tview.KeyMsg:
switch {
case keybind.Matches(msg, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
ml.clearSelection()
@ -887,7 +887,7 @@ func (ml *messagesList) Update(msg tview.Msg) tview.Cmd {
}
return ml.Model.Update(msg)
case *olderMessagesLoadedMsg:
case olderMessagesLoadedMsg:
selectedChannel := ml.chat.SelectedChannel()
if selectedChannel == nil || selectedChannel.ID != msg.ChannelID {
return nil

View file

@ -247,9 +247,9 @@ func (m *Model) focusNext() tview.Cmd {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *tview.InitMsg:
case tview.InitMsg:
return tview.Batch(m.openState(), m.listen())
case *gatewayEventMsg:
case gatewayEventMsg:
switch eventMsg := msg.Event.(type) {
case *ws.RawEvent:
m.onRaw(eventMsg)
@ -278,7 +278,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
m.onReadUpdate(eventMsg)
}
return m.listen()
case *channelLoadedMsg:
case channelLoadedMsg:
node := m.guildsTree.GetCurrentNode()
if node == nil {
return nil
@ -311,7 +311,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
}
m.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
return focusCmd
case *QuitMsg:
case QuitMsg:
return tview.Batch(
m.closeState(),
tview.Quit(),
@ -331,7 +331,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
}
return focusCmd
}
case *tview.KeyMsg:
case tview.KeyMsg:
switch {
case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind):
m.messageInput.removeMentionsList()
@ -356,7 +356,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind):
return tview.Batch(m.closeState(), m.logout())
}
case *tabSuggestMsg:
case tabSuggestMsg:
// Member search completes in a command goroutine; resume suggestion
// generation on the update loop to keep UI mutations serialized.
return m.messageInput.Update(msg)

View file

@ -7,7 +7,6 @@ import (
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/gdamore/tcell/v3"
)
func (m *Model) openState() tview.Cmd {
@ -33,38 +32,35 @@ func (m *Model) closeState() tview.Cmd {
}
type gatewayEventMsg struct {
tcell.EventTime
gateway.Event
}
func (m *Model) listen() tview.Cmd {
return func() tview.Msg {
return &gatewayEventMsg{Event: <-m.events}
return gatewayEventMsg{Event: <-m.events}
}
}
type channelLoadedMsg struct {
tcell.EventTime
Channel discord.Channel
Messages []discord.Message
}
type olderMessagesLoadedMsg struct {
tcell.EventTime
ChannelID discord.ChannelID
Older []discord.Message
}
func newOlderMessagesLoadedMsg(channelID discord.ChannelID, older []discord.Message) *olderMessagesLoadedMsg {
return &olderMessagesLoadedMsg{ChannelID: channelID, Older: older}
func newOlderMessagesLoadedMsg(channelID discord.ChannelID, older []discord.Message) olderMessagesLoadedMsg {
return olderMessagesLoadedMsg{ChannelID: channelID, Older: older}
}
type LogoutMsg struct{ tcell.EventTime }
type LogoutMsg struct{}
func (m *Model) logout() tview.Cmd {
return func() tview.Msg {
return &LogoutMsg{}
return LogoutMsg{}
}
}
type QuitMsg struct{ tcell.EventTime }
type QuitMsg struct{}

View file

@ -43,7 +43,7 @@ func NewModel(cfg *config.Config) *Model {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *errMsg:
case errMsg:
if m.HasLayer(errorLayerName) {
return nil
}

View file

@ -5,11 +5,9 @@ import (
"github.com/ayn2op/discordo/internal/clipboard"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
type errMsg struct {
tcell.EventTime
err error
}

View file

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

View file

@ -14,33 +14,27 @@ import (
"github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/utils/httputil"
"github.com/gdamore/tcell/v3"
"github.com/gorilla/websocket"
"github.com/skip2/go-qrcode"
)
type errMsg struct {
tcell.EventTime
err error
}
func newErrMsg(err error) *errMsg {
return &errMsg{err: err}
func newErrMsg(err error) errMsg {
return errMsg{err: err}
}
type TokenMsg struct {
tcell.EventTime
Token string
}
type TokenMsg string
const remoteAuthGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
type connCreateMsg struct {
tcell.EventTime
conn *websocket.Conn
}
type connCloseMsg struct{ tcell.EventTime }
type connCloseMsg struct{}
func (m *Model) connect() tview.Cmd {
return func() tview.Msg {
@ -50,7 +44,7 @@ func (m *Model) connect() tview.Cmd {
if err != nil {
return newErrMsg(err)
}
return &connCreateMsg{conn: conn}
return connCreateMsg{conn: conn}
}
}
@ -61,37 +55,32 @@ func (m *Model) close() tview.Cmd {
return newErrMsg(err)
}
}
return &connCloseMsg{}
return connCloseMsg{}
}
}
type helloMsg struct {
tcell.EventTime
heartbeatInterval int
timeoutMS int
}
type nonceProofMsg struct {
tcell.EventTime
encryptedNonce string
}
type pendingRemoteInitMsg struct {
tcell.EventTime
fingerprint string
}
type pendingTicketMsg struct {
tcell.EventTime
encryptedUserPayload string
}
type pendingLoginMsg struct {
tcell.EventTime
ticket string
}
type cancelMsg struct{ tcell.EventTime }
type cancelMsg struct{}
func (m *Model) listen() tview.Cmd {
return func() tview.Msg {
@ -120,7 +109,7 @@ func (m *Model) listen() tview.Cmd {
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
}
return &helloMsg{heartbeatInterval: payload.HeartbeatInterval, timeoutMS: payload.TimeoutMS}
return helloMsg{heartbeatInterval: payload.HeartbeatInterval, timeoutMS: payload.TimeoutMS}
case "nonce_proof":
var payload struct {
EncryptedNonce string `json:"encrypted_nonce"`
@ -128,7 +117,7 @@ func (m *Model) listen() tview.Cmd {
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
}
return &nonceProofMsg{encryptedNonce: payload.EncryptedNonce}
return nonceProofMsg{encryptedNonce: payload.EncryptedNonce}
case "pending_remote_init":
var payload struct {
Fingerprint string `json:"fingerprint"`
@ -136,7 +125,7 @@ func (m *Model) listen() tview.Cmd {
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
}
return &pendingRemoteInitMsg{fingerprint: payload.Fingerprint}
return pendingRemoteInitMsg{fingerprint: payload.Fingerprint}
case "pending_ticket":
var payload struct {
EncryptedUserPayload string `json:"encrypted_user_payload"`
@ -144,9 +133,9 @@ func (m *Model) listen() tview.Cmd {
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
}
return &pendingTicketMsg{encryptedUserPayload: payload.EncryptedUserPayload}
return pendingTicketMsg{encryptedUserPayload: payload.EncryptedUserPayload}
case "cancel":
return &cancelMsg{}
return cancelMsg{}
case "pending_login":
var payload struct {
Ticket string `json:"ticket"`
@ -154,19 +143,19 @@ func (m *Model) listen() tview.Cmd {
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
}
return &pendingLoginMsg{ticket: payload.Ticket}
return pendingLoginMsg{ticket: payload.Ticket}
default:
return nil
}
}
}
type heartbeatTickMsg struct{ tcell.EventTime }
type heartbeatTickMsg struct{}
func (m *Model) heartbeat() tview.Cmd {
return func() tview.Msg {
time.Sleep(m.heartbeatInterval)
return &heartbeatTickMsg{}
return heartbeatTickMsg{}
}
}
@ -186,7 +175,6 @@ func (m *Model) sendHeartbeat() tview.Cmd {
}
type privateKeyMsg struct {
tcell.EventTime
privateKey *rsa.PrivateKey
}
@ -196,7 +184,7 @@ func (m *Model) generatePrivateKey() tview.Cmd {
if err != nil {
return newErrMsg(err)
}
return &privateKeyMsg{privateKey: privateKey}
return privateKeyMsg{privateKey: privateKey}
}
}
@ -246,7 +234,6 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd {
}
type qrCodeMsg struct {
tcell.EventTime
qrCode *qrcode.QRCode
}
@ -258,12 +245,11 @@ func (m *Model) generateQRCode(fingerprint string) tview.Cmd {
return newErrMsg(err)
}
qrCode.DisableBorder = true
return &qrCodeMsg{qrCode: qrCode}
return qrCodeMsg{qrCode: qrCode}
}
}
type userMsg struct {
tcell.EventTime
discriminator string
username string
}
@ -285,7 +271,7 @@ func (m *Model) decryptUserPayload(encryptedPayload string) tview.Cmd {
return newErrMsg(errors.New("invalid user payload"))
}
return &userMsg{discriminator: parts[1], username: parts[3]}
return userMsg{discriminator: parts[1], username: parts[3]}
}
}
@ -314,6 +300,6 @@ func (m *Model) exchangeTicket(ticket string) tview.Cmd {
if err != nil {
return newErrMsg(err)
}
return &TokenMsg{Token: string(decryptedToken)}
return TokenMsg(string(decryptedToken))
}
}

View file

@ -2,16 +2,12 @@ package token
import (
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
type TokenMsg struct {
tcell.EventTime
Token string
}
type TokenMsg string
func tokenCommand(token string) tview.Cmd {
return func() tview.Msg {
return &TokenMsg{Token: token}
return TokenMsg(token)
}
}

View file

@ -56,13 +56,13 @@ func NewModel(cfg *config.Config, app *tview.Application) *Model {
func (m *Model) showLogin() tview.Cmd {
m.inner = login.NewModel(m.cfg)
m.buildLayout()
return tview.Batch(m.inner.Update(&tview.InitMsg{}), tview.SetFocus(m))
return tview.Batch(m.inner.Update(tview.InitMsg{}), tview.SetFocus(m))
}
func (m *Model) showChat(token string) tview.Cmd {
m.inner = chat.NewModel(m.app, m.cfg, token)
m.buildLayout()
return tview.Batch(m.inner.Update(&tview.InitMsg{}), tview.SetFocus(m))
return tview.Batch(m.inner.Update(tview.InitMsg{}), tview.SetFocus(m))
}
func (m *Model) buildLayout() {
@ -82,7 +82,7 @@ func (m *Model) Draw(screen tcell.Screen) {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *tview.InitMsg:
case tview.InitMsg:
var cmd tview.Cmd
if token := os.Getenv(tokenEnvVarKey); token != "" {
cmd = tokenCommand(token)
@ -95,23 +95,23 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
cmd,
)
case *loginMsg:
case loginMsg:
return m.showLogin()
case *tokenMsg:
return m.showChat(msg.token)
case tokenMsg:
return m.showChat(string(msg))
case *token.TokenMsg:
return tview.Batch(m.showChat(msg.Token), setToken(msg.Token))
case *qr.TokenMsg:
return tview.Batch(m.showChat(msg.Token), setToken(msg.Token))
case token.TokenMsg:
return tview.Batch(m.showChat(string(msg)), setToken(string(msg)))
case qr.TokenMsg:
return tview.Batch(m.showChat(string(msg)), setToken(string(msg)))
case *chat.LogoutMsg:
case chat.LogoutMsg:
return tview.Batch(
m.showLogin(),
deleteToken(),
)
case *tview.KeyMsg:
case tview.KeyMsg:
switch {
case keybind.Matches(msg, m.cfg.Keybinds.ToggleHelp.Keybind):
m.help.SetShowAll(!m.help.ShowAll())
@ -123,7 +123,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case keybind.Matches(msg, m.cfg.Keybinds.Quit.Keybind):
var innerCmd tview.Cmd
if m.inner != nil {
innerCmd = m.inner.Update(&chat.QuitMsg{})
innerCmd = m.inner.Update(chat.QuitMsg{})
}
return tview.Batch(innerCmd, tview.Quit())
}

View file

@ -6,30 +6,26 @@ import (
"github.com/ayn2op/discordo/internal/clipboard"
"github.com/ayn2op/discordo/internal/keyring"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
type tokenMsg struct {
tcell.EventTime
token string
}
type tokenMsg string
func tokenCommand(token string) tview.Cmd {
return func() tview.Msg {
return &tokenMsg{token: token}
return tokenMsg(token)
}
}
type loginMsg struct{ tcell.EventTime }
type loginMsg struct{}
func getToken() tview.Cmd {
return func() tview.Msg {
token, err := keyring.GetToken()
if err != nil {
slog.Info("failed to retrieve token from keyring", "err", err)
return &loginMsg{}
return loginMsg{}
}
return &tokenMsg{token: token}
return tokenMsg(token)
}
}