Преглед изворни кода

feat(ui/login): switch to tabs for token and qr login methods

ayn2op пре 1 месец
родитељ
комит
5917a460d3

+ 1 - 1
cmd/root.go

@@ -64,7 +64,7 @@ func Run() error {
 
 	tview.Styles = tview.Theme{}
 	app := tview.NewApplication()
-	app.SetRoot(root.NewView(cfg, app))
+	app.SetRoot(root.NewModel(cfg, app))
 	app.SetScreen(screen)
 	return app.Run()
 }

+ 2 - 2
go.mod

@@ -11,9 +11,9 @@ require (
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/andybalholm/brotli v1.2.0
 	github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97
-	github.com/ayn2op/tview v0.0.0-20260304052427-7549865d186e
+	github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e
 	github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
-	github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa
+	github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64
 	github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4
 	github.com/gdamore/tcell/v3 v3.1.2
 	github.com/gen2brain/beeep v0.11.2

+ 4 - 4
go.sum

@@ -16,8 +16,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
 github.com/andybalholm/brotli v1.2.0/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-20260304052427-7549865d186e h1:fc5qHUJV+XYlYabsJtWW/AwbGLFu6XMFTiHXDViUBeM=
-github.com/ayn2op/tview v0.0.0-20260304052427-7549865d186e/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
+github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e h1:FyeMOB3R64YqkAv6I6AZZpZgr86X5ixP9GSCv6uCJyg=
+github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0=
 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=
@@ -27,8 +27,8 @@ github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI=
 github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc=
 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw=
 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y=
-github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa h1:xgONqoeHHAxAG002WkwxZsZ3vrOpA+VskVO4CNLsPIA=
-github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go=
+github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64 h1:ZdzbwItbGTvOdNfoCH7QIbRiBPDufsq8AwfJUaM4mHM=
+github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go=
 github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 h1:m1WyrOUuFE4BuIcQzPeRmmLQmJMBNElT/tfx/s2+AoE=
 github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4/go.mod h1:JSqSCBN5MAI9yjAO6tZlSayXM9KWx+8q3EhhlkCuscM=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=

+ 2 - 2
internal/ui/chat/attachments_picker.go

@@ -17,13 +17,13 @@ type attachmentItem struct {
 type attachmentsPicker struct {
 	*picker.Picker
 	cfg      *config.Config
-	chatView *View
+	chatView *Model
 	items    []attachmentItem
 }
 
 var _ help.KeyMap = (*attachmentsPicker)(nil)
 
-func newAttachmentsPicker(cfg *config.Config, chatView *View) *attachmentsPicker {
+func newAttachmentsPicker(cfg *config.Config, chatView *Model) *attachmentsPicker {
 	ap := &attachmentsPicker{
 		Picker:   picker.New(),
 		cfg:      cfg,

+ 2 - 2
internal/ui/chat/channels_picker.go

@@ -15,12 +15,12 @@ import (
 
 type channelsPicker struct {
 	*picker.Picker
-	chatView *View
+	chatView *Model
 }
 
 var _ help.KeyMap = (*channelsPicker)(nil)
 
-func newChannelsPicker(cfg *config.Config, chatView *View) *channelsPicker {
+func newChannelsPicker(cfg *config.Config, chatView *Model) *channelsPicker {
 	cp := &channelsPicker{picker.New(), chatView}
 	cp.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
 	// When a child of tview.Flex is focused, tview.Flex itself is not reported as focused. Instead, the focused child (picker) is considered focused. Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance.

+ 13 - 14
internal/ui/chat/events.go

@@ -9,12 +9,16 @@ import (
 
 type LogoutEvent struct{ tcell.EventTime }
 
-func NewLogoutEvent() *LogoutEvent {
+func newLogoutEvent() *LogoutEvent {
 	event := &LogoutEvent{}
 	event.SetEventNow()
 	return event
 }
 
+func (v *Model) logout() tview.Command {
+	return tview.EventCommand(func() tcell.Event { return newLogoutEvent() })
+}
+
 type QuitEvent struct{ tcell.EventTime }
 
 func NewQuitEvent() *QuitEvent {
@@ -23,17 +27,12 @@ func NewQuitEvent() *QuitEvent {
 	return event
 }
 
-func (v *View) closeState() tcell.Event {
-	if err := v.CloseState(); err != nil {
-		slog.Error("failed to close the session", "err", err)
-		return tcell.NewEventError(err)
-	}
-	return nil
-}
-
-func (v *View) logout() tview.Command {
-	return tview.BatchCommand{
-		tview.EventCommand(v.closeState),
-		tview.EventCommand(func() tcell.Event { return NewLogoutEvent() }),
-	}
+func (v *Model) closeState() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if err := v.CloseState(); err != nil {
+			slog.Error("failed to close the session", "err", err)
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
 }

+ 29 - 28
internal/ui/chat/guilds_tree.go

@@ -20,8 +20,9 @@ type dmNode struct{}
 
 type guildsTree struct {
 	*tview.TreeView
-	cfg      *config.Config
-	chatView *View
+	chat *Model
+
+	cfg *config.Config
 
 	// Fast-path indexes for frequent event handlers (read updates, picker
 	// navigation). They mirror the current rendered tree and are rebuilt on
@@ -33,11 +34,11 @@ type guildsTree struct {
 
 var _ help.KeyMap = (*guildsTree)(nil)
 
-func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree {
+func newGuildsTree(cfg *config.Config, chatView *Model) *guildsTree {
 	gt := &guildsTree{
 		TreeView: tview.NewTreeView(),
 		cfg:      cfg,
-		chatView: chatView,
+		chat:     chatView,
 
 		guildNodeByID:   make(map[discord.GuildID]*tview.TreeNode),
 		channelNodeByID: make(map[discord.ChannelID]*tview.TreeNode),
@@ -183,12 +184,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl
 }
 
 func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style {
-	indication := gt.chatView.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}})
+	indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}})
 	return gt.unreadStyle(indication)
 }
 
 func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style {
-	indication := gt.chatView.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
+	indication := gt.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
 	return gt.unreadStyle(indication)
 }
 
@@ -204,11 +205,11 @@ func (gt *guildsTree) createGuildNode(n *tview.TreeNode, guild discord.Guild) {
 }
 
 func (gt *guildsTree) createChannelNode(node *tview.TreeNode, channel discord.Channel) {
-	if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
+	if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
 		return
 	}
 
-	channelNode := tview.NewTreeNode(ui.ChannelToString(channel, gt.cfg.Icons, gt.chatView.state)).SetReference(channel.ID)
+	channelNode := tview.NewTreeNode(ui.ChannelToString(channel, gt.cfg.Icons, gt.chat.state)).SetReference(channel.ID)
 	gt.setNodeLineStyle(channelNode, gt.getChannelNodeStyle(channel.ID))
 	switch channel.Type {
 	case discord.DirectMessage:
@@ -285,9 +286,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 
 	switch ref := node.GetReference().(type) {
 	case discord.GuildID:
-		go gt.chatView.state.MemberState.Subscribe(ref)
+		go gt.chat.state.MemberState.Subscribe(ref)
 
-		channels, err := gt.chatView.state.Cabinet.Channels(ref)
+		channels, err := gt.chat.state.Cabinet.Channels(ref)
 		if err != nil {
 			slog.Error("failed to get channels", "err", err, "guild_id", ref)
 			return
@@ -297,7 +298,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		gt.createChannelNodes(node, channels)
 		node.Expand()
 	case discord.ChannelID:
-		channel, err := gt.chatView.state.Cabinet.Channel(ref)
+		channel, err := gt.chat.state.Cabinet.Channel(ref)
 		if err != nil {
 			slog.Error("failed to get channel from state", "err", err, "channel_id", ref)
 			return
@@ -306,7 +307,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		// Handle forum channels differently - they contain threads, not direct messages
 		if channel.Type == discord.GuildForum {
 			// Get all channels from the guild - this includes active threads from GuildCreateEvent
-			allChannels, err := gt.chatView.state.Cabinet.Channels(channel.GuildID)
+			allChannels, err := gt.chat.state.Cabinet.Channels(channel.GuildID)
 			if err != nil {
 				slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID)
 				return
@@ -331,41 +332,41 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		}
 
 		limit := gt.cfg.MessagesLimit
-		messages, err := gt.chatView.state.Messages(channel.ID, uint(limit))
+		messages, err := gt.chat.state.Messages(channel.ID, uint(limit))
 		if err != nil {
 			slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit)
 			return
 		}
 
-		go gt.chatView.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
+		go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
 
 		if guildID := channel.GuildID; guildID.IsValid() {
-			gt.chatView.messagesList.requestGuildMembers(guildID, messages)
+			gt.chat.messagesList.requestGuildMembers(guildID, messages)
 		}
 
-		gt.chatView.SetSelectedChannel(channel)
-		gt.chatView.clearTypers()
-		gt.chatView.messageInput.stopTypingTimer()
+		gt.chat.SetSelectedChannel(channel)
+		gt.chat.clearTypers()
+		gt.chat.messageInput.stopTypingTimer()
 
-		gt.chatView.messagesList.reset()
-		gt.chatView.messagesList.setTitle(*channel)
-		gt.chatView.messagesList.setMessages(messages)
-		gt.chatView.messagesList.ScrollToEnd()
+		gt.chat.messagesList.reset()
+		gt.chat.messagesList.setTitle(*channel)
+		gt.chat.messagesList.setMessages(messages)
+		gt.chat.messagesList.ScrollToEnd()
 
-		hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
-		gt.chatView.messageInput.SetDisabled(hasNoPerm)
+		hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
+		gt.chat.messageInput.SetDisabled(hasNoPerm)
 		var text string
 		if hasNoPerm {
 			text = "You do not have permission to send messages in this channel."
 		} else {
 			text = "Message..."
 			if gt.cfg.AutoFocus {
-				gt.chatView.app.SetFocus(gt.chatView.messageInput)
+				gt.chat.app.SetFocus(gt.chat.messageInput)
 			}
 		}
-		gt.chatView.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
+		gt.chat.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
 	case dmNode: // Direct messages folder
-		channels, err := gt.chatView.state.PrivateChannels()
+		channels, err := gt.chat.state.PrivateChannels()
 		if err != nil {
 			slog.Error("failed to get private channels", "err", err)
 			return
@@ -467,7 +468,7 @@ func (gt *guildsTree) findNodeByReference(reference any) *tview.TreeNode {
 }
 
 func (gt *guildsTree) findNodeByChannelID(channelID discord.ChannelID) *tview.TreeNode {
-	channel, err := gt.chatView.state.Cabinet.Channel(channelID)
+	channel, err := gt.chat.state.Cabinet.Channel(channelID)
 	if err != nil {
 		slog.Error("failed to get channel", "channel_id", channelID, "err", err)
 		return nil

+ 6 - 6
internal/ui/chat/keybinds.go

@@ -5,9 +5,9 @@ import (
 	"github.com/ayn2op/tview/keybind"
 )
 
-var _ help.KeyMap = (*View)(nil)
+var _ help.KeyMap = (*Model)(nil)
 
-func (v *View) ShortHelp() []keybind.Keybind {
+func (v *Model) ShortHelp() []keybind.Keybind {
 	short := make([]keybind.Keybind, 0, 16)
 	if active := v.activeKeyMap(); active != nil {
 		short = append(short, active.ShortHelp()...)
@@ -16,7 +16,7 @@ func (v *View) ShortHelp() []keybind.Keybind {
 	return short
 }
 
-func (v *View) FullHelp() [][]keybind.Keybind {
+func (v *Model) FullHelp() [][]keybind.Keybind {
 	full := make([][]keybind.Keybind, 0, 8)
 	if active := v.activeKeyMap(); active != nil {
 		full = append(full, active.FullHelp()...)
@@ -25,7 +25,7 @@ func (v *View) FullHelp() [][]keybind.Keybind {
 	return full
 }
 
-func (v *View) activeKeyMap() help.KeyMap {
+func (v *Model) activeKeyMap() help.KeyMap {
 	if v.GetVisible(channelsPickerLayerName) {
 		return v.channelsPicker
 	}
@@ -46,7 +46,7 @@ func (v *View) activeKeyMap() help.KeyMap {
 	}
 }
 
-func (v *View) baseShortHelp() []keybind.Keybind {
+func (v *Model) baseShortHelp() []keybind.Keybind {
 	cfg := v.cfg.Keybinds
 	short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	if !v.messageInput.GetDisabled() {
@@ -56,7 +56,7 @@ func (v *View) baseShortHelp() []keybind.Keybind {
 	return short
 }
 
-func (v *View) baseFullHelp() [][]keybind.Keybind {
+func (v *Model) baseFullHelp() [][]keybind.Keybind {
 	cfg := v.cfg.Keybinds
 	focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	if !v.messageInput.GetDisabled() {

+ 52 - 51
internal/ui/chat/message_input.go

@@ -41,8 +41,9 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
 
 type messageInput struct {
 	*tview.TextArea
-	cfg      *config.Config
-	chatView *View
+	chat *Model
+
+	cfg *config.Config
 
 	edit            bool
 	sendMessageData *api.SendMessageData
@@ -56,11 +57,11 @@ type messageInput struct {
 
 var _ help.KeyMap = (*messageInput)(nil)
 
-func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
+func newMessageInput(cfg *config.Config, chatView *Model) *messageInput {
 	mi := &messageInput{
 		TextArea:        tview.NewTextArea(),
 		cfg:             cfg,
-		chatView:        chatView,
+		chat:            chatView,
 		sendMessageData: &api.SendMessageData{},
 		cache:           cache.NewCache(),
 		mentionsList:    newMentionsList(cfg),
@@ -113,7 +114,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			mi.paste()
 			return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind):
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				mi.tabComplete()
 			} else {
 				mi.send()
@@ -141,7 +142,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			return cmds
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
 			var cmds tview.BatchCommand
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				mi.stopTabCompletion(func(next tview.Command) {
 					if next != nil {
 						cmds = append(cmds, next)
@@ -153,7 +154,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			cmds = append(cmds, redraw)
 			return cmds
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind):
-			go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() })
+			go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() })
 			return redraw
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
 			return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
@@ -166,13 +167,13 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 				mi.typingTimerMu.Unlock()
 			})
 
-			if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil {
-				go mi.chatView.state.Typing(selectedChannel.ID)
+			if selectedChannel := mi.chat.SelectedChannel(); selectedChannel != nil {
+				go mi.chat.state.Typing(selectedChannel.ID)
 			}
 		}
 
 		if mi.cfg.AutocompleteLimit > 0 {
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				switch {
 				case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
 					mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
@@ -189,7 +190,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 				}
 			}
 
-			go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
+			go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
 		}
 
 		return handler(event)
@@ -210,7 +211,7 @@ func (mi *messageInput) paste() {
 }
 
 func (mi *messageInput) send() {
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -232,14 +233,14 @@ func (mi *messageInput) send() {
 	text = mi.processText(selected, []byte(text))
 
 	if mi.edit {
-		m, err := mi.chatView.messagesList.selectedMessage()
+		m, err := mi.chat.messagesList.selectedMessage()
 		if err != nil {
 			slog.Error("failed to get selected message", "err", err)
 			return
 		}
 
 		data := api.EditMessageData{Content: option.NewNullableString(text)}
-		if _, err := mi.chatView.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
+		if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
 			slog.Error("failed to edit message", "err", err)
 		}
 
@@ -247,7 +248,7 @@ func (mi *messageInput) send() {
 	} else {
 		data := mi.sendMessageData
 		data.Content = text
-		if _, err := mi.chatView.state.SendMessageComplex(selected.ID, *data); err != nil {
+		if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil {
 			slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err)
 		}
 	}
@@ -257,8 +258,8 @@ func (mi *messageInput) send() {
 		mi.typingTimer = nil
 	}
 	mi.reset()
-	mi.chatView.messagesList.clearSelection()
-	mi.chatView.messagesList.ScrollToEnd()
+	mi.chat.messagesList.clearSelection()
+	mi.chat.messagesList.ScrollToEnd()
 }
 
 func (mi *messageInput) processText(channel *discord.Channel, src []byte) string {
@@ -302,7 +303,7 @@ func (mi *messageInput) processText(channel *discord.Channel, src []byte) string
 }
 
 func (mi *messageInput) expandMentions(c *discord.Channel, src []byte) []byte {
-	state := mi.chatView.state
+	state := mi.chat.state
 	return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
 		output := input
 		name := string(input[1:])
@@ -342,7 +343,7 @@ func (mi *messageInput) tabComplete() {
 	}
 	pos := posEnd - (len(name) + 1)
 
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -357,7 +358,7 @@ func (mi *messageInput) tabComplete() {
 			}
 		} else {
 			mi.searchMember(gID, name)
-			members, err := mi.chatView.state.Cabinet.Members(gID)
+			members, err := mi.chat.state.Cabinet.Members(gID)
 			if err != nil {
 				slog.Error("failed to get members from state", "guild_id", gID, "err", err)
 				return
@@ -365,7 +366,7 @@ func (mi *messageInput) tabComplete() {
 
 			res := fuzzy.FindFrom(name, memberList(members))
 			for _, r := range res {
-				if channelHasUser(mi.chatView.state, selected.ID, members[r.Index].User.ID) {
+				if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) {
 					mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
 					return
 				}
@@ -392,7 +393,7 @@ func (mi *messageInput) tabSuggestion() {
 		mi.stopTabCompletion(nil)
 		return
 	}
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -405,14 +406,14 @@ func (mi *messageInput) tabSuggestion() {
 	if name == "" {
 		shown = make(map[string]struct{})
 		// Don't show @me in the list of recent authors
-		me, _ := mi.chatView.state.Cabinet.Me()
+		me, _ := mi.chat.state.Cabinet.Me()
 		shown[me.Username] = userDone
 	}
 
 	// DMs have recipients, not members
 	if !gID.IsValid() {
 		if name == "" { // show recent messages' authors
-			msgs, err := mi.chatView.state.Cabinet.Messages(cID)
+			msgs, err := mi.chat.state.Cabinet.Messages(cID)
 			if err != nil {
 				return
 			}
@@ -425,7 +426,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 		} else {
 			users := selected.DMRecipients
-			me, _ := mi.chatView.state.Cabinet.Me()
+			me, _ := mi.chat.state.Cabinet.Me()
 			users = append(users, *me)
 			res := fuzzy.FindFrom(name, userList(users))
 			for _, r := range res {
@@ -433,7 +434,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 		}
 	} else if name == "" { // show recent messages' authors
-		msgs, err := mi.chatView.state.Cabinet.Messages(cID)
+		msgs, err := mi.chat.state.Cabinet.Messages(cID)
 		if err != nil {
 			return
 		}
@@ -442,8 +443,8 @@ func (mi *messageInput) tabSuggestion() {
 				continue
 			}
 			shown[m.Author.Username] = userDone
-			mi.chatView.state.MemberState.RequestMember(gID, m.Author.ID)
-			if mem, err := mi.chatView.state.Cabinet.Member(gID, m.Author.ID); err == nil {
+			mi.chat.state.MemberState.RequestMember(gID, m.Author.ID)
+			if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil {
 				if mi.addMentionMember(gID, mem) {
 					break
 				}
@@ -451,7 +452,7 @@ func (mi *messageInput) tabSuggestion() {
 		}
 	} else {
 		mi.searchMember(gID, name)
-		mems, err := mi.chatView.state.Cabinet.Members(gID)
+		mems, err := mi.chat.state.Cabinet.Members(gID)
 		if err != nil {
 			slog.Error("fetching members failed", "err", err)
 			return
@@ -461,7 +462,7 @@ func (mi *messageInput) tabSuggestion() {
 			res = res[:int(mi.cfg.AutocompleteLimit)]
 		}
 		for _, r := range res {
-			if channelHasUser(mi.chatView.state, cID, mems[r.Index].User.ID) &&
+			if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) &&
 				mi.addMentionMember(gID, &mems[r.Index]) {
 				break
 			}
@@ -516,22 +517,22 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) {
 	// everything starting with "ab". This will still be true even if a new
 	// member joins because arikawa loads new members into the state.
 	if k := key[:len(key)-1]; mi.cache.Exists(k) {
-		if c := mi.cache.Get(k); c < mi.chatView.state.MemberState.SearchLimit {
+		if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit {
 			mi.cache.Create(key, c)
 			return
 		}
 	}
 
 	// Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect
-	if mi.lastSearch.Add(mi.chatView.state.MemberState.SearchFrequency).After(time.Now()) {
+	if mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) {
 		return
 	}
 
 	mi.lastSearch = time.Now()
-	mi.chatView.messagesList.waitForChunkEvent()
-	mi.chatView.messagesList.setFetchingChunk(true, 0)
-	mi.chatView.state.MemberState.SearchMember(gID, name)
-	mi.cache.Create(key, mi.chatView.messagesList.waitForChunkEvent())
+	mi.chat.messagesList.waitForChunkEvent()
+	mi.chat.messagesList.setFetchingChunk(true, 0)
+	mi.chat.state.MemberState.SearchMember(gID, name)
+	mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent())
 }
 
 func (mi *messageInput) showMentionList() {
@@ -542,7 +543,7 @@ func (mi *messageInput) showMentionList() {
 	l := mi.mentionsList
 	x, _, _, _ := mi.GetInnerRect()
 	_, y, _, _ := mi.GetRect()
-	_, _, maxW, maxH := mi.chatView.messagesList.GetInnerRect()
+	_, _, maxW, maxH := mi.chat.messagesList.GetInnerRect()
 	if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
 		maxH = min(maxH, t)
 	}
@@ -561,8 +562,8 @@ func (mi *messageInput) showMentionList() {
 	}
 
 	l.SetRect(x, y, w, h)
-	mi.chatView.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName)
-	mi.chatView.app.SetFocus(mi)
+	mi.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName)
+	mi.chat.app.SetFocus(mi)
 }
 
 func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
@@ -579,14 +580,14 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member)
 
 	// This avoids a slower member color lookup path.
 	color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
-		r, _ := mi.chatView.state.Cabinet.Role(gID, id)
+		r, _ := mi.chat.state.Cabinet.Role(gID, id)
 		return r
 	})
 	if ok {
 		style = style.Foreground(tcell.NewHexColor(int32(color)))
 	}
 
-	presence, err := mi.chatView.state.Cabinet.Presence(gID, m.User.ID)
+	presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID)
 	if err != nil {
 		slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
@@ -608,7 +609,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 
 	name := user.DisplayOrUsername()
 	style := tcell.StyleDefault
-	presence, err := mi.chatView.state.Cabinet.Presence(discord.NullGuildID, user.ID)
+	presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID)
 	if err != nil {
 		slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
@@ -623,7 +624,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 }
 
 func (mi *messageInput) removeMentionsList() {
-	mi.chatView.HideLayer(mentionsListLayerName)
+	mi.chat.HideLayer(mentionsListLayerName)
 }
 
 func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
@@ -634,7 +635,7 @@ func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
 			emit(tview.SetFocusCommand{Target: mi})
 		} else {
 			mi.removeMentionsList()
-			mi.chatView.app.SetFocus(mi)
+			mi.chat.app.SetFocus(mi)
 		}
 	}
 }
@@ -664,7 +665,7 @@ func (mi *messageInput) editor() {
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 
-	mi.chatView.app.Suspend(func() {
+	mi.chat.app.Suspend(func() {
 		err := cmd.Run()
 		if err != nil {
 			slog.Error("failed to run command", "args", cmd.Args, "err", err)
@@ -682,7 +683,7 @@ func (mi *messageInput) editor() {
 }
 
 func (mi *messageInput) openFilePicker() {
-	if mi.chatView.SelectedChannel() == nil {
+	if mi.chat.SelectedChannel() == nil {
 		return
 	}
 
@@ -715,11 +716,11 @@ func (mi *messageInput) attach(name string, reader io.Reader) {
 }
 
 func (mi *messageInput) ShortHelp() []keybind.Keybind {
-	if mi.chatView.GetVisible(mentionsListLayerName) {
+	if mi.chat.GetVisible(mentionsListLayerName) {
 		cfg := mi.cfg.Keybinds.MentionsList
 		icfg := mi.cfg.Keybinds.MessageInput
 		short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind}
-		if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
+		if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
 			short = append(short, icfg.OpenFilePicker.Keybind)
 		}
 		return short
@@ -727,14 +728,14 @@ func (mi *messageInput) ShortHelp() []keybind.Keybind {
 
 	cfg := mi.cfg.Keybinds.MessageInput
 	short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
-	if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
+	if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
 		short = append(short, cfg.OpenFilePicker.Keybind)
 	}
 	return short
 }
 
 func (mi *messageInput) FullHelp() [][]keybind.Keybind {
-	if mi.chatView.GetVisible(mentionsListLayerName) {
+	if mi.chat.GetVisible(mentionsListLayerName) {
 		mcfg := mi.cfg.Keybinds.MentionsList
 		icfg := mi.cfg.Keybinds.MessageInput
 		return [][]keybind.Keybind{
@@ -745,7 +746,7 @@ func (mi *messageInput) FullHelp() [][]keybind.Keybind {
 
 	cfg := mi.cfg.Keybinds.MessageInput
 	openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
-	if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
+	if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) {
 		openEditor = append(openEditor, cfg.OpenFilePicker.Keybind)
 	}
 

+ 2 - 2
internal/ui/chat/messages_list.go

@@ -43,7 +43,7 @@ import (
 type messagesList struct {
 	*tview.List
 	cfg      *config.Config
-	chatView *View
+	chatView *Model
 	messages []discord.Message
 	// rows is the virtual list model rendered by tview (message rows +
 	// date-separator rows). It is rebuilt lazily when rowsDirty is true.
@@ -79,7 +79,7 @@ type messagesListRow struct {
 	timestamp    discord.Timestamp
 }
 
-func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
+func newMessagesList(cfg *config.Config, chatView *Model) *messagesList {
 	ml := &messagesList{
 		List:     tview.NewList(),
 		cfg:      cfg,

+ 45 - 35
internal/ui/chat/view.go → internal/ui/chat/model.go

@@ -27,7 +27,7 @@ const (
 	channelsPickerLayerName  = "channelsPicker"
 )
 
-type View struct {
+type Model struct {
 	*layers.Layers
 
 	rootFlex  *tview.Flex
@@ -45,14 +45,17 @@ type View struct {
 	typersMu sync.RWMutex
 	typers   map[discord.UserID]*time.Timer
 
+	confirmModalDone          func(label string)
+	confirmModalPreviousFocus tview.Primitive
+
 	app   *tview.Application
 	cfg   *config.Config
 	state *ningen.State
 	token string
 }
 
-func NewView(app *tview.Application, cfg *config.Config, token string) *View {
-	v := &View{
+func NewView(app *tview.Application, cfg *config.Config, token string) *Model {
+	v := &Model{
 		Layers: layers.New(),
 
 		rootFlex:  tview.NewFlex(),
@@ -77,19 +80,19 @@ func NewView(app *tview.Application, cfg *config.Config, token string) *View {
 	return v
 }
 
-func (v *View) SelectedChannel() *discord.Channel {
+func (v *Model) SelectedChannel() *discord.Channel {
 	v.selectedChannelMu.RLock()
 	defer v.selectedChannelMu.RUnlock()
 	return v.selectedChannel
 }
 
-func (v *View) SetSelectedChannel(channel *discord.Channel) {
+func (v *Model) SetSelectedChannel(channel *discord.Channel) {
 	v.selectedChannelMu.Lock()
 	v.selectedChannel = channel
 	v.selectedChannelMu.Unlock()
 }
 
-func (v *View) buildLayout() {
+func (v *Model) buildLayout() {
 	v.Clear()
 	v.rootFlex.Clear()
 	v.rightFlex.Clear()
@@ -111,7 +114,7 @@ func (v *View) buildLayout() {
 	v.AddLayer(v.messageInput.mentionsList, layers.WithName(mentionsListLayerName), layers.WithResize(false), layers.WithVisible(false))
 }
 
-func (v *View) togglePicker() {
+func (v *Model) togglePicker() {
 	if v.HasLayer(channelsPickerLayerName) {
 		v.closePicker()
 	} else {
@@ -119,7 +122,7 @@ func (v *View) togglePicker() {
 	}
 }
 
-func (v *View) openPicker() {
+func (v *Model) openPicker() {
 	v.AddLayer(
 		ui.Centered(v.channelsPicker, v.cfg.Picker.Width, v.cfg.Picker.Height),
 		layers.WithName(channelsPickerLayerName),
@@ -130,12 +133,12 @@ func (v *View) openPicker() {
 	v.channelsPicker.update()
 }
 
-func (v *View) closePicker() {
+func (v *Model) closePicker() {
 	v.RemoveLayer(channelsPickerLayerName)
 	v.channelsPicker.Update()
 }
 
-func (v *View) toggleGuildsTree() {
+func (v *Model) toggleGuildsTree() {
 	// The guilds tree is visible if the number of items is two.
 	if v.mainFlex.GetItemCount() == 2 {
 		v.mainFlex.RemoveItem(v.guildsTree)
@@ -148,7 +151,7 @@ func (v *View) toggleGuildsTree() {
 	}
 }
 
-func (v *View) focusGuildsTree() bool {
+func (v *Model) focusGuildsTree() bool {
 	// The guilds tree is not hidden if the number of items is two.
 	if v.mainFlex.GetItemCount() == 2 {
 		v.app.SetFocus(v.guildsTree)
@@ -158,7 +161,7 @@ func (v *View) focusGuildsTree() bool {
 	return false
 }
 
-func (v *View) focusMessageInput() bool {
+func (v *Model) focusMessageInput() bool {
 	if !v.messageInput.GetDisabled() {
 		v.app.SetFocus(v.messageInput)
 		return true
@@ -167,7 +170,7 @@ func (v *View) focusMessageInput() bool {
 	return false
 }
 
-func (v *View) focusPrevious() {
+func (v *Model) focusPrevious() {
 	switch v.app.GetFocus() {
 	case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
 		if v.focusGuildsTree() {
@@ -184,7 +187,7 @@ func (v *View) focusPrevious() {
 	}
 }
 
-func (v *View) focusNext() {
+func (v *Model) focusNext() {
 	switch v.app.GetFocus() {
 	case v.messagesList:
 		if v.focusMessageInput() {
@@ -201,7 +204,7 @@ func (v *View) focusNext() {
 	}
 }
 
-func (v *View) HandleEvent(event tcell.Event) tview.Command {
+func (v *Model) HandleEvent(event tcell.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.InitEvent:
 		return tview.EventCommand(func() tcell.Event {
@@ -213,8 +216,22 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command {
 		})
 	case *QuitEvent:
 		return tview.BatchCommand{
-			tview.EventCommand(v.closeState),
-			tview.QuitCommand{},
+			v.closeState(),
+			tview.Quit(),
+		}
+	case *tview.ModalDoneEvent:
+		if v.HasLayer(confirmModalLayerName) {
+			v.RemoveLayer(confirmModalLayerName)
+			if v.confirmModalPreviousFocus != nil {
+				v.app.SetFocus(v.confirmModalPreviousFocus)
+			}
+			onDone := v.confirmModalDone
+			v.confirmModalDone = nil
+			v.confirmModalPreviousFocus = nil
+			if onDone != nil {
+				onDone(event.ButtonLabel)
+			}
+			return tview.RedrawCommand{}
 		}
 	case *tview.KeyEvent:
 		redraw := tview.RedrawCommand{}
@@ -237,7 +254,7 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command {
 			v.focusNext()
 			return redraw
 		case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind):
-			return v.logout()
+			return tview.BatchCommand{v.closeState(), v.logout()}
 		case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind):
 			v.toggleGuildsTree()
 			return redraw
@@ -250,7 +267,7 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command {
 	return v.consumeLayerCommands(cmd)
 }
 
-func (v *View) consumeLayerCommands(command tview.Command) tview.Command {
+func (v *Model) consumeLayerCommands(command tview.Command) tview.Command {
 	if command == nil {
 		return nil
 	}
@@ -298,20 +315,13 @@ func (v *View) consumeLayerCommands(command tview.Command) tview.Command {
 	return tview.BatchCommand(remaining)
 }
 
-func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
-	previousFocus := v.app.GetFocus()
+func (v *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
+	v.confirmModalPreviousFocus = v.app.GetFocus()
+	v.confirmModalDone = onDone
 
 	modal := tview.NewModal().
 		SetText(prompt).
-		AddButtons(buttons).
-		SetDoneFunc(func(_ int, buttonLabel string) {
-			v.RemoveLayer(confirmModalLayerName)
-			v.app.SetFocus(previousFocus)
-
-			if onDone != nil {
-				onDone(buttonLabel)
-			}
-		})
+		AddButtons(buttons)
 	v.
 		AddLayer(
 			ui.Centered(modal, 0, 0),
@@ -323,7 +333,7 @@ func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(lab
 		SendToFront(confirmModalLayerName)
 }
 
-func (v *View) onReadUpdate(event *read.UpdateEvent) {
+func (v *Model) onReadUpdate(event *read.UpdateEvent) {
 	v.app.QueueUpdateDraw(func() {
 		// Use indexed node lookup to avoid walking the whole tree on every read
 		// event. This runs frequently while reading/typing across channels.
@@ -341,7 +351,7 @@ func (v *View) onReadUpdate(event *read.UpdateEvent) {
 	})
 }
 
-func (v *View) clearTypers() {
+func (v *Model) clearTypers() {
 	v.typersMu.Lock()
 	for _, timer := range v.typers {
 		timer.Stop()
@@ -351,7 +361,7 @@ func (v *View) clearTypers() {
 	v.updateFooter()
 }
 
-func (v *View) addTyper(userID discord.UserID) {
+func (v *Model) addTyper(userID discord.UserID) {
 	v.typersMu.Lock()
 	typer, ok := v.typers[userID]
 	if ok {
@@ -365,7 +375,7 @@ func (v *View) addTyper(userID discord.UserID) {
 	v.updateFooter()
 }
 
-func (v *View) removeTyper(userID discord.UserID) {
+func (v *Model) removeTyper(userID discord.UserID) {
 	v.typersMu.Lock()
 	if typer, ok := v.typers[userID]; ok {
 		typer.Stop()
@@ -375,7 +385,7 @@ func (v *View) removeTyper(userID discord.UserID) {
 	v.updateFooter()
 }
 
-func (v *View) updateFooter() {
+func (v *Model) updateFooter() {
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel == nil {
 		return

+ 11 - 11
internal/ui/chat/state.go

@@ -20,7 +20,7 @@ import (
 	"github.com/diamondburned/ningen/v3"
 )
 
-func (v *View) OpenState(token string) error {
+func (v *Model) OpenState(token string) error {
 	identifyProps := http.IdentifyProperties()
 	gateway.DefaultIdentity = identifyProps
 	gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
@@ -56,14 +56,14 @@ func (v *View) OpenState(token string) error {
 	return v.state.Open(context.Background())
 }
 
-func (v *View) CloseState() error {
+func (v *Model) CloseState() error {
 	if v.state == nil {
 		return nil
 	}
 	return v.state.Close()
 }
 
-func (v *View) onRequest(r httpdriver.Request) error {
+func (v *Model) onRequest(r httpdriver.Request) error {
 	if req, ok := r.(*httpdriver.DefaultRequest); ok {
 		slog.Debug("new HTTP request", "method", req.Method, "url", req.URL)
 	}
@@ -71,7 +71,7 @@ func (v *View) onRequest(r httpdriver.Request) error {
 	return nil
 }
 
-func (v *View) onRaw(event *ws.RawEvent) {
+func (v *Model) onRaw(event *ws.RawEvent) {
 	slog.Debug(
 		"new raw event",
 		"code", event.OriginalCode,
@@ -80,7 +80,7 @@ func (v *View) onRaw(event *ws.RawEvent) {
 	)
 }
 
-func (v *View) onReady(event *gateway.ReadyEvent) {
+func (v *Model) onReady(event *gateway.ReadyEvent) {
 	v.app.QueueUpdateDraw(func() {
 		// Rebuild indexes from scratch so reconnects and account switches do not
 		// retain pointers to detached tree nodes.
@@ -147,7 +147,7 @@ func (v *View) onReady(event *gateway.ReadyEvent) {
 	})
 }
 
-func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
+func (v *Model) onMessageCreate(message *gateway.MessageCreateEvent) {
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
 		v.removeTyper(message.Author.ID)
@@ -161,7 +161,7 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
 	}
 }
 
-func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) {
+func (v *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
 		index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
 			return m.ID == message.ID
@@ -176,7 +176,7 @@ func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) {
 	}
 }
 
-func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) {
+func (v *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
 		prevCursor := v.messagesList.Cursor()
 		deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
@@ -215,15 +215,15 @@ func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) {
 	}
 }
 
-func (v *View) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
+func (v *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
 	v.messagesList.setFetchingChunk(false, uint(len(event.Members)))
 }
 
-func (v *View) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
+func (v *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
 	v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit)
 }
 
-func (v *View) onTypingStart(event *gateway.TypingStartEvent) {
+func (v *Model) onTypingStart(event *gateway.TypingStartEvent) {
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel == nil {
 		return

+ 14 - 9
internal/ui/login/events.go

@@ -1,14 +1,19 @@
 package login
 
-import "github.com/gdamore/tcell/v3"
+import (
+	"log/slog"
 
-type TokenEvent struct {
-	tcell.EventTime
-	Token string
-}
+	"github.com/ayn2op/discordo/internal/clipboard"
+	"github.com/ayn2op/tview"
+	"github.com/gdamore/tcell/v3"
+)
 
-func NewTokenEvent(token string) *TokenEvent {
-	event := &TokenEvent{Token: token}
-	event.SetEventNow()
-	return event
+func setClipboard(content string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if err := clipboard.Write(clipboard.FmtText, []byte(content)); err != nil {
+			slog.Error("failed to copy error message", "err", err)
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
 }

+ 0 - 123
internal/ui/login/form.go

@@ -1,123 +0,0 @@
-package login
-
-import (
-	"errors"
-	"log/slog"
-
-	"github.com/ayn2op/tview/layers"
-	"github.com/gdamore/tcell/v3"
-
-	"github.com/ayn2op/discordo/internal/clipboard"
-	"github.com/ayn2op/discordo/internal/config"
-	"github.com/ayn2op/discordo/internal/keyring"
-	"github.com/ayn2op/discordo/internal/ui"
-	"github.com/ayn2op/tview"
-)
-
-const (
-	formLayerName  = "form"
-	errorLayerName = "error"
-	qrLayerName    = "qr"
-)
-
-type Form struct {
-	*layers.Layers
-	app  *tview.Application
-	cfg  *config.Config
-	form *tview.Form
-}
-
-func NewForm(app *tview.Application, cfg *config.Config) *Form {
-	f := &Form{
-		Layers: layers.New(),
-		app:    app,
-		cfg:    cfg,
-		form:   tview.NewForm(),
-	}
-
-	f.form.
-		AddPasswordField("Token", "", 0, 0, nil).
-		AddButton("Login", f.login).
-		AddButton("Login with QR", f.loginWithQR)
-	f.SetBackgroundLayerStyle(f.cfg.Theme.Dialog.BackgroundStyle.Style)
-	f.AddLayer(f.form, layers.WithName(formLayerName), layers.WithResize(true), layers.WithVisible(true))
-	return f
-}
-
-func (f *Form) login() {
-	token := f.form.GetFormItem(0).(*tview.InputField).GetText()
-	if token == "" {
-		f.onError(errors.New("token required"))
-		return
-	}
-
-	go keyring.SetToken(token)
-	f.app.QueueEvent(NewTokenEvent(token))
-}
-
-func (f *Form) onError(err error) {
-	slog.Error("failed to login", "err", err)
-
-	message := err.Error()
-	modal := tview.NewModal().
-		SetText(message).
-		AddButtons([]string{"Copy", "Close"}).
-		SetDoneFunc(func(buttonIndex int, _ string) {
-			if buttonIndex == 0 {
-				go func() {
-					if err := clipboard.Write(clipboard.FmtText, []byte(message)); err != nil {
-						slog.Error("failed to copy error message", "err", err)
-					}
-				}()
-			} else {
-				f.RemoveLayer(errorLayerName)
-			}
-		})
-	{
-		bg := f.cfg.Theme.Dialog.Style.GetBackground()
-		buttonStyle := f.cfg.Theme.Dialog.Style.Style
-		if bg != tcell.ColorDefault {
-			modal.SetBackgroundColor(bg)
-			buttonStyle = buttonStyle.Background(bg)
-		}
-		fg := f.cfg.Theme.Dialog.Style.GetForeground()
-		if fg != tcell.ColorDefault {
-			modal.SetTextColor(fg)
-			buttonStyle = buttonStyle.Foreground(fg)
-		}
-		// Keep button styles aligned with dialog content without hiding text.
-		modal.SetButtonStyle(buttonStyle)
-		modal.SetButtonActivatedStyle(buttonStyle)
-	}
-	f.
-		AddLayer(
-			ui.Centered(modal, 0, 0),
-			layers.WithName(errorLayerName),
-			layers.WithResize(true),
-			layers.WithVisible(true),
-			layers.WithOverlay(),
-		).
-		SendToFront(errorLayerName)
-}
-
-func (f *Form) loginWithQR() {
-	qr := newQRLogin(f.app, f.cfg, func(token string, err error) {
-		if err != nil {
-			f.onError(err)
-			return
-		}
-
-		if token == "" {
-			f.RemoveLayer(qrLayerName)
-			return
-		}
-
-		go keyring.SetToken(token)
-
-		f.RemoveLayer(qrLayerName)
-		f.app.QueueEvent(NewTokenEvent(token))
-	})
-
-	f.AddLayer(qr, layers.WithName(qrLayerName), layers.WithResize(true), layers.WithVisible(true))
-	qr.start()
-}

+ 16 - 0
internal/ui/login/keybinds.go

@@ -0,0 +1,16 @@
+package login
+
+import (
+	"github.com/ayn2op/tview/help"
+	"github.com/ayn2op/tview/keybind"
+)
+
+var _ help.KeyMap = (*Model)(nil)
+
+func (m *Model) ShortHelp() []keybind.Keybind {
+	return m.tabs.ShortHelp()
+}
+
+func (m *Model) FullHelp() [][]keybind.Keybind {
+	return m.tabs.FullHelp()
+}

+ 104 - 0
internal/ui/login/model.go

@@ -0,0 +1,104 @@
+package login
+
+import (
+	"log/slog"
+
+	"github.com/ayn2op/tview/layers"
+	"github.com/ayn2op/tview/tabs"
+	"github.com/gdamore/tcell/v3"
+
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/discordo/internal/ui/login/qr"
+	"github.com/ayn2op/discordo/internal/ui/login/token"
+	"github.com/ayn2op/tview"
+)
+
+const (
+	tabsLayerName  = "tabs"
+	errorLayerName = "error"
+)
+
+type Model struct {
+	*layers.Layers
+	tabs *tabs.Model
+	app  *tview.Application
+
+	cfg            *config.Config
+	errorModalText string
+}
+
+func NewModel(app *tview.Application, cfg *config.Config) *Model {
+	tabs := tabs.NewModel([]tabs.Tab{token.NewModel(), qr.NewModel(app)})
+
+	l := layers.New()
+	ui.ConfigureBox(l.Box, &cfg.Theme)
+	l.SetBackgroundLayerStyle(cfg.Theme.Dialog.BackgroundStyle.Style)
+	l.AddLayer(tabs, layers.WithName(tabsLayerName), layers.WithResize(true), layers.WithVisible(true))
+	return &Model{
+		Layers: l,
+		tabs:   tabs,
+		app:    app,
+
+		cfg: cfg,
+	}
+}
+
+func (m *Model) HandleEvent(event tcell.Event) tview.Command {
+	switch event := event.(type) {
+	case *tcell.EventError:
+		if m.HasLayer(errorLayerName) {
+			return tview.RedrawCommand{}
+		}
+		m.onError(event)
+		return tview.RedrawCommand{}
+	case *tview.ModalDoneEvent:
+		if !m.HasLayer(errorLayerName) {
+			return nil
+		}
+		if event.ButtonIndex == 0 {
+			return setClipboard(m.errorModalText)
+		}
+		m.RemoveLayer(errorLayerName)
+		m.errorModalText = ""
+		return tview.RedrawCommand{}
+	}
+	return m.Layers.HandleEvent(event)
+}
+
+func (m *Model) onError(err error) {
+	slog.Error("failed to login", "err", err)
+
+	message := err.Error()
+	m.errorModalText = message
+	modal := tview.NewModal().
+		SetText(message).
+		AddButtons([]string{"Copy", "Close"})
+	{
+		bg := m.cfg.Theme.Dialog.Style.GetBackground()
+		buttonStyle := m.cfg.Theme.Dialog.Style.Style
+		if bg != tcell.ColorDefault {
+			modal.SetBackgroundColor(bg)
+			buttonStyle = buttonStyle.Background(bg)
+		}
+		fg := m.cfg.Theme.Dialog.Style.GetForeground()
+		if fg != tcell.ColorDefault {
+			modal.SetTextColor(fg)
+			buttonStyle = buttonStyle.Foreground(fg)
+		}
+		// Keep button styles aligned with dialog content and still show focus.
+		modal.SetButtonStyle(buttonStyle)
+		modal.SetButtonActivatedStyle(buttonStyle.Reverse(true))
+	}
+	m.
+		AddLayer(
+			ui.Centered(modal, 0, 0),
+			layers.WithName(errorLayerName),
+			layers.WithResize(true),
+			layers.WithVisible(true),
+			layers.WithOverlay(),
+		).
+		SendToFront(errorLayerName)
+	modal.SetFocus(0)
+	m.app.SetFocus(modal)
+}

+ 0 - 453
internal/ui/login/qr.go

@@ -1,453 +0,0 @@
-package login
-
-import (
-	"context"
-	"crypto/rand"
-	"crypto/rsa"
-	"crypto/sha256"
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	stdhttp "net/http"
-	"strings"
-	"time"
-
-	"github.com/ayn2op/discordo/internal/config"
-	apphttp "github.com/ayn2op/discordo/internal/http"
-	"github.com/ayn2op/discordo/internal/ui"
-	"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"
-)
-
-const gatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
-
-type qrLogin struct {
-	*tview.TextView
-	app         *tview.Application
-	cfg         *config.Config
-	done        func(token string, err error)
-	conn        *websocket.Conn
-	privKey     *rsa.PrivateKey
-	cancel      context.CancelFunc
-	fingerprint string
-}
-
-func newQRLogin(app *tview.Application, cfg *config.Config, done func(token string, err error)) *qrLogin {
-	q := &qrLogin{
-		TextView: tview.NewTextView(),
-		app:      app,
-		cfg:      cfg,
-		done:     done,
-	}
-	q.Box = ui.ConfigureBox(q.Box, &cfg.Theme)
-
-	q.
-		SetScrollable(true).
-		SetWrap(false).
-		SetTextAlign(tview.AlignmentCenter).
-		SetChangedFunc(func() {
-			q.app.QueueUpdateDraw(func() {})
-		}).
-		SetTitle("Login with QR")
-
-	return q
-}
-
-func (q *qrLogin) HandleEvent(event tcell.Event) tview.Command {
-	switch event := event.(type) {
-	case *tview.KeyEvent:
-		if event.Key() == tcell.KeyEsc {
-			q.stop()
-			if q.done != nil {
-				q.done("", nil)
-			}
-			return tview.RedrawCommand{}
-		}
-		return q.TextView.HandleEvent(event)
-	}
-	return q.TextView.HandleEvent(event)
-}
-
-func (q *qrLogin) start() {
-	ctx, cancel := context.WithCancel(context.Background())
-	q.cancel = cancel
-	go q.run(ctx)
-}
-
-func (q *qrLogin) stop() {
-	if q.cancel != nil {
-		q.cancel()
-	}
-	if q.conn != nil {
-		q.conn.Close()
-	}
-}
-
-func (q *qrLogin) setText(s string) {
-	builder := tview.NewLineBuilder()
-	builder.Write(s, tcell.StyleDefault)
-	q.setLines(builder.Finish())
-}
-
-func (q *qrLogin) setLines(lines []tview.Line) {
-	q.app.QueueUpdateDraw(func() {
-		q.SetLines(q.centerLines(lines))
-	})
-}
-
-func (q *qrLogin) centerLines(lines []tview.Line) []tview.Line {
-	_, _, _, height := q.GetInnerRect()
-	if height == 0 {
-		height = 40
-	}
-	padding := (height - len(lines)) / 2
-	if padding < 0 {
-		padding = 0
-	} else if padding < 1 && height > len(lines) {
-		padding = 1
-	}
-	if padding == 0 {
-		return lines
-	}
-
-	centered := make([]tview.Line, 0, padding+len(lines))
-	centered = append(centered, make([]tview.Line, padding)...)
-	centered = append(centered, lines...)
-	return centered
-}
-
-func (q *qrLogin) writeJSON(data any) error {
-	return q.conn.WriteJSON(data)
-}
-
-type raHello struct {
-	TimeoutMs         int `json:"timeout_ms"`
-	HeartbeatInterval int `json:"heartbeat_interval"`
-}
-
-type raNonceProof struct {
-	EncryptedNonce string `json:"encrypted_nonce"`
-}
-
-type raPendingInit struct {
-	Fingerprint string `json:"fingerprint"`
-}
-
-type raPendingLogin struct {
-	Ticket string `json:"ticket"`
-}
-
-type raPendingTicket struct {
-	EncryptedUserPayload string `json:"encrypted_user_payload"`
-}
-
-func (q *qrLogin) run(ctx context.Context) {
-	defer q.stop()
-	q.setText("Preparing QR code...\n\nPress Esc to cancel")
-
-	privKey, err := rsa.GenerateKey(rand.Reader, 2048)
-	if err != nil {
-		q.fail(err)
-		return
-	}
-	q.privKey = privKey
-
-	pubDER, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
-	if err != nil {
-		q.fail(err)
-		return
-	}
-	encodedPublicKey := base64.StdEncoding.EncodeToString(pubDER)
-
-	q.setText("Connecting to Remote Auth Gateway...\n\nPress Esc to cancel")
-
-	headers := apphttp.Headers()
-	headers.Set("User-Agent", apphttp.BrowserUserAgent)
-
-	dialer := websocket.Dialer{
-		Proxy:             stdhttp.ProxyFromEnvironment,
-		HandshakeTimeout:  15 * time.Second,
-		EnableCompression: true,
-	}
-
-	conn, resp, err := dialer.DialContext(ctx, gatewayURL, headers)
-	if err != nil {
-		var body []byte
-		if resp != nil && resp.Body != nil {
-			body, _ = io.ReadAll(resp.Body)
-		}
-		status := ""
-		if resp != nil {
-			status = resp.Status
-		}
-		q.fail(fmt.Errorf("websocket dial failed: %w, status=%s, body=%s", err, status, string(body)))
-		return
-	}
-	q.conn = conn
-
-	readCh := make(chan []byte, 1)
-	readErr := make(chan error, 1)
-	go func() {
-		for {
-			_, data, err := conn.ReadMessage()
-			if err != nil {
-				readErr <- err
-				return
-			}
-			readCh <- data
-		}
-	}()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return
-		case err := <-readErr:
-			if mapped := mapWSCloseError(err); mapped == nil {
-				return
-			} else {
-				q.fail(mapped)
-			}
-			return
-		case data := <-readCh:
-			var opOnly struct {
-				Op string `json:"op"`
-			}
-			if err := json.Unmarshal(data, &opOnly); err != nil {
-				q.setText("Bad JSON: " + err.Error())
-				q.fail(err)
-				return
-			}
-
-			switch opOnly.Op {
-			case "hello":
-				var h raHello
-				if err := json.Unmarshal(data, &h); err != nil {
-					q.setText("Hello decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				if h.HeartbeatInterval > 0 {
-					heartbeatTicker := time.NewTicker(time.Duration(h.HeartbeatInterval) * time.Millisecond)
-					go func() {
-						defer heartbeatTicker.Stop()
-						for {
-							select {
-							case <-ctx.Done():
-								return
-							case <-heartbeatTicker.C:
-								q.writeJSON(map[string]any{"op": "heartbeat"})
-							}
-						}
-					}()
-				}
-				q.setText("Connected. Handshaking...\n\nPress Esc to cancel")
-				if err := q.writeJSON(map[string]any{
-					"op":                 "init",
-					"encoded_public_key": encodedPublicKey,
-				}); err != nil {
-					q.setText("Init send failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-			case "nonce_proof":
-				var n raNonceProof
-				if err := json.Unmarshal(data, &n); err != nil {
-					q.setText("Nonce decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				enc, err := base64.StdEncoding.DecodeString(n.EncryptedNonce)
-				if err != nil {
-					q.setText("Nonce b64 decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, enc, nil)
-				if err != nil {
-					q.setText("Nonce decrypt failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				nonce := base64.RawURLEncoding.EncodeToString(pt)
-				if err := q.writeJSON(map[string]any{"op": "nonce_proof", "nonce": nonce}); err != nil {
-					q.setText("Nonce send failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-			case "pending_remote_init":
-				var p raPendingInit
-				if err := json.Unmarshal(data, &p); err != nil {
-					q.setText("Init decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				q.fingerprint = p.Fingerprint
-				content := "https://discord.com/ra/" + p.Fingerprint
-				qrLines, err := renderQR(content)
-				if err != nil {
-					q.setText("QR render failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				builder := tview.NewLineBuilder()
-				builder.AppendLines(qrLines)
-				builder.Write("\n\nScan with Discord mobile app\n\nPress Esc to cancel", tcell.StyleDefault)
-				q.setLines(builder.Finish())
-			case "heartbeat_ack":
-			case "pending_ticket":
-				var t raPendingTicket
-				if err := json.Unmarshal(data, &t); err != nil {
-					q.setText("Ticket decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				payload, err := base64.StdEncoding.DecodeString(t.EncryptedUserPayload)
-				if err != nil {
-					q.setText("Ticket payload b64 failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				pt, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, q.privKey, payload, nil)
-				if err != nil {
-					q.setText("Ticket payload decrypt failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				parts := strings.SplitN(string(pt), ":", 4)
-				var discriminator, username string
-				if len(parts) == 4 {
-					discriminator = parts[1]
-					username = parts[3]
-				}
-				if discriminator == "" && username == "" {
-					q.setText("Scan received.\n\nWaiting for approval on mobile...\n\nPress Esc to cancel")
-				} else {
-					q.setText("Logging in as " + username + "#" + discriminator + "\n\nConfirm on mobile...\n\nPress Esc to cancel")
-				}
-			case "pending_login":
-				var p raPendingLogin
-				if err := json.Unmarshal(data, &p); err != nil {
-					q.setText("Login decode failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				q.setText("Authenticating...\n\nPlease wait")
-				token, err := exchangeTicket(p.Ticket, q.fingerprint, q.privKey)
-				if err != nil {
-					q.setText("Ticket exchange failed: " + err.Error())
-					q.fail(err)
-					return
-				}
-				q.success(token)
-				return
-			case "cancel":
-				q.setText("Login canceled on mobile")
-				if q.done != nil {
-					q.done("", nil)
-				}
-				return
-			default:
-			}
-		}
-	}
-}
-
-func renderQR(content string) ([]tview.Line, error) {
-	code, err := qrcode.New(content, qrcode.Low)
-	if err != nil {
-		return nil, err
-	}
-	bitmap := code.Bitmap()
-	builder := tview.NewLineBuilder()
-	style := tcell.StyleDefault
-	for y := 0; y < len(bitmap); y += 2 {
-		for x := range bitmap[y] {
-			top := bitmap[y][x]
-			bottom := false
-			if y+1 < len(bitmap) {
-				bottom = bitmap[y+1][x]
-			}
-			if top && bottom {
-				builder.Write("█", style)
-			} else if top && !bottom {
-				builder.Write("▀", style)
-			} else if !top && bottom {
-				builder.Write("▄", style)
-			} else {
-				builder.Write(" ", style)
-			}
-		}
-		builder.NewLine()
-	}
-	return builder.Finish(), nil
-}
-
-func exchangeTicket(ticket string, fingerprint string, priv *rsa.PrivateKey) (string, error) {
-	if ticket == "" {
-		return "", errors.New("empty ticket")
-	}
-
-	headers := apphttp.Headers()
-	if fingerprint != "" {
-		headers.Set("X-Fingerprint", fingerprint)
-		headers.Set("Referer", "https://discord.com/ra/"+fingerprint)
-	}
-
-	// Create an API client without a token.
-	client := apphttp.NewClient("")
-	client.OnRequest = append(client.OnRequest, httputil.WithHeaders(headers))
-
-	token, err := client.ExchangeRemoteAuthTicket(ticket)
-	if err != nil {
-		return "", err
-	}
-
-	decodedToken, err := base64.StdEncoding.DecodeString(token)
-	if err != nil {
-		return "", err
-	}
-	decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, priv, decodedToken, nil)
-	if err != nil {
-		return "", err
-	}
-	return string(decryptedToken), nil
-}
-
-func (q *qrLogin) success(token string) {
-	if q.done != nil {
-		q.done(token, nil)
-	}
-}
-
-func (q *qrLogin) fail(err error) {
-	slog.Error("qr login failed", "err", err)
-	if q.done != nil {
-		q.done("", err)
-	}
-}
-
-func mapWSCloseError(err error) error {
-	if err, ok := errors.AsType[*websocket.CloseError](err); ok {
-		switch err.Code {
-		case 1000:
-			return errors.New("session closed")
-		case 4000:
-			return errors.New("remote auth: invalid version")
-		case 4001:
-			return errors.New("remote auth: decode error")
-		case 4002:
-			return errors.New("remote auth: handshake failure")
-		case 4003:
-			return errors.New("remote auth: session timed out")
-		}
-	}
-	return err
-}

+ 388 - 0
internal/ui/login/qr/events.go

@@ -0,0 +1,388 @@
+package qr
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"strings"
+	"time"
+
+	"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 TokenEvent struct {
+	tcell.EventTime
+	Token string
+}
+
+func newTokenEvent(token string) *TokenEvent {
+	event := &TokenEvent{Token: token}
+	event.SetEventNow()
+	return event
+}
+
+const remoteAuthGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
+
+type connCreateEvent struct {
+	tcell.EventTime
+	conn *websocket.Conn
+}
+
+func newConnCreateEvent(conn *websocket.Conn) *connCreateEvent {
+	event := &connCreateEvent{conn: conn}
+	event.SetEventNow()
+	return event
+}
+
+type connCloseEvent struct{ tcell.EventTime }
+
+func newConnCloseEvent() *connCloseEvent {
+	event := &connCloseEvent{}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) connect() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		headers := http.Headers()
+		headers.Set("User-Agent", http.BrowserUserAgent)
+		conn, _, err := websocket.DefaultDialer.Dial(remoteAuthGatewayURL, headers)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+		return newConnCreateEvent(conn)
+	})
+}
+
+func (m *Model) close() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if m.conn != nil {
+			if err := m.conn.Close(); err != nil {
+				return tcell.NewEventError(err)
+			}
+		}
+		return newConnCloseEvent()
+	})
+}
+
+type helloEvent struct {
+	tcell.EventTime
+	heartbeatInterval int
+	timeoutMS         int
+}
+
+func newHelloEvent(heartbeatInterval, timeoutMS int) *helloEvent {
+	event := &helloEvent{heartbeatInterval: heartbeatInterval, timeoutMS: timeoutMS}
+	event.SetEventNow()
+	return event
+}
+
+type nonceProofEvent struct {
+	tcell.EventTime
+	encryptedNonce string
+}
+
+func newNonceProofEvent(encryptedNonce string) *nonceProofEvent {
+	event := &nonceProofEvent{encryptedNonce: encryptedNonce}
+	event.SetEventNow()
+	return event
+}
+
+type pendingRemoteInitEvent struct {
+	tcell.EventTime
+	fingerprint string
+}
+
+func newPendingRemoteInitEvent(fingerprint string) *pendingRemoteInitEvent {
+	event := &pendingRemoteInitEvent{fingerprint: fingerprint}
+	event.SetEventNow()
+	return event
+}
+
+type pendingTicketEvent struct {
+	tcell.EventTime
+	encryptedUserPayload string
+}
+
+func newPendingTicketEvent(encryptedUserPayload string) *pendingTicketEvent {
+	event := &pendingTicketEvent{encryptedUserPayload: encryptedUserPayload}
+	event.SetEventNow()
+	return event
+}
+
+type pendingLoginEvent struct {
+	tcell.EventTime
+	ticket string
+}
+
+func newPendingLoginEvent(ticket string) *pendingLoginEvent {
+	event := &pendingLoginEvent{ticket: ticket}
+	event.SetEventNow()
+	return event
+}
+
+type cancelEvent struct{ tcell.EventTime }
+
+func newCancelEvent() *cancelEvent {
+	event := &cancelEvent{}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) listen() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if m.conn == nil {
+			return nil
+		}
+
+		_, data, err := m.conn.ReadMessage()
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		var payload struct {
+			Op string `json:"op"`
+		}
+		if err := json.Unmarshal(data, &payload); err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		switch payload.Op {
+		case "hello":
+			var payload struct {
+				HeartbeatInterval int `json:"heartbeat_interval"`
+				TimeoutMS         int `json:"timeout_ms"`
+			}
+			if err := json.Unmarshal(data, &payload); err != nil {
+				return tcell.NewEventError(err)
+			}
+			return newHelloEvent(payload.HeartbeatInterval, payload.TimeoutMS)
+		case "nonce_proof":
+			var payload struct {
+				EncryptedNonce string `json:"encrypted_nonce"`
+			}
+			if err := json.Unmarshal(data, &payload); err != nil {
+				return tcell.NewEventError(err)
+			}
+			return newNonceProofEvent(payload.EncryptedNonce)
+		case "pending_remote_init":
+			var payload struct {
+				Fingerprint string `json:"fingerprint"`
+			}
+			if err := json.Unmarshal(data, &payload); err != nil {
+				return tcell.NewEventError(err)
+			}
+			return newPendingRemoteInitEvent(payload.Fingerprint)
+		case "pending_ticket":
+			var payload struct {
+				EncryptedUserPayload string `json:"encrypted_user_payload"`
+			}
+			if err := json.Unmarshal(data, &payload); err != nil {
+				return tcell.NewEventError(err)
+			}
+			return newPendingTicketEvent(payload.EncryptedUserPayload)
+		case "cancel":
+			return newCancelEvent()
+		case "pending_login":
+			var payload struct {
+				Ticket string `json:"ticket"`
+			}
+			if err := json.Unmarshal(data, &payload); err != nil {
+				return tcell.NewEventError(err)
+			}
+			return newPendingLoginEvent(payload.Ticket)
+		default:
+			return nil
+		}
+	})
+}
+
+type heartbeatTickEvent struct{ tcell.EventTime }
+
+func newHeartbeatTickEvent() *heartbeatTickEvent {
+	event := &heartbeatTickEvent{}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) heartbeat() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		time.Sleep(m.heartbeatInterval)
+		return newHeartbeatTickEvent()
+	})
+}
+
+func (m *Model) sendHeartbeat() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if m.conn == nil {
+			return nil
+		}
+		data := struct {
+			Op string `json:"op"`
+		}{"heartbeat"}
+		if err := m.conn.WriteJSON(data); err != nil {
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
+}
+
+type privateKeyEvent struct {
+	tcell.EventTime
+	privateKey *rsa.PrivateKey
+}
+
+func newPrivateKeyEvent(privateKey *rsa.PrivateKey) *privateKeyEvent {
+	event := &privateKeyEvent{privateKey: privateKey}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) generatePrivateKey() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+		return newPrivateKeyEvent(privateKey)
+	})
+}
+
+func (m *Model) sendInit() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if m.privateKey == nil {
+			return tcell.NewEventError(errors.New("missing private key"))
+		}
+		spki, err := x509.MarshalPKIXPublicKey(m.privateKey.Public())
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+		encodedPublicKey := base64.StdEncoding.EncodeToString(spki)
+		data := struct {
+			Op               string `json:"op"`
+			EncodedPublicKey string `json:"encoded_public_key"`
+		}{"init", encodedPublicKey}
+		if err := m.conn.WriteJSON(data); err != nil {
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
+}
+
+func (m *Model) sendNonceProof(encryptedNonce string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		decodedNonce, err := base64.StdEncoding.DecodeString(encryptedNonce)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		decryptedNonce, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedNonce, nil)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		encodedNonce := base64.RawURLEncoding.EncodeToString(decryptedNonce)
+		data := struct {
+			Op    string `json:"op"`
+			Nonce string `json:"nonce"`
+		}{"nonce_proof", encodedNonce}
+		if err := m.conn.WriteJSON(data); err != nil {
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
+}
+
+type qrCodeEvent struct {
+	tcell.EventTime
+	qrCode *qrcode.QRCode
+}
+
+func newQRCodeEvent(qrCode *qrcode.QRCode) *qrCodeEvent {
+	event := &qrCodeEvent{qrCode: qrCode}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) generateQRCode(fingerprint string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		content := "https://discord.com/ra/" + fingerprint
+		qrCode, err := qrcode.New(content, qrcode.Low)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+		qrCode.DisableBorder = true
+		return newQRCodeEvent(qrCode)
+	})
+}
+
+type userEvent struct {
+	tcell.EventTime
+	discriminator string
+	username      string
+}
+
+func newUserEvent(discriminator, username string) *userEvent {
+	event := &userEvent{discriminator: discriminator, username: username}
+	event.SetEventNow()
+	return event
+}
+
+func (m *Model) decryptUserPayload(encryptedPayload string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		decodedPayload, err := base64.StdEncoding.DecodeString(encryptedPayload)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		decryptedPayload, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedPayload, nil)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		parts := strings.Split(string(decryptedPayload), ":")
+		if len(parts) != 4 {
+			return tcell.NewEventError(errors.New("invalid user payload"))
+		}
+
+		return newUserEvent(parts[1], parts[3])
+	})
+}
+
+func (m *Model) exchangeTicket(ticket string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		headers := http.Headers()
+		headers.Set("Referer", "https://discord.com/login")
+		if m.fingerprint != "" {
+			headers.Set("X-Fingerprint", m.fingerprint)
+		}
+
+		client := http.NewClient("")
+		client.OnRequest = append(client.OnRequest, httputil.WithHeaders(headers))
+
+		encryptedToken, err := client.ExchangeRemoteAuthTicket(ticket)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		decodedToken, err := base64.StdEncoding.DecodeString(encryptedToken)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+
+		decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedToken, nil)
+		if err != nil {
+			return tcell.NewEventError(err)
+		}
+		return newTokenEvent(string(decryptedToken))
+	})
+}

+ 172 - 0
internal/ui/login/qr/model.go

@@ -0,0 +1,172 @@
+package qr
+
+import (
+	"crypto/rsa"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/tabs"
+	"github.com/gdamore/tcell/v3"
+	"github.com/gorilla/websocket"
+	"github.com/skip2/go-qrcode"
+)
+
+type Model struct {
+	*tview.TextView
+	app *tview.Application
+
+	conn              *websocket.Conn
+	heartbeatInterval time.Duration
+	privateKey        *rsa.PrivateKey
+	fingerprint       string
+
+	qrCode *qrcode.QRCode
+	msg    string
+}
+
+func NewModel(app *tview.Application) *Model {
+	m := &Model{
+		TextView: tview.NewTextView(),
+		app:      app,
+	}
+	m.
+		SetScrollable(true).
+		SetWrap(false).
+		SetTextAlign(tview.AlignmentCenter).
+		SetChangedFunc(func() {
+			m.app.QueueUpdateDraw(func() {})
+		})
+
+	m.msg = "Press Ctrl+N to open QR login"
+	return m
+}
+
+var _ tabs.Tab = (*Model)(nil)
+
+func (m *Model) Label() string {
+	return "QR"
+}
+
+func (m *Model) HandleEvent(event tcell.Event) tview.Command {
+	switch event := event.(type) {
+	case *tview.InitEvent:
+		m.msg = "Connecting to Remote Auth Gateway..."
+		return m.connect()
+	case *tview.KeyEvent:
+		if event.Key() == tcell.KeyEsc {
+			m.msg = "Canceled"
+			return tview.BatchCommand{m.close(), tview.RedrawCommand{}}
+		}
+		return m.TextView.HandleEvent(event)
+
+	case *connCreateEvent:
+		m.conn = event.conn
+		m.msg = "Connected. Handshaking..."
+		return m.listen()
+	case *connCloseEvent:
+		m.conn = nil
+		return nil
+
+	case *helloEvent:
+		m.heartbeatInterval = time.Duration(event.heartbeatInterval) * time.Millisecond
+		return tview.BatchCommand{m.listen(), m.heartbeat(), m.generatePrivateKey()}
+	case *privateKeyEvent:
+		m.privateKey = event.privateKey
+		return tview.BatchCommand{m.listen(), m.sendInit()}
+	case *nonceProofEvent:
+		return tview.BatchCommand{m.listen(), m.sendNonceProof(event.encryptedNonce)}
+	case *pendingRemoteInitEvent:
+		m.fingerprint = event.fingerprint
+		return tview.BatchCommand{m.listen(), m.generateQRCode(event.fingerprint)}
+	case *qrCodeEvent:
+		m.qrCode = event.qrCode
+		m.msg = "Scan this with the Discord mobile app to log in instantly."
+		return m.listen()
+	case *pendingTicketEvent:
+		return tview.BatchCommand{m.listen(), m.decryptUserPayload(event.encryptedUserPayload)}
+	case *userEvent:
+		name := event.username
+		if event.discriminator != "0" {
+			name += "#" + event.discriminator
+		}
+		m.msg = fmt.Sprintf("Check your phone! Logging in as %s", name)
+		return m.listen()
+	case *pendingLoginEvent:
+		m.msg = "Authenticating..."
+		return tview.BatchCommand{m.close(), m.exchangeTicket(event.ticket)}
+	case *cancelEvent:
+		m.msg = "Login canceled on mobile"
+		return m.close()
+
+	case *heartbeatTickEvent:
+		if m.conn == nil {
+			return nil
+		}
+		return tview.BatchCommand{m.heartbeat(), m.sendHeartbeat()}
+
+	case *tcell.EventError:
+		m.msg = event.Error()
+		return tview.BatchCommand{m.close(), event}
+	}
+
+	return nil
+}
+
+func (m *Model) Draw(screen tcell.Screen) {
+	var contents []string
+	if m.qrCode != nil {
+		bitmap := m.qrCode.Bitmap()
+		var b strings.Builder
+		for y := 0; y < len(bitmap); y += 2 {
+			for x := range bitmap[y] {
+				top := bitmap[y][x]
+				bottom := false
+				if y+1 < len(bitmap) {
+					bottom = bitmap[y+1][x]
+				}
+				if top && bottom {
+					b.WriteString("█")
+				} else if top && !bottom {
+					b.WriteString("▀")
+				} else if !top && bottom {
+					b.WriteString("▄")
+				} else {
+					b.WriteByte(' ')
+				}
+			}
+			b.WriteByte('\n')
+		}
+		contents = append(contents, b.String())
+	}
+	if m.msg != "" {
+		contents = append(contents, m.msg)
+	}
+
+	builder := tview.NewLineBuilder()
+	builder.Write(strings.Join(contents, "\n"), tcell.StyleDefault)
+	m.SetLines(m.centerLines(builder.Finish()))
+	m.TextView.Draw(screen)
+}
+
+func (m *Model) centerLines(lines []tview.Line) []tview.Line {
+	_, _, _, height := m.GetInnerRect()
+	if height == 0 {
+		height = 40
+	}
+	padding := (height - len(lines)) / 2
+	if padding < 0 {
+		padding = 0
+	} else if padding < 1 && height > len(lines) {
+		padding = 1
+	}
+	if padding == 0 {
+		return lines
+	}
+
+	centered := make([]tview.Line, 0, padding+len(lines))
+	centered = append(centered, make([]tview.Line, padding)...)
+	centered = append(centered, lines...)
+	return centered
+}

+ 23 - 0
internal/ui/login/token/events.go

@@ -0,0 +1,23 @@
+package token
+
+import (
+	"github.com/ayn2op/tview"
+	"github.com/gdamore/tcell/v3"
+)
+
+type TokenEvent struct {
+	tcell.EventTime
+	Token string
+}
+
+func newTokenEvent(token string) *TokenEvent {
+	event := &TokenEvent{Token: token}
+	event.SetEventNow()
+	return event
+}
+
+func tokenCommand(token string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		return newTokenEvent(token)
+	})
+}

+ 36 - 0
internal/ui/login/token/model.go

@@ -0,0 +1,36 @@
+package token
+
+import (
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/tabs"
+	"github.com/gdamore/tcell/v3"
+)
+
+type Model struct {
+	*tview.Form
+}
+
+func NewModel() *Model {
+	form := tview.NewForm().
+		AddPasswordField("Token", "", 0, 0, nil).
+		AddButton("Login")
+	return &Model{Form: form}
+}
+
+var _ tabs.Tab = (*Model)(nil)
+
+func (m *Model) Label() string {
+	return "Token"
+}
+
+func (m *Model) HandleEvent(event tcell.Event) tview.Command {
+	switch event.(type) {
+	case *tview.FormSubmitEvent:
+		token := m.GetFormItem(0).(*tview.InputField).GetText()
+		if token == "" {
+			return nil
+		}
+		return tokenCommand(token)
+	}
+	return m.Form.HandleEvent(event)
+}

+ 47 - 20
internal/ui/root/events.go

@@ -2,10 +2,10 @@ package root
 
 import (
 	"log/slog"
-	"os"
 
 	"github.com/ayn2op/discordo/internal/clipboard"
 	"github.com/ayn2op/discordo/internal/keyring"
+	"github.com/ayn2op/tview"
 	"github.com/gdamore/tcell/v3"
 )
 
@@ -20,30 +20,57 @@ func newTokenEvent(token string) *tokenEvent {
 	return event
 }
 
-func getToken() tcell.Event {
-	token := os.Getenv(tokenEnvVarKey)
-	if token == "" {
-		tok, err := keyring.GetToken()
+func tokenCommand(token string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		return newTokenEvent(token)
+	})
+}
+
+type loginEvent struct{ tcell.EventTime }
+
+func newLoginEvent() *loginEvent {
+	event := &loginEvent{}
+	event.SetEventNow()
+	return event
+}
+
+func getToken() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		token, err := keyring.GetToken()
 		if err != nil {
 			slog.Info("failed to retrieve token from keyring", "err", err)
+			return newLoginEvent()
+		}
+		return newTokenEvent(token)
+	})
+}
+
+func setToken(token string) tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if err := keyring.SetToken(token); err != nil {
+			slog.Error("failed to set token to keyring", "err", err)
+			return tcell.NewEventError(err)
 		}
-		token = tok
-	}
-	return newTokenEvent(token)
+		return nil
+	})
 }
 
-func deleteToken() tcell.Event {
-	if err := keyring.DeleteToken(); err != nil {
-		slog.Error("failed to delete token from keyring", "err", err)
-		return tcell.NewEventError(err)
-	}
-	return nil
+func deleteToken() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if err := keyring.DeleteToken(); err != nil {
+			slog.Error("failed to delete token from keyring", "err", err)
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
 }
 
-func initClipboard() tcell.Event {
-	if err := clipboard.Init(); err != nil {
-		slog.Error("failed to init clipboard", "err", err)
-		return tcell.NewEventError(err)
-	}
-	return nil
+func initClipboard() tview.Command {
+	return tview.EventCommand(func() tcell.Event {
+		if err := clipboard.Init(); err != nil {
+			slog.Error("failed to init clipboard", "err", err)
+			return tcell.NewEventError(err)
+		}
+		return nil
+	})
 }

+ 13 - 13
internal/ui/root/keybinds.go

@@ -5,36 +5,36 @@ import (
 	"github.com/ayn2op/tview/keybind"
 )
 
-var _ help.KeyMap = (*View)(nil)
+var _ help.KeyMap = (*Model)(nil)
 
-func (v *View) ShortHelp() []keybind.Keybind {
+func (m *Model) ShortHelp() []keybind.Keybind {
 	global := []keybind.Keybind{
-		v.cfg.Keybinds.ToggleHelp.Keybind,
-		v.cfg.Keybinds.Suspend.Keybind,
-		v.cfg.Keybinds.Quit.Keybind,
+		m.cfg.Keybinds.ToggleHelp.Keybind,
+		m.cfg.Keybinds.Suspend.Keybind,
+		m.cfg.Keybinds.Quit.Keybind,
 	}
-	if active := v.activeKeyMap(); active != nil {
+	if active := m.activeKeyMap(); active != nil {
 		short := active.ShortHelp()
 		return append(short, global...)
 	}
 	return global
 }
 
-func (v *View) FullHelp() [][]keybind.Keybind {
+func (m *Model) FullHelp() [][]keybind.Keybind {
 	global := []keybind.Keybind{
-		v.cfg.Keybinds.ToggleHelp.Keybind,
-		v.cfg.Keybinds.Suspend.Keybind,
-		v.cfg.Keybinds.Quit.Keybind,
+		m.cfg.Keybinds.ToggleHelp.Keybind,
+		m.cfg.Keybinds.Suspend.Keybind,
+		m.cfg.Keybinds.Quit.Keybind,
 	}
-	if active := v.activeKeyMap(); active != nil {
+	if active := m.activeKeyMap(); active != nil {
 		full := active.FullHelp()
 		return append(full, global)
 	}
 	return [][]keybind.Keybind{global}
 }
 
-func (v *View) activeKeyMap() help.KeyMap {
-	if keyMap, ok := v.inner.(help.KeyMap); ok {
+func (m *Model) activeKeyMap() help.KeyMap {
+	if keyMap, ok := m.inner.(help.KeyMap); ok {
 		return keyMap
 	}
 	return nil

+ 170 - 0
internal/ui/root/model.go

@@ -0,0 +1,170 @@
+package root
+
+import (
+	"os"
+
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/consts"
+	"github.com/ayn2op/discordo/internal/ui/chat"
+	"github.com/ayn2op/discordo/internal/ui/login"
+	"github.com/ayn2op/discordo/internal/ui/login/qr"
+	"github.com/ayn2op/discordo/internal/ui/login/token"
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/help"
+	"github.com/ayn2op/tview/keybind"
+	"github.com/gdamore/tcell/v3"
+)
+
+const tokenEnvVarKey = "DISCORDO_TOKEN"
+
+type Model struct {
+	app      *tview.Application
+	rootFlex *tview.Flex // inner + help
+	inner    tview.Primitive
+	help     *help.Help
+
+	cfg *config.Config
+}
+
+func NewModel(cfg *config.Config, app *tview.Application) *Model {
+	m := &Model{
+		app:      app,
+		rootFlex: tview.NewFlex(),
+		help:     help.New(),
+
+		cfg: cfg,
+	}
+
+	m.rootFlex.SetDirection(tview.FlexRow)
+
+	styles := help.DefaultStyles()
+	styles.ShortKeyStyle = cfg.Theme.Help.ShortKeyStyle.Style
+	styles.ShortDescStyle = cfg.Theme.Help.ShortDescStyle.Style
+	styles.FullKeyStyle = cfg.Theme.Help.FullKeyStyle.Style
+	styles.FullDescStyle = cfg.Theme.Help.FullDescStyle.Style
+	m.help.SetStyles(styles)
+
+	m.help.SetKeyMap(m)
+	m.help.SetCompactModifiers(cfg.Help.CompactModifiers)
+	m.help.SetShortSeparator(cfg.Help.Separator)
+	m.help.SetBorderPadding(0, 0, cfg.Help.Padding[0], cfg.Help.Padding[1])
+	m.buildLayout()
+	return m
+}
+
+func (m *Model) showLogin() tview.Command {
+	m.inner = login.NewModel(m.app, m.cfg)
+	m.buildLayout()
+	return tview.BatchCommand{m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocusCommand{Target: m}}
+}
+
+func (m *Model) showChat(token string) tview.Command {
+	m.inner = chat.NewView(m.app, m.cfg, token)
+	m.buildLayout()
+	return tview.BatchCommand{m.inner.HandleEvent(tview.NewInitEvent()), tview.SetFocusCommand{Target: m}}
+}
+
+func (m *Model) buildLayout() {
+	m.rootFlex.Clear()
+	if m.inner != nil {
+		m.rootFlex.AddItem(m.inner, 0, 1, true)
+	}
+	m.rootFlex.AddItem(m.help, 1, 0, false)
+	m.updateHelpHeight()
+}
+
+var _ tview.Primitive = (*Model)(nil)
+
+func (m *Model) Draw(screen tcell.Screen) {
+	m.rootFlex.Draw(screen)
+}
+
+func (m *Model) HandleEvent(event tcell.Event) tview.Command {
+	switch event := event.(type) {
+	case *tview.InitEvent:
+		var cmd tview.Command
+		if token := os.Getenv(tokenEnvVarKey); token != "" {
+			cmd = tokenCommand(token)
+		} else {
+			cmd = getToken()
+		}
+		return tview.BatchCommand{
+			tview.SetTitleCommand(consts.Name),
+			initClipboard(),
+			cmd,
+		}
+
+	case *loginEvent:
+		return m.showLogin()
+	case *tokenEvent:
+		return m.showChat(event.token)
+
+	case *token.TokenEvent:
+		return tview.BatchCommand{m.showChat(event.Token), setToken(event.Token)}
+	case *qr.TokenEvent:
+		return tview.BatchCommand{m.showChat(event.Token), setToken(event.Token)}
+
+	case *chat.LogoutEvent:
+		return tview.BatchCommand{
+			m.showLogin(),
+			deleteToken(),
+		}
+
+	case *tview.KeyEvent:
+		switch {
+		case keybind.Matches(event, m.cfg.Keybinds.ToggleHelp.Keybind):
+			m.help.SetShowAll(!m.help.ShowAll())
+			m.updateHelpHeight()
+			return tview.RedrawCommand{}
+		case keybind.Matches(event, m.cfg.Keybinds.Suspend.Keybind):
+			m.suspend()
+			return nil
+		case keybind.Matches(event, m.cfg.Keybinds.Quit.Keybind):
+			var innerCmd tview.Command
+			if m.inner != nil {
+				innerCmd = m.inner.HandleEvent(chat.NewQuitEvent())
+			}
+			return tview.BatchCommand{innerCmd, tview.Quit()}
+		}
+	}
+
+	if m.inner != nil {
+		return m.inner.HandleEvent(event)
+	}
+	return nil
+}
+
+func (m *Model) updateHelpHeight() {
+	height := 1
+	if m.help.ShowAll() {
+		height = max(len(m.help.FullHelpLines(m.FullHelp(), 0)), 1)
+	}
+	m.rootFlex.ResizeItem(m.help, height, 0)
+}
+
+func (m *Model) GetRect() (int, int, int, int) {
+	return m.rootFlex.GetRect()
+}
+
+func (m *Model) SetRect(x int, y int, width int, height int) {
+	m.rootFlex.SetRect(x, y, width, height)
+}
+
+func (m *Model) Focus(delegate func(p tview.Primitive)) {
+	if m.inner != nil {
+		delegate(m.inner)
+	}
+}
+
+func (m *Model) HasFocus() bool {
+	if m.inner != nil {
+		return m.inner.HasFocus()
+	}
+	return true
+}
+
+func (m *Model) Blur() {
+	if m.inner != nil {
+		m.inner.Blur()
+	}
+}

+ 1 - 1
internal/ui/root/suspend_default.go

@@ -2,4 +2,4 @@
 
 package root
 
-func (v *View) suspend() {}
+func (m *Model) suspend() {}

+ 2 - 2
internal/ui/root/suspend_unix.go

@@ -8,8 +8,8 @@ import (
 	"syscall"
 )
 
-func (v *View) suspend() {
-	v.app.Suspend(func() {
+func (m *Model) suspend() {
+	m.app.Suspend(func() {
 		c := make(chan os.Signal, 1)
 		signal.Notify(c, syscall.SIGCONT)
 		defer signal.Stop(c)

+ 0 - 161
internal/ui/root/view.go

@@ -1,161 +0,0 @@
-package root
-
-import (
-	"github.com/ayn2op/discordo/internal/config"
-	"github.com/ayn2op/discordo/internal/consts"
-	"github.com/ayn2op/discordo/internal/ui/chat"
-	"github.com/ayn2op/discordo/internal/ui/login"
-	"github.com/ayn2op/tview"
-	"github.com/ayn2op/tview/help"
-	"github.com/ayn2op/tview/keybind"
-	"github.com/gdamore/tcell/v3"
-)
-
-const tokenEnvVarKey = "DISCORDO_TOKEN"
-
-type View struct {
-	app      *tview.Application
-	rootFlex *tview.Flex // inner + help
-	inner    tview.Primitive
-	help     *help.Help
-
-	cfg *config.Config
-}
-
-func NewView(cfg *config.Config, app *tview.Application) *View {
-	v := &View{
-		app:      app,
-		rootFlex: tview.NewFlex(),
-		help:     help.New(),
-
-		cfg: cfg,
-	}
-
-	v.rootFlex.SetDirection(tview.FlexRow)
-
-	styles := help.DefaultStyles()
-	styles.ShortKeyStyle = cfg.Theme.Help.ShortKeyStyle.Style
-	styles.ShortDescStyle = cfg.Theme.Help.ShortDescStyle.Style
-	styles.FullKeyStyle = cfg.Theme.Help.FullKeyStyle.Style
-	styles.FullDescStyle = cfg.Theme.Help.FullDescStyle.Style
-	v.help.SetStyles(styles)
-
-	v.help.SetKeyMap(v)
-	v.help.SetCompactModifiers(cfg.Help.CompactModifiers)
-	v.help.SetShortSeparator(cfg.Help.Separator)
-	v.help.SetBorderPadding(0, 0, cfg.Help.Padding[0], cfg.Help.Padding[1])
-	v.buildLayout()
-	return v
-}
-
-func (v *View) showLoginView() tview.Command {
-	v.inner = login.NewForm(v.app, v.cfg)
-	v.buildLayout()
-	return v.inner.HandleEvent(tview.NewInitEvent())
-}
-
-func (v *View) showChatView(token string) tview.Command {
-	v.inner = chat.NewView(v.app, v.cfg, token)
-	v.buildLayout()
-	return v.inner.HandleEvent(tview.NewInitEvent())
-}
-
-func (v *View) buildLayout() {
-	v.rootFlex.Clear()
-
-	content := v.inner
-	if content == nil {
-		content = tview.NewBox()
-	}
-	v.rootFlex.AddItem(content, 0, 1, true)
-	v.rootFlex.AddItem(v.help, 1, 0, false)
-	v.updateHelpHeight()
-}
-
-var _ tview.Primitive = (*View)(nil)
-
-func (v *View) Draw(screen tcell.Screen) {
-	v.rootFlex.Draw(screen)
-}
-
-func (v *View) HandleEvent(event tcell.Event) tview.Command {
-	switch event := event.(type) {
-	case *tview.InitEvent:
-		return tview.BatchCommand{
-			tview.SetTitleCommand(consts.Name),
-			tview.EventCommand(initClipboard),
-			tview.EventCommand(getToken),
-		}
-	case *tokenEvent:
-		if event.token == "" {
-			return tview.BatchCommand{v.showLoginView(), tview.SetFocusCommand{Target: v.inner}}
-		} else {
-			return tview.BatchCommand{v.showChatView(event.token), tview.SetFocusCommand{Target: v.inner}}
-		}
-	case *login.TokenEvent:
-		return tview.BatchCommand{v.showChatView(event.Token), tview.SetFocusCommand{Target: v.inner}}
-	case *chat.LogoutEvent:
-		v.showLoginView()
-		return tview.BatchCommand{
-			tview.EventCommand(deleteToken),
-			tview.SetFocusCommand{Target: v.inner},
-		}
-
-	case *tview.KeyEvent:
-		switch {
-		case keybind.Matches(event, v.cfg.Keybinds.ToggleHelp.Keybind):
-			v.help.SetShowAll(!v.help.ShowAll())
-			v.updateHelpHeight()
-			return tview.RedrawCommand{}
-		case keybind.Matches(event, v.cfg.Keybinds.Suspend.Keybind):
-			v.suspend()
-			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.Quit.Keybind):
-			var innerCmd tview.Command
-			if v.inner != nil {
-				innerCmd = v.inner.HandleEvent(chat.NewQuitEvent())
-			}
-			return tview.BatchCommand{innerCmd, tview.QuitCommand{}}
-		}
-	}
-
-	if v.inner != nil {
-		return v.inner.HandleEvent(event)
-	}
-	return nil
-}
-
-func (v *View) updateHelpHeight() {
-	height := 1
-	if v.help.ShowAll() {
-		height = max(len(v.help.FullHelpLines(v.FullHelp(), 0)), 1)
-	}
-	v.rootFlex.ResizeItem(v.help, height, 0)
-}
-
-func (v *View) GetRect() (int, int, int, int) {
-	return v.rootFlex.GetRect()
-}
-
-func (v *View) SetRect(x int, y int, width int, height int) {
-	v.rootFlex.SetRect(x, y, width, height)
-}
-
-func (v *View) Focus(delegate func(p tview.Primitive)) {
-	if v.inner != nil {
-		delegate(v.inner)
-	}
-}
-
-func (v *View) HasFocus() bool {
-	if v.inner != nil {
-		return v.inner.HasFocus()
-	}
-	return true
-}
-
-func (v *View) Blur() {
-	if v.inner != nil {
-		v.inner.Blur()
-	}
-}