Explorar o código

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

ayn2op hai 1 mes
pai
achega
5917a460d3

+ 1 - 1
cmd/root.go

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

+ 2 - 2
go.mod

@@ -11,9 +11,9 @@ require (
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/andybalholm/brotli v1.2.0
 	github.com/andybalholm/brotli v1.2.0
 	github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97
 	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/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/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4
 	github.com/gdamore/tcell/v3 v3.1.2
 	github.com/gdamore/tcell/v3 v3.1.2
 	github.com/gen2brain/beeep v0.11.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/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 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io=
 github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80=
 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 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 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=
 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/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 h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw=
 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y=
 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 h1:m1WyrOUuFE4BuIcQzPeRmmLQmJMBNElT/tfx/s2+AoE=
 github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4/go.mod h1:JSqSCBN5MAI9yjAO6tZlSayXM9KWx+8q3EhhlkCuscM=
 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=
 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 {
 type attachmentsPicker struct {
 	*picker.Picker
 	*picker.Picker
 	cfg      *config.Config
 	cfg      *config.Config
-	chatView *View
+	chatView *Model
 	items    []attachmentItem
 	items    []attachmentItem
 }
 }
 
 
 var _ help.KeyMap = (*attachmentsPicker)(nil)
 var _ help.KeyMap = (*attachmentsPicker)(nil)
 
 
-func newAttachmentsPicker(cfg *config.Config, chatView *View) *attachmentsPicker {
+func newAttachmentsPicker(cfg *config.Config, chatView *Model) *attachmentsPicker {
 	ap := &attachmentsPicker{
 	ap := &attachmentsPicker{
 		Picker:   picker.New(),
 		Picker:   picker.New(),
 		cfg:      cfg,
 		cfg:      cfg,

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

@@ -15,12 +15,12 @@ import (
 
 
 type channelsPicker struct {
 type channelsPicker struct {
 	*picker.Picker
 	*picker.Picker
-	chatView *View
+	chatView *Model
 }
 }
 
 
 var _ help.KeyMap = (*channelsPicker)(nil)
 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 := &channelsPicker{picker.New(), chatView}
 	cp.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme)
 	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.
 	// 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 }
 type LogoutEvent struct{ tcell.EventTime }
 
 
-func NewLogoutEvent() *LogoutEvent {
+func newLogoutEvent() *LogoutEvent {
 	event := &LogoutEvent{}
 	event := &LogoutEvent{}
 	event.SetEventNow()
 	event.SetEventNow()
 	return event
 	return event
 }
 }
 
 
+func (v *Model) logout() tview.Command {
+	return tview.EventCommand(func() tcell.Event { return newLogoutEvent() })
+}
+
 type QuitEvent struct{ tcell.EventTime }
 type QuitEvent struct{ tcell.EventTime }
 
 
 func NewQuitEvent() *QuitEvent {
 func NewQuitEvent() *QuitEvent {
@@ -23,17 +27,12 @@ func NewQuitEvent() *QuitEvent {
 	return event
 	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 {
 type guildsTree struct {
 	*tview.TreeView
 	*tview.TreeView
-	cfg      *config.Config
-	chatView *View
+	chat *Model
+
+	cfg *config.Config
 
 
 	// Fast-path indexes for frequent event handlers (read updates, picker
 	// Fast-path indexes for frequent event handlers (read updates, picker
 	// navigation). They mirror the current rendered tree and are rebuilt on
 	// navigation). They mirror the current rendered tree and are rebuilt on
@@ -33,11 +34,11 @@ type guildsTree struct {
 
 
 var _ help.KeyMap = (*guildsTree)(nil)
 var _ help.KeyMap = (*guildsTree)(nil)
 
 
-func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree {
+func newGuildsTree(cfg *config.Config, chatView *Model) *guildsTree {
 	gt := &guildsTree{
 	gt := &guildsTree{
 		TreeView: tview.NewTreeView(),
 		TreeView: tview.NewTreeView(),
 		cfg:      cfg,
 		cfg:      cfg,
-		chatView: chatView,
+		chat:     chatView,
 
 
 		guildNodeByID:   make(map[discord.GuildID]*tview.TreeNode),
 		guildNodeByID:   make(map[discord.GuildID]*tview.TreeNode),
 		channelNodeByID: make(map[discord.ChannelID]*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 {
 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)
 	return gt.unreadStyle(indication)
 }
 }
 
 
 func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style {
 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)
 	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) {
 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
 		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))
 	gt.setNodeLineStyle(channelNode, gt.getChannelNodeStyle(channel.ID))
 	switch channel.Type {
 	switch channel.Type {
 	case discord.DirectMessage:
 	case discord.DirectMessage:
@@ -285,9 +286,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 
 
 	switch ref := node.GetReference().(type) {
 	switch ref := node.GetReference().(type) {
 	case discord.GuildID:
 	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 {
 		if err != nil {
 			slog.Error("failed to get channels", "err", err, "guild_id", ref)
 			slog.Error("failed to get channels", "err", err, "guild_id", ref)
 			return
 			return
@@ -297,7 +298,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		gt.createChannelNodes(node, channels)
 		gt.createChannelNodes(node, channels)
 		node.Expand()
 		node.Expand()
 	case discord.ChannelID:
 	case discord.ChannelID:
-		channel, err := gt.chatView.state.Cabinet.Channel(ref)
+		channel, err := gt.chat.state.Cabinet.Channel(ref)
 		if err != nil {
 		if err != nil {
 			slog.Error("failed to get channel from state", "err", err, "channel_id", ref)
 			slog.Error("failed to get channel from state", "err", err, "channel_id", ref)
 			return
 			return
@@ -306,7 +307,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		// Handle forum channels differently - they contain threads, not direct messages
 		// Handle forum channels differently - they contain threads, not direct messages
 		if channel.Type == discord.GuildForum {
 		if channel.Type == discord.GuildForum {
 			// Get all channels from the guild - this includes active threads from GuildCreateEvent
 			// 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 {
 			if err != nil {
 				slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID)
 				slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID)
 				return
 				return
@@ -331,41 +332,41 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 		}
 		}
 
 
 		limit := gt.cfg.MessagesLimit
 		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 {
 		if err != nil {
 			slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit)
 			slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit)
 			return
 			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() {
 		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
 		var text string
 		if hasNoPerm {
 		if hasNoPerm {
 			text = "You do not have permission to send messages in this channel."
 			text = "You do not have permission to send messages in this channel."
 		} else {
 		} else {
 			text = "Message..."
 			text = "Message..."
 			if gt.cfg.AutoFocus {
 			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
 	case dmNode: // Direct messages folder
-		channels, err := gt.chatView.state.PrivateChannels()
+		channels, err := gt.chat.state.PrivateChannels()
 		if err != nil {
 		if err != nil {
 			slog.Error("failed to get private channels", "err", err)
 			slog.Error("failed to get private channels", "err", err)
 			return
 			return
@@ -467,7 +468,7 @@ func (gt *guildsTree) findNodeByReference(reference any) *tview.TreeNode {
 }
 }
 
 
 func (gt *guildsTree) findNodeByChannelID(channelID discord.ChannelID) *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 {
 	if err != nil {
 		slog.Error("failed to get channel", "channel_id", channelID, "err", err)
 		slog.Error("failed to get channel", "channel_id", channelID, "err", err)
 		return nil
 		return nil

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

@@ -5,9 +5,9 @@ import (
 	"github.com/ayn2op/tview/keybind"
 	"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)
 	short := make([]keybind.Keybind, 0, 16)
 	if active := v.activeKeyMap(); active != nil {
 	if active := v.activeKeyMap(); active != nil {
 		short = append(short, active.ShortHelp()...)
 		short = append(short, active.ShortHelp()...)
@@ -16,7 +16,7 @@ func (v *View) ShortHelp() []keybind.Keybind {
 	return short
 	return short
 }
 }
 
 
-func (v *View) FullHelp() [][]keybind.Keybind {
+func (v *Model) FullHelp() [][]keybind.Keybind {
 	full := make([][]keybind.Keybind, 0, 8)
 	full := make([][]keybind.Keybind, 0, 8)
 	if active := v.activeKeyMap(); active != nil {
 	if active := v.activeKeyMap(); active != nil {
 		full = append(full, active.FullHelp()...)
 		full = append(full, active.FullHelp()...)
@@ -25,7 +25,7 @@ func (v *View) FullHelp() [][]keybind.Keybind {
 	return full
 	return full
 }
 }
 
 
-func (v *View) activeKeyMap() help.KeyMap {
+func (v *Model) activeKeyMap() help.KeyMap {
 	if v.GetVisible(channelsPickerLayerName) {
 	if v.GetVisible(channelsPickerLayerName) {
 		return v.channelsPicker
 		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
 	cfg := v.cfg.Keybinds
 	short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	if !v.messageInput.GetDisabled() {
 	if !v.messageInput.GetDisabled() {
@@ -56,7 +56,7 @@ func (v *View) baseShortHelp() []keybind.Keybind {
 	return short
 	return short
 }
 }
 
 
-func (v *View) baseFullHelp() [][]keybind.Keybind {
+func (v *Model) baseFullHelp() [][]keybind.Keybind {
 	cfg := v.cfg.Keybinds
 	cfg := v.cfg.Keybinds
 	focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
 	if !v.messageInput.GetDisabled() {
 	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 {
 type messageInput struct {
 	*tview.TextArea
 	*tview.TextArea
-	cfg      *config.Config
-	chatView *View
+	chat *Model
+
+	cfg *config.Config
 
 
 	edit            bool
 	edit            bool
 	sendMessageData *api.SendMessageData
 	sendMessageData *api.SendMessageData
@@ -56,11 +57,11 @@ type messageInput struct {
 
 
 var _ help.KeyMap = (*messageInput)(nil)
 var _ help.KeyMap = (*messageInput)(nil)
 
 
-func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
+func newMessageInput(cfg *config.Config, chatView *Model) *messageInput {
 	mi := &messageInput{
 	mi := &messageInput{
 		TextArea:        tview.NewTextArea(),
 		TextArea:        tview.NewTextArea(),
 		cfg:             cfg,
 		cfg:             cfg,
-		chatView:        chatView,
+		chat:            chatView,
 		sendMessageData: &api.SendMessageData{},
 		sendMessageData: &api.SendMessageData{},
 		cache:           cache.NewCache(),
 		cache:           cache.NewCache(),
 		mentionsList:    newMentionsList(cfg),
 		mentionsList:    newMentionsList(cfg),
@@ -113,7 +114,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			mi.paste()
 			mi.paste()
 			return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))
 			return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone))
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind):
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind):
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				mi.tabComplete()
 				mi.tabComplete()
 			} else {
 			} else {
 				mi.send()
 				mi.send()
@@ -141,7 +142,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			return cmds
 			return cmds
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind):
 			var cmds tview.BatchCommand
 			var cmds tview.BatchCommand
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				mi.stopTabCompletion(func(next tview.Command) {
 				mi.stopTabCompletion(func(next tview.Command) {
 					if next != nil {
 					if next != nil {
 						cmds = append(cmds, next)
 						cmds = append(cmds, next)
@@ -153,7 +154,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 			cmds = append(cmds, redraw)
 			cmds = append(cmds, redraw)
 			return cmds
 			return cmds
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind):
 		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
 			return redraw
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
 		case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
 			return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
 			return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
@@ -166,13 +167,13 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command {
 				mi.typingTimerMu.Unlock()
 				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.cfg.AutocompleteLimit > 0 {
-			if mi.chatView.GetVisible(mentionsListLayerName) {
+			if mi.chat.GetVisible(mentionsListLayerName) {
 				switch {
 				switch {
 				case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
 				case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind):
 					mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone))
 					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)
 		return handler(event)
@@ -210,7 +211,7 @@ func (mi *messageInput) paste() {
 }
 }
 
 
 func (mi *messageInput) send() {
 func (mi *messageInput) send() {
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 	if selected == nil {
 		return
 		return
 	}
 	}
@@ -232,14 +233,14 @@ func (mi *messageInput) send() {
 	text = mi.processText(selected, []byte(text))
 	text = mi.processText(selected, []byte(text))
 
 
 	if mi.edit {
 	if mi.edit {
-		m, err := mi.chatView.messagesList.selectedMessage()
+		m, err := mi.chat.messagesList.selectedMessage()
 		if err != nil {
 		if err != nil {
 			slog.Error("failed to get selected message", "err", err)
 			slog.Error("failed to get selected message", "err", err)
 			return
 			return
 		}
 		}
 
 
 		data := api.EditMessageData{Content: option.NewNullableString(text)}
 		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)
 			slog.Error("failed to edit message", "err", err)
 		}
 		}
 
 
@@ -247,7 +248,7 @@ func (mi *messageInput) send() {
 	} else {
 	} else {
 		data := mi.sendMessageData
 		data := mi.sendMessageData
 		data.Content = text
 		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)
 			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.typingTimer = nil
 	}
 	}
 	mi.reset()
 	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 {
 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 {
 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 {
 	return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
 		output := input
 		output := input
 		name := string(input[1:])
 		name := string(input[1:])
@@ -342,7 +343,7 @@ func (mi *messageInput) tabComplete() {
 	}
 	}
 	pos := posEnd - (len(name) + 1)
 	pos := posEnd - (len(name) + 1)
 
 
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 	if selected == nil {
 		return
 		return
 	}
 	}
@@ -357,7 +358,7 @@ func (mi *messageInput) tabComplete() {
 			}
 			}
 		} else {
 		} else {
 			mi.searchMember(gID, name)
 			mi.searchMember(gID, name)
-			members, err := mi.chatView.state.Cabinet.Members(gID)
+			members, err := mi.chat.state.Cabinet.Members(gID)
 			if err != nil {
 			if err != nil {
 				slog.Error("failed to get members from state", "guild_id", gID, "err", err)
 				slog.Error("failed to get members from state", "guild_id", gID, "err", err)
 				return
 				return
@@ -365,7 +366,7 @@ func (mi *messageInput) tabComplete() {
 
 
 			res := fuzzy.FindFrom(name, memberList(members))
 			res := fuzzy.FindFrom(name, memberList(members))
 			for _, r := range res {
 			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+" ")
 					mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
 					return
 					return
 				}
 				}
@@ -392,7 +393,7 @@ func (mi *messageInput) tabSuggestion() {
 		mi.stopTabCompletion(nil)
 		mi.stopTabCompletion(nil)
 		return
 		return
 	}
 	}
-	selected := mi.chatView.SelectedChannel()
+	selected := mi.chat.SelectedChannel()
 	if selected == nil {
 	if selected == nil {
 		return
 		return
 	}
 	}
@@ -405,14 +406,14 @@ func (mi *messageInput) tabSuggestion() {
 	if name == "" {
 	if name == "" {
 		shown = make(map[string]struct{})
 		shown = make(map[string]struct{})
 		// Don't show @me in the list of recent authors
 		// 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
 		shown[me.Username] = userDone
 	}
 	}
 
 
 	// DMs have recipients, not members
 	// DMs have recipients, not members
 	if !gID.IsValid() {
 	if !gID.IsValid() {
 		if name == "" { // show recent messages' authors
 		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 {
 			if err != nil {
 				return
 				return
 			}
 			}
@@ -425,7 +426,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 			}
 		} else {
 		} else {
 			users := selected.DMRecipients
 			users := selected.DMRecipients
-			me, _ := mi.chatView.state.Cabinet.Me()
+			me, _ := mi.chat.state.Cabinet.Me()
 			users = append(users, *me)
 			users = append(users, *me)
 			res := fuzzy.FindFrom(name, userList(users))
 			res := fuzzy.FindFrom(name, userList(users))
 			for _, r := range res {
 			for _, r := range res {
@@ -433,7 +434,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 			}
 		}
 		}
 	} else if name == "" { // show recent messages' authors
 	} 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 {
 		if err != nil {
 			return
 			return
 		}
 		}
@@ -442,8 +443,8 @@ func (mi *messageInput) tabSuggestion() {
 				continue
 				continue
 			}
 			}
 			shown[m.Author.Username] = userDone
 			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) {
 				if mi.addMentionMember(gID, mem) {
 					break
 					break
 				}
 				}
@@ -451,7 +452,7 @@ func (mi *messageInput) tabSuggestion() {
 		}
 		}
 	} else {
 	} else {
 		mi.searchMember(gID, name)
 		mi.searchMember(gID, name)
-		mems, err := mi.chatView.state.Cabinet.Members(gID)
+		mems, err := mi.chat.state.Cabinet.Members(gID)
 		if err != nil {
 		if err != nil {
 			slog.Error("fetching members failed", "err", err)
 			slog.Error("fetching members failed", "err", err)
 			return
 			return
@@ -461,7 +462,7 @@ func (mi *messageInput) tabSuggestion() {
 			res = res[:int(mi.cfg.AutocompleteLimit)]
 			res = res[:int(mi.cfg.AutocompleteLimit)]
 		}
 		}
 		for _, r := range res {
 		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]) {
 				mi.addMentionMember(gID, &mems[r.Index]) {
 				break
 				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
 	// everything starting with "ab". This will still be true even if a new
 	// member joins because arikawa loads new members into the state.
 	// member joins because arikawa loads new members into the state.
 	if k := key[:len(key)-1]; mi.cache.Exists(k) {
 	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)
 			mi.cache.Create(key, c)
 			return
 			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
 	// 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
 		return
 	}
 	}
 
 
 	mi.lastSearch = time.Now()
 	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() {
 func (mi *messageInput) showMentionList() {
@@ -542,7 +543,7 @@ func (mi *messageInput) showMentionList() {
 	l := mi.mentionsList
 	l := mi.mentionsList
 	x, _, _, _ := mi.GetInnerRect()
 	x, _, _, _ := mi.GetInnerRect()
 	_, y, _, _ := mi.GetRect()
 	_, 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 {
 	if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
 		maxH = min(maxH, t)
 		maxH = min(maxH, t)
 	}
 	}
@@ -561,8 +562,8 @@ func (mi *messageInput) showMentionList() {
 	}
 	}
 
 
 	l.SetRect(x, y, w, h)
 	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 {
 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.
 	// This avoids a slower member color lookup path.
 	color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
 	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
 		return r
 	})
 	})
 	if ok {
 	if ok {
 		style = style.Foreground(tcell.NewHexColor(int32(color)))
 		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 {
 	if err != nil {
 		slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
 		slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
 	} else if presence.Status == discord.OfflineStatus {
@@ -608,7 +609,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 
 
 	name := user.DisplayOrUsername()
 	name := user.DisplayOrUsername()
 	style := tcell.StyleDefault
 	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 {
 	if err != nil {
 		slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
 		slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
 	} else if presence.Status == discord.OfflineStatus {
 	} else if presence.Status == discord.OfflineStatus {
@@ -623,7 +624,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 }
 }
 
 
 func (mi *messageInput) removeMentionsList() {
 func (mi *messageInput) removeMentionsList() {
-	mi.chatView.HideLayer(mentionsListLayerName)
+	mi.chat.HideLayer(mentionsListLayerName)
 }
 }
 
 
 func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) {
 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})
 			emit(tview.SetFocusCommand{Target: mi})
 		} else {
 		} else {
 			mi.removeMentionsList()
 			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.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 	cmd.Stderr = os.Stderr
 
 
-	mi.chatView.app.Suspend(func() {
+	mi.chat.app.Suspend(func() {
 		err := cmd.Run()
 		err := cmd.Run()
 		if err != nil {
 		if err != nil {
 			slog.Error("failed to run command", "args", cmd.Args, "err", err)
 			slog.Error("failed to run command", "args", cmd.Args, "err", err)
@@ -682,7 +683,7 @@ func (mi *messageInput) editor() {
 }
 }
 
 
 func (mi *messageInput) openFilePicker() {
 func (mi *messageInput) openFilePicker() {
-	if mi.chatView.SelectedChannel() == nil {
+	if mi.chat.SelectedChannel() == nil {
 		return
 		return
 	}
 	}
 
 
@@ -715,11 +716,11 @@ func (mi *messageInput) attach(name string, reader io.Reader) {
 }
 }
 
 
 func (mi *messageInput) ShortHelp() []keybind.Keybind {
 func (mi *messageInput) ShortHelp() []keybind.Keybind {
-	if mi.chatView.GetVisible(mentionsListLayerName) {
+	if mi.chat.GetVisible(mentionsListLayerName) {
 		cfg := mi.cfg.Keybinds.MentionsList
 		cfg := mi.cfg.Keybinds.MentionsList
 		icfg := mi.cfg.Keybinds.MessageInput
 		icfg := mi.cfg.Keybinds.MessageInput
 		short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind}
 		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)
 			short = append(short, icfg.OpenFilePicker.Keybind)
 		}
 		}
 		return short
 		return short
@@ -727,14 +728,14 @@ func (mi *messageInput) ShortHelp() []keybind.Keybind {
 
 
 	cfg := mi.cfg.Keybinds.MessageInput
 	cfg := mi.cfg.Keybinds.MessageInput
 	short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
 	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)
 		short = append(short, cfg.OpenFilePicker.Keybind)
 	}
 	}
 	return short
 	return short
 }
 }
 
 
 func (mi *messageInput) FullHelp() [][]keybind.Keybind {
 func (mi *messageInput) FullHelp() [][]keybind.Keybind {
-	if mi.chatView.GetVisible(mentionsListLayerName) {
+	if mi.chat.GetVisible(mentionsListLayerName) {
 		mcfg := mi.cfg.Keybinds.MentionsList
 		mcfg := mi.cfg.Keybinds.MentionsList
 		icfg := mi.cfg.Keybinds.MessageInput
 		icfg := mi.cfg.Keybinds.MessageInput
 		return [][]keybind.Keybind{
 		return [][]keybind.Keybind{
@@ -745,7 +746,7 @@ func (mi *messageInput) FullHelp() [][]keybind.Keybind {
 
 
 	cfg := mi.cfg.Keybinds.MessageInput
 	cfg := mi.cfg.Keybinds.MessageInput
 	openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
 	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)
 		openEditor = append(openEditor, cfg.OpenFilePicker.Keybind)
 	}
 	}
 
 

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

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

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

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

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

@@ -20,7 +20,7 @@ import (
 	"github.com/diamondburned/ningen/v3"
 	"github.com/diamondburned/ningen/v3"
 )
 )
 
 
-func (v *View) OpenState(token string) error {
+func (v *Model) OpenState(token string) error {
 	identifyProps := http.IdentifyProperties()
 	identifyProps := http.IdentifyProperties()
 	gateway.DefaultIdentity = identifyProps
 	gateway.DefaultIdentity = identifyProps
 	gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
 	gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
@@ -56,14 +56,14 @@ func (v *View) OpenState(token string) error {
 	return v.state.Open(context.Background())
 	return v.state.Open(context.Background())
 }
 }
 
 
-func (v *View) CloseState() error {
+func (v *Model) CloseState() error {
 	if v.state == nil {
 	if v.state == nil {
 		return nil
 		return nil
 	}
 	}
 	return v.state.Close()
 	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 {
 	if req, ok := r.(*httpdriver.DefaultRequest); ok {
 		slog.Debug("new HTTP request", "method", req.Method, "url", req.URL)
 		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
 	return nil
 }
 }
 
 
-func (v *View) onRaw(event *ws.RawEvent) {
+func (v *Model) onRaw(event *ws.RawEvent) {
 	slog.Debug(
 	slog.Debug(
 		"new raw event",
 		"new raw event",
 		"code", event.OriginalCode,
 		"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() {
 	v.app.QueueUpdateDraw(func() {
 		// Rebuild indexes from scratch so reconnects and account switches do not
 		// Rebuild indexes from scratch so reconnects and account switches do not
 		// retain pointers to detached tree nodes.
 		// 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()
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
 	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
 		v.removeTyper(message.Author.ID)
 		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 {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
 		index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
 		index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
 			return m.ID == message.ID
 			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 {
 	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
 		prevCursor := v.messagesList.Cursor()
 		prevCursor := v.messagesList.Cursor()
 		deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
 		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)))
 	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)
 	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()
 	selectedChannel := v.SelectedChannel()
 	if selectedChannel == nil {
 	if selectedChannel == nil {
 		return
 		return

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

@@ -1,14 +1,19 @@
 package login
 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 (
 import (
 	"log/slog"
 	"log/slog"
-	"os"
 
 
 	"github.com/ayn2op/discordo/internal/clipboard"
 	"github.com/ayn2op/discordo/internal/clipboard"
 	"github.com/ayn2op/discordo/internal/keyring"
 	"github.com/ayn2op/discordo/internal/keyring"
+	"github.com/ayn2op/tview"
 	"github.com/gdamore/tcell/v3"
 	"github.com/gdamore/tcell/v3"
 )
 )
 
 
@@ -20,30 +20,57 @@ func newTokenEvent(token string) *tokenEvent {
 	return event
 	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 {
 		if err != nil {
 			slog.Info("failed to retrieve token from keyring", "err", err)
 			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"
 	"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{
 	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()
 		short := active.ShortHelp()
 		return append(short, global...)
 		return append(short, global...)
 	}
 	}
 	return global
 	return global
 }
 }
 
 
-func (v *View) FullHelp() [][]keybind.Keybind {
+func (m *Model) FullHelp() [][]keybind.Keybind {
 	global := []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()
 		full := active.FullHelp()
 		return append(full, global)
 		return append(full, global)
 	}
 	}
 	return [][]keybind.Keybind{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 keyMap
 	}
 	}
 	return nil
 	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
 package root
 
 
-func (v *View) suspend() {}
+func (m *Model) suspend() {}

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

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