diff --git a/cmd/root.go b/cmd/root.go index ed88dce..991e041 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,7 +64,7 @@ func Run() error { tview.Styles = tview.Theme{} app := tview.NewApplication() - app.SetRoot(root.NewView(cfg, app)) + app.SetRoot(root.NewModel(cfg, app)) app.SetScreen(screen) return app.Run() } diff --git a/go.mod b/go.mod index 25c7ae3..9cbd7a8 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ require ( github.com/alecthomas/chroma/v2 v2.23.1 github.com/andybalholm/brotli v1.2.0 github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 - github.com/ayn2op/tview v0.0.0-20260304052427-7549865d186e + github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb - github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa + github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64 github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 github.com/gdamore/tcell/v3 v3.1.2 github.com/gen2brain/beeep v0.11.2 diff --git a/go.sum b/go.sum index f650f9b..f5642ea 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io= github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80= -github.com/ayn2op/tview v0.0.0-20260304052427-7549865d186e h1:fc5qHUJV+XYlYabsJtWW/AwbGLFu6XMFTiHXDViUBeM= -github.com/ayn2op/tview v0.0.0-20260304052427-7549865d186e/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0= +github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e h1:FyeMOB3R64YqkAv6I6AZZpZgr86X5ixP9GSCv6uCJyg= +github.com/ayn2op/tview v0.0.0-20260310212203-f3d86bcf321e/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,8 +27,8 @@ github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw= github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y= -github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa h1:xgONqoeHHAxAG002WkwxZsZ3vrOpA+VskVO4CNLsPIA= -github.com/diamondburned/arikawa/v3 v3.6.1-0.20260308005009-475d37ffd5fa/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go= +github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64 h1:ZdzbwItbGTvOdNfoCH7QIbRiBPDufsq8AwfJUaM4mHM= +github.com/diamondburned/arikawa/v3 v3.6.1-0.20260309010533-e61165a61b64/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go= github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4 h1:m1WyrOUuFE4BuIcQzPeRmmLQmJMBNElT/tfx/s2+AoE= github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4/go.mod h1:JSqSCBN5MAI9yjAO6tZlSayXM9KWx+8q3EhhlkCuscM= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= diff --git a/internal/ui/chat/attachments_picker.go b/internal/ui/chat/attachments_picker.go index 59dbea0..b71af88 100644 --- a/internal/ui/chat/attachments_picker.go +++ b/internal/ui/chat/attachments_picker.go @@ -17,13 +17,13 @@ type attachmentItem struct { type attachmentsPicker struct { *picker.Picker cfg *config.Config - chatView *View + chatView *Model items []attachmentItem } var _ help.KeyMap = (*attachmentsPicker)(nil) -func newAttachmentsPicker(cfg *config.Config, chatView *View) *attachmentsPicker { +func newAttachmentsPicker(cfg *config.Config, chatView *Model) *attachmentsPicker { ap := &attachmentsPicker{ Picker: picker.New(), cfg: cfg, diff --git a/internal/ui/chat/channels_picker.go b/internal/ui/chat/channels_picker.go index a0f8018..2b4a41e 100644 --- a/internal/ui/chat/channels_picker.go +++ b/internal/ui/chat/channels_picker.go @@ -15,12 +15,12 @@ import ( type channelsPicker struct { *picker.Picker - chatView *View + chatView *Model } var _ help.KeyMap = (*channelsPicker)(nil) -func newChannelsPicker(cfg *config.Config, chatView *View) *channelsPicker { +func newChannelsPicker(cfg *config.Config, chatView *Model) *channelsPicker { cp := &channelsPicker{picker.New(), chatView} cp.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme) // When a child of tview.Flex is focused, tview.Flex itself is not reported as focused. Instead, the focused child (picker) is considered focused. Therefore, we manually set the active border style on the picker to ensure it displays the correct focused appearance. diff --git a/internal/ui/chat/events.go b/internal/ui/chat/events.go index 190d7f4..6c2aa30 100644 --- a/internal/ui/chat/events.go +++ b/internal/ui/chat/events.go @@ -9,12 +9,16 @@ import ( type LogoutEvent struct{ tcell.EventTime } -func NewLogoutEvent() *LogoutEvent { +func newLogoutEvent() *LogoutEvent { event := &LogoutEvent{} event.SetEventNow() return event } +func (v *Model) logout() tview.Command { + return tview.EventCommand(func() tcell.Event { return newLogoutEvent() }) +} + type QuitEvent struct{ tcell.EventTime } func NewQuitEvent() *QuitEvent { @@ -23,17 +27,12 @@ func NewQuitEvent() *QuitEvent { return event } -func (v *View) closeState() tcell.Event { - if err := v.CloseState(); err != nil { - slog.Error("failed to close the session", "err", err) - return tcell.NewEventError(err) - } - return nil -} - -func (v *View) logout() tview.Command { - return tview.BatchCommand{ - tview.EventCommand(v.closeState), - tview.EventCommand(func() tcell.Event { return NewLogoutEvent() }), - } +func (v *Model) closeState() tview.Command { + return tview.EventCommand(func() tcell.Event { + if err := v.CloseState(); err != nil { + slog.Error("failed to close the session", "err", err) + return tcell.NewEventError(err) + } + return nil + }) } diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index 5e61d4c..19c0cef 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -20,8 +20,9 @@ type dmNode struct{} type guildsTree struct { *tview.TreeView - cfg *config.Config - chatView *View + chat *Model + + cfg *config.Config // Fast-path indexes for frequent event handlers (read updates, picker // navigation). They mirror the current rendered tree and are rebuilt on @@ -33,11 +34,11 @@ type guildsTree struct { var _ help.KeyMap = (*guildsTree)(nil) -func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree { +func newGuildsTree(cfg *config.Config, chatView *Model) *guildsTree { gt := &guildsTree{ TreeView: tview.NewTreeView(), cfg: cfg, - chatView: chatView, + chat: chatView, guildNodeByID: make(map[discord.GuildID]*tview.TreeNode), channelNodeByID: make(map[discord.ChannelID]*tview.TreeNode), @@ -183,12 +184,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl } func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style { - indication := gt.chatView.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) + indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}}) return gt.unreadStyle(indication) } func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style { - indication := gt.chatView.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) + indication := gt.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true}) return gt.unreadStyle(indication) } @@ -204,11 +205,11 @@ func (gt *guildsTree) createGuildNode(n *tview.TreeNode, guild discord.Guild) { } func (gt *guildsTree) createChannelNode(node *tview.TreeNode, channel discord.Channel) { - if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { + if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { return } - channelNode := tview.NewTreeNode(ui.ChannelToString(channel, gt.cfg.Icons, gt.chatView.state)).SetReference(channel.ID) + channelNode := tview.NewTreeNode(ui.ChannelToString(channel, gt.cfg.Icons, gt.chat.state)).SetReference(channel.ID) gt.setNodeLineStyle(channelNode, gt.getChannelNodeStyle(channel.ID)) switch channel.Type { case discord.DirectMessage: @@ -285,9 +286,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { switch ref := node.GetReference().(type) { case discord.GuildID: - go gt.chatView.state.MemberState.Subscribe(ref) + go gt.chat.state.MemberState.Subscribe(ref) - channels, err := gt.chatView.state.Cabinet.Channels(ref) + channels, err := gt.chat.state.Cabinet.Channels(ref) if err != nil { slog.Error("failed to get channels", "err", err, "guild_id", ref) return @@ -297,7 +298,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { gt.createChannelNodes(node, channels) node.Expand() case discord.ChannelID: - channel, err := gt.chatView.state.Cabinet.Channel(ref) + channel, err := gt.chat.state.Cabinet.Channel(ref) if err != nil { slog.Error("failed to get channel from state", "err", err, "channel_id", ref) return @@ -306,7 +307,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { // Handle forum channels differently - they contain threads, not direct messages if channel.Type == discord.GuildForum { // Get all channels from the guild - this includes active threads from GuildCreateEvent - allChannels, err := gt.chatView.state.Cabinet.Channels(channel.GuildID) + allChannels, err := gt.chat.state.Cabinet.Channels(channel.GuildID) if err != nil { slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID) return @@ -331,41 +332,41 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) { } limit := gt.cfg.MessagesLimit - messages, err := gt.chatView.state.Messages(channel.ID, uint(limit)) + messages, err := gt.chat.state.Messages(channel.ID, uint(limit)) if err != nil { slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit) return } - go gt.chatView.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) + go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID) if guildID := channel.GuildID; guildID.IsValid() { - gt.chatView.messagesList.requestGuildMembers(guildID, messages) + gt.chat.messagesList.requestGuildMembers(guildID, messages) } - gt.chatView.SetSelectedChannel(channel) - gt.chatView.clearTypers() - gt.chatView.messageInput.stopTypingTimer() + gt.chat.SetSelectedChannel(channel) + gt.chat.clearTypers() + gt.chat.messageInput.stopTypingTimer() - gt.chatView.messagesList.reset() - gt.chatView.messagesList.setTitle(*channel) - gt.chatView.messagesList.setMessages(messages) - gt.chatView.messagesList.ScrollToEnd() + gt.chat.messagesList.reset() + gt.chat.messagesList.setTitle(*channel) + gt.chat.messagesList.setMessages(messages) + gt.chat.messagesList.ScrollToEnd() - hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionSendMessages) - gt.chatView.messageInput.SetDisabled(hasNoPerm) + hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages) + gt.chat.messageInput.SetDisabled(hasNoPerm) var text string if hasNoPerm { text = "You do not have permission to send messages in this channel." } else { text = "Message..." if gt.cfg.AutoFocus { - gt.chatView.app.SetFocus(gt.chatView.messageInput) + gt.chat.app.SetFocus(gt.chat.messageInput) } } - gt.chatView.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) + gt.chat.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true)))) case dmNode: // Direct messages folder - channels, err := gt.chatView.state.PrivateChannels() + channels, err := gt.chat.state.PrivateChannels() if err != nil { slog.Error("failed to get private channels", "err", err) return @@ -467,7 +468,7 @@ func (gt *guildsTree) findNodeByReference(reference any) *tview.TreeNode { } func (gt *guildsTree) findNodeByChannelID(channelID discord.ChannelID) *tview.TreeNode { - channel, err := gt.chatView.state.Cabinet.Channel(channelID) + channel, err := gt.chat.state.Cabinet.Channel(channelID) if err != nil { slog.Error("failed to get channel", "channel_id", channelID, "err", err) return nil diff --git a/internal/ui/chat/keybinds.go b/internal/ui/chat/keybinds.go index 6c0123e..3049db3 100644 --- a/internal/ui/chat/keybinds.go +++ b/internal/ui/chat/keybinds.go @@ -5,9 +5,9 @@ import ( "github.com/ayn2op/tview/keybind" ) -var _ help.KeyMap = (*View)(nil) +var _ help.KeyMap = (*Model)(nil) -func (v *View) ShortHelp() []keybind.Keybind { +func (v *Model) ShortHelp() []keybind.Keybind { short := make([]keybind.Keybind, 0, 16) if active := v.activeKeyMap(); active != nil { short = append(short, active.ShortHelp()...) @@ -16,7 +16,7 @@ func (v *View) ShortHelp() []keybind.Keybind { return short } -func (v *View) FullHelp() [][]keybind.Keybind { +func (v *Model) FullHelp() [][]keybind.Keybind { full := make([][]keybind.Keybind, 0, 8) if active := v.activeKeyMap(); active != nil { full = append(full, active.FullHelp()...) @@ -25,7 +25,7 @@ func (v *View) FullHelp() [][]keybind.Keybind { return full } -func (v *View) activeKeyMap() help.KeyMap { +func (v *Model) activeKeyMap() help.KeyMap { if v.GetVisible(channelsPickerLayerName) { return v.channelsPicker } @@ -46,7 +46,7 @@ func (v *View) activeKeyMap() help.KeyMap { } } -func (v *View) baseShortHelp() []keybind.Keybind { +func (v *Model) baseShortHelp() []keybind.Keybind { cfg := v.cfg.Keybinds short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind} if !v.messageInput.GetDisabled() { @@ -56,7 +56,7 @@ func (v *View) baseShortHelp() []keybind.Keybind { return short } -func (v *View) baseFullHelp() [][]keybind.Keybind { +func (v *Model) baseFullHelp() [][]keybind.Keybind { cfg := v.cfg.Keybinds focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind} if !v.messageInput.GetDisabled() { diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index 38a45fb..16f258d 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -41,8 +41,9 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type messageInput struct { *tview.TextArea - cfg *config.Config - chatView *View + chat *Model + + cfg *config.Config edit bool sendMessageData *api.SendMessageData @@ -56,11 +57,11 @@ type messageInput struct { var _ help.KeyMap = (*messageInput)(nil) -func newMessageInput(cfg *config.Config, chatView *View) *messageInput { +func newMessageInput(cfg *config.Config, chatView *Model) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), cfg: cfg, - chatView: chatView, + chat: chatView, sendMessageData: &api.SendMessageData{}, cache: cache.NewCache(), mentionsList: newMentionsList(cfg), @@ -113,7 +114,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command { mi.paste() return handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)) case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind): - if mi.chatView.GetVisible(mentionsListLayerName) { + if mi.chat.GetVisible(mentionsListLayerName) { mi.tabComplete() } else { mi.send() @@ -141,7 +142,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command { return cmds case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind): var cmds tview.BatchCommand - if mi.chatView.GetVisible(mentionsListLayerName) { + if mi.chat.GetVisible(mentionsListLayerName) { mi.stopTabCompletion(func(next tview.Command) { if next != nil { cmds = append(cmds, next) @@ -153,7 +154,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command { cmds = append(cmds, redraw) return cmds case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind): - go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() }) + go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() }) return redraw case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Undo.Keybind): return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone)) @@ -166,13 +167,13 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command { mi.typingTimerMu.Unlock() }) - if selectedChannel := mi.chatView.SelectedChannel(); selectedChannel != nil { - go mi.chatView.state.Typing(selectedChannel.ID) + if selectedChannel := mi.chat.SelectedChannel(); selectedChannel != nil { + go mi.chat.state.Typing(selectedChannel.ID) } } if mi.cfg.AutocompleteLimit > 0 { - if mi.chatView.GetVisible(mentionsListLayerName) { + if mi.chat.GetVisible(mentionsListLayerName) { switch { case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind): mi.mentionsList.HandleEvent(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)) @@ -189,7 +190,7 @@ func (mi *messageInput) HandleEvent(event tcell.Event) tview.Command { } } - go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) + go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() }) } return handler(event) @@ -210,7 +211,7 @@ func (mi *messageInput) paste() { } func (mi *messageInput) send() { - selected := mi.chatView.SelectedChannel() + selected := mi.chat.SelectedChannel() if selected == nil { return } @@ -232,14 +233,14 @@ func (mi *messageInput) send() { text = mi.processText(selected, []byte(text)) if mi.edit { - m, err := mi.chatView.messagesList.selectedMessage() + m, err := mi.chat.messagesList.selectedMessage() if err != nil { slog.Error("failed to get selected message", "err", err) return } data := api.EditMessageData{Content: option.NewNullableString(text)} - if _, err := mi.chatView.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { + if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil { slog.Error("failed to edit message", "err", err) } @@ -247,7 +248,7 @@ func (mi *messageInput) send() { } else { data := mi.sendMessageData data.Content = text - if _, err := mi.chatView.state.SendMessageComplex(selected.ID, *data); err != nil { + if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil { slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err) } } @@ -257,8 +258,8 @@ func (mi *messageInput) send() { mi.typingTimer = nil } mi.reset() - mi.chatView.messagesList.clearSelection() - mi.chatView.messagesList.ScrollToEnd() + mi.chat.messagesList.clearSelection() + mi.chat.messagesList.ScrollToEnd() } func (mi *messageInput) processText(channel *discord.Channel, src []byte) string { @@ -302,7 +303,7 @@ func (mi *messageInput) processText(channel *discord.Channel, src []byte) string } func (mi *messageInput) expandMentions(c *discord.Channel, src []byte) []byte { - state := mi.chatView.state + state := mi.chat.state return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte { output := input name := string(input[1:]) @@ -342,7 +343,7 @@ func (mi *messageInput) tabComplete() { } pos := posEnd - (len(name) + 1) - selected := mi.chatView.SelectedChannel() + selected := mi.chat.SelectedChannel() if selected == nil { return } @@ -357,7 +358,7 @@ func (mi *messageInput) tabComplete() { } } else { mi.searchMember(gID, name) - members, err := mi.chatView.state.Cabinet.Members(gID) + members, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("failed to get members from state", "guild_id", gID, "err", err) return @@ -365,7 +366,7 @@ func (mi *messageInput) tabComplete() { res := fuzzy.FindFrom(name, memberList(members)) for _, r := range res { - if channelHasUser(mi.chatView.state, selected.ID, members[r.Index].User.ID) { + if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) { mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") return } @@ -392,7 +393,7 @@ func (mi *messageInput) tabSuggestion() { mi.stopTabCompletion(nil) return } - selected := mi.chatView.SelectedChannel() + selected := mi.chat.SelectedChannel() if selected == nil { return } @@ -405,14 +406,14 @@ func (mi *messageInput) tabSuggestion() { if name == "" { shown = make(map[string]struct{}) // Don't show @me in the list of recent authors - me, _ := mi.chatView.state.Cabinet.Me() + me, _ := mi.chat.state.Cabinet.Me() shown[me.Username] = userDone } // DMs have recipients, not members if !gID.IsValid() { if name == "" { // show recent messages' authors - msgs, err := mi.chatView.state.Cabinet.Messages(cID) + msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } @@ -425,7 +426,7 @@ func (mi *messageInput) tabSuggestion() { } } else { users := selected.DMRecipients - me, _ := mi.chatView.state.Cabinet.Me() + me, _ := mi.chat.state.Cabinet.Me() users = append(users, *me) res := fuzzy.FindFrom(name, userList(users)) for _, r := range res { @@ -433,7 +434,7 @@ func (mi *messageInput) tabSuggestion() { } } } else if name == "" { // show recent messages' authors - msgs, err := mi.chatView.state.Cabinet.Messages(cID) + msgs, err := mi.chat.state.Cabinet.Messages(cID) if err != nil { return } @@ -442,8 +443,8 @@ func (mi *messageInput) tabSuggestion() { continue } shown[m.Author.Username] = userDone - mi.chatView.state.MemberState.RequestMember(gID, m.Author.ID) - if mem, err := mi.chatView.state.Cabinet.Member(gID, m.Author.ID); err == nil { + mi.chat.state.MemberState.RequestMember(gID, m.Author.ID) + if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil { if mi.addMentionMember(gID, mem) { break } @@ -451,7 +452,7 @@ func (mi *messageInput) tabSuggestion() { } } else { mi.searchMember(gID, name) - mems, err := mi.chatView.state.Cabinet.Members(gID) + mems, err := mi.chat.state.Cabinet.Members(gID) if err != nil { slog.Error("fetching members failed", "err", err) return @@ -461,7 +462,7 @@ func (mi *messageInput) tabSuggestion() { res = res[:int(mi.cfg.AutocompleteLimit)] } for _, r := range res { - if channelHasUser(mi.chatView.state, cID, mems[r.Index].User.ID) && + if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) && mi.addMentionMember(gID, &mems[r.Index]) { break } @@ -516,22 +517,22 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) { // everything starting with "ab". This will still be true even if a new // member joins because arikawa loads new members into the state. if k := key[:len(key)-1]; mi.cache.Exists(k) { - if c := mi.cache.Get(k); c < mi.chatView.state.MemberState.SearchLimit { + if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit { mi.cache.Create(key, c) return } } // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect - if mi.lastSearch.Add(mi.chatView.state.MemberState.SearchFrequency).After(time.Now()) { + if mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) { return } mi.lastSearch = time.Now() - mi.chatView.messagesList.waitForChunkEvent() - mi.chatView.messagesList.setFetchingChunk(true, 0) - mi.chatView.state.MemberState.SearchMember(gID, name) - mi.cache.Create(key, mi.chatView.messagesList.waitForChunkEvent()) + mi.chat.messagesList.waitForChunkEvent() + mi.chat.messagesList.setFetchingChunk(true, 0) + mi.chat.state.MemberState.SearchMember(gID, name) + mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent()) } func (mi *messageInput) showMentionList() { @@ -542,7 +543,7 @@ func (mi *messageInput) showMentionList() { l := mi.mentionsList x, _, _, _ := mi.GetInnerRect() _, y, _, _ := mi.GetRect() - _, _, maxW, maxH := mi.chatView.messagesList.GetInnerRect() + _, _, maxW, maxH := mi.chat.messagesList.GetInnerRect() if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } @@ -561,8 +562,8 @@ func (mi *messageInput) showMentionList() { } l.SetRect(x, y, w, h) - mi.chatView.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) - mi.chatView.app.SetFocus(mi) + mi.chat.ShowLayer(mentionsListLayerName).SendToFront(mentionsListLayerName) + mi.chat.app.SetFocus(mi) } func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool { @@ -579,14 +580,14 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) // This avoids a slower member color lookup path. color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role { - r, _ := mi.chatView.state.Cabinet.Role(gID, id) + r, _ := mi.chat.state.Cabinet.Role(gID, id) return r }) if ok { style = style.Foreground(tcell.NewHexColor(int32(color))) } - presence, err := mi.chatView.state.Cabinet.Presence(gID, m.User.ID) + presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID) if err != nil { slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -608,7 +609,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { name := user.DisplayOrUsername() style := tcell.StyleDefault - presence, err := mi.chatView.state.Cabinet.Presence(discord.NullGuildID, user.ID) + presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID) if err != nil { slog.Info("failed to get presence from state", "user_id", user.ID, "err", err) } else if presence.Status == discord.OfflineStatus { @@ -623,7 +624,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) { } func (mi *messageInput) removeMentionsList() { - mi.chatView.HideLayer(mentionsListLayerName) + mi.chat.HideLayer(mentionsListLayerName) } func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) { @@ -634,7 +635,7 @@ func (mi *messageInput) stopTabCompletion(emit func(tview.Command)) { emit(tview.SetFocusCommand{Target: mi}) } else { mi.removeMentionsList() - mi.chatView.app.SetFocus(mi) + mi.chat.app.SetFocus(mi) } } } @@ -664,7 +665,7 @@ func (mi *messageInput) editor() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - mi.chatView.app.Suspend(func() { + mi.chat.app.Suspend(func() { err := cmd.Run() if err != nil { slog.Error("failed to run command", "args", cmd.Args, "err", err) @@ -682,7 +683,7 @@ func (mi *messageInput) editor() { } func (mi *messageInput) openFilePicker() { - if mi.chatView.SelectedChannel() == nil { + if mi.chat.SelectedChannel() == nil { return } @@ -715,11 +716,11 @@ func (mi *messageInput) attach(name string, reader io.Reader) { } func (mi *messageInput) ShortHelp() []keybind.Keybind { - if mi.chatView.GetVisible(mentionsListLayerName) { + if mi.chat.GetVisible(mentionsListLayerName) { cfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind} - if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { short = append(short, icfg.OpenFilePicker.Keybind) } return short @@ -727,14 +728,14 @@ func (mi *messageInput) ShortHelp() []keybind.Keybind { cfg := mi.cfg.Keybinds.MessageInput short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} - if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { short = append(short, cfg.OpenFilePicker.Keybind) } return short } func (mi *messageInput) FullHelp() [][]keybind.Keybind { - if mi.chatView.GetVisible(mentionsListLayerName) { + if mi.chat.GetVisible(mentionsListLayerName) { mcfg := mi.cfg.Keybinds.MentionsList icfg := mi.cfg.Keybinds.MessageInput return [][]keybind.Keybind{ @@ -745,7 +746,7 @@ func (mi *messageInput) FullHelp() [][]keybind.Keybind { cfg := mi.cfg.Keybinds.MessageInput openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind} - if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + if selected := mi.chat.SelectedChannel(); selected != nil && mi.chat.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { openEditor = append(openEditor, cfg.OpenFilePicker.Keybind) } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 9a8682d..1808e9f 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -43,7 +43,7 @@ import ( type messagesList struct { *tview.List cfg *config.Config - chatView *View + chatView *Model messages []discord.Message // rows is the virtual list model rendered by tview (message rows + // date-separator rows). It is rebuilt lazily when rowsDirty is true. @@ -79,7 +79,7 @@ type messagesListRow struct { timestamp discord.Timestamp } -func newMessagesList(cfg *config.Config, chatView *View) *messagesList { +func newMessagesList(cfg *config.Config, chatView *Model) *messagesList { ml := &messagesList{ List: tview.NewList(), cfg: cfg, diff --git a/internal/ui/chat/view.go b/internal/ui/chat/model.go similarity index 85% rename from internal/ui/chat/view.go rename to internal/ui/chat/model.go index 0d9c8ba..483faab 100644 --- a/internal/ui/chat/view.go +++ b/internal/ui/chat/model.go @@ -27,7 +27,7 @@ const ( channelsPickerLayerName = "channelsPicker" ) -type View struct { +type Model struct { *layers.Layers rootFlex *tview.Flex @@ -45,14 +45,17 @@ type View struct { typersMu sync.RWMutex typers map[discord.UserID]*time.Timer + confirmModalDone func(label string) + confirmModalPreviousFocus tview.Primitive + app *tview.Application cfg *config.Config state *ningen.State token string } -func NewView(app *tview.Application, cfg *config.Config, token string) *View { - v := &View{ +func NewView(app *tview.Application, cfg *config.Config, token string) *Model { + v := &Model{ Layers: layers.New(), rootFlex: tview.NewFlex(), @@ -77,19 +80,19 @@ func NewView(app *tview.Application, cfg *config.Config, token string) *View { return v } -func (v *View) SelectedChannel() *discord.Channel { +func (v *Model) SelectedChannel() *discord.Channel { v.selectedChannelMu.RLock() defer v.selectedChannelMu.RUnlock() return v.selectedChannel } -func (v *View) SetSelectedChannel(channel *discord.Channel) { +func (v *Model) SetSelectedChannel(channel *discord.Channel) { v.selectedChannelMu.Lock() v.selectedChannel = channel v.selectedChannelMu.Unlock() } -func (v *View) buildLayout() { +func (v *Model) buildLayout() { v.Clear() v.rootFlex.Clear() v.rightFlex.Clear() @@ -111,7 +114,7 @@ func (v *View) buildLayout() { v.AddLayer(v.messageInput.mentionsList, layers.WithName(mentionsListLayerName), layers.WithResize(false), layers.WithVisible(false)) } -func (v *View) togglePicker() { +func (v *Model) togglePicker() { if v.HasLayer(channelsPickerLayerName) { v.closePicker() } else { @@ -119,7 +122,7 @@ func (v *View) togglePicker() { } } -func (v *View) openPicker() { +func (v *Model) openPicker() { v.AddLayer( ui.Centered(v.channelsPicker, v.cfg.Picker.Width, v.cfg.Picker.Height), layers.WithName(channelsPickerLayerName), @@ -130,12 +133,12 @@ func (v *View) openPicker() { v.channelsPicker.update() } -func (v *View) closePicker() { +func (v *Model) closePicker() { v.RemoveLayer(channelsPickerLayerName) v.channelsPicker.Update() } -func (v *View) toggleGuildsTree() { +func (v *Model) toggleGuildsTree() { // The guilds tree is visible if the number of items is two. if v.mainFlex.GetItemCount() == 2 { v.mainFlex.RemoveItem(v.guildsTree) @@ -148,7 +151,7 @@ func (v *View) toggleGuildsTree() { } } -func (v *View) focusGuildsTree() bool { +func (v *Model) focusGuildsTree() bool { // The guilds tree is not hidden if the number of items is two. if v.mainFlex.GetItemCount() == 2 { v.app.SetFocus(v.guildsTree) @@ -158,7 +161,7 @@ func (v *View) focusGuildsTree() bool { return false } -func (v *View) focusMessageInput() bool { +func (v *Model) focusMessageInput() bool { if !v.messageInput.GetDisabled() { v.app.SetFocus(v.messageInput) return true @@ -167,7 +170,7 @@ func (v *View) focusMessageInput() bool { return false } -func (v *View) focusPrevious() { +func (v *Model) focusPrevious() { switch v.app.GetFocus() { case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is). if v.focusGuildsTree() { @@ -184,7 +187,7 @@ func (v *View) focusPrevious() { } } -func (v *View) focusNext() { +func (v *Model) focusNext() { switch v.app.GetFocus() { case v.messagesList: if v.focusMessageInput() { @@ -201,7 +204,7 @@ func (v *View) focusNext() { } } -func (v *View) HandleEvent(event tcell.Event) tview.Command { +func (v *Model) HandleEvent(event tcell.Event) tview.Command { switch event := event.(type) { case *tview.InitEvent: return tview.EventCommand(func() tcell.Event { @@ -213,8 +216,22 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command { }) case *QuitEvent: return tview.BatchCommand{ - tview.EventCommand(v.closeState), - tview.QuitCommand{}, + v.closeState(), + tview.Quit(), + } + case *tview.ModalDoneEvent: + if v.HasLayer(confirmModalLayerName) { + v.RemoveLayer(confirmModalLayerName) + if v.confirmModalPreviousFocus != nil { + v.app.SetFocus(v.confirmModalPreviousFocus) + } + onDone := v.confirmModalDone + v.confirmModalDone = nil + v.confirmModalPreviousFocus = nil + if onDone != nil { + onDone(event.ButtonLabel) + } + return tview.RedrawCommand{} } case *tview.KeyEvent: redraw := tview.RedrawCommand{} @@ -237,7 +254,7 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command { v.focusNext() return redraw case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind): - return v.logout() + return tview.BatchCommand{v.closeState(), v.logout()} case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind): v.toggleGuildsTree() return redraw @@ -250,7 +267,7 @@ func (v *View) HandleEvent(event tcell.Event) tview.Command { return v.consumeLayerCommands(cmd) } -func (v *View) consumeLayerCommands(command tview.Command) tview.Command { +func (v *Model) consumeLayerCommands(command tview.Command) tview.Command { if command == nil { return nil } @@ -298,20 +315,13 @@ func (v *View) consumeLayerCommands(command tview.Command) tview.Command { return tview.BatchCommand(remaining) } -func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { - previousFocus := v.app.GetFocus() +func (v *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { + v.confirmModalPreviousFocus = v.app.GetFocus() + v.confirmModalDone = onDone modal := tview.NewModal(). SetText(prompt). - AddButtons(buttons). - SetDoneFunc(func(_ int, buttonLabel string) { - v.RemoveLayer(confirmModalLayerName) - v.app.SetFocus(previousFocus) - - if onDone != nil { - onDone(buttonLabel) - } - }) + AddButtons(buttons) v. AddLayer( ui.Centered(modal, 0, 0), @@ -323,7 +333,7 @@ func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(lab SendToFront(confirmModalLayerName) } -func (v *View) onReadUpdate(event *read.UpdateEvent) { +func (v *Model) onReadUpdate(event *read.UpdateEvent) { v.app.QueueUpdateDraw(func() { // Use indexed node lookup to avoid walking the whole tree on every read // event. This runs frequently while reading/typing across channels. @@ -341,7 +351,7 @@ func (v *View) onReadUpdate(event *read.UpdateEvent) { }) } -func (v *View) clearTypers() { +func (v *Model) clearTypers() { v.typersMu.Lock() for _, timer := range v.typers { timer.Stop() @@ -351,7 +361,7 @@ func (v *View) clearTypers() { v.updateFooter() } -func (v *View) addTyper(userID discord.UserID) { +func (v *Model) addTyper(userID discord.UserID) { v.typersMu.Lock() typer, ok := v.typers[userID] if ok { @@ -365,7 +375,7 @@ func (v *View) addTyper(userID discord.UserID) { v.updateFooter() } -func (v *View) removeTyper(userID discord.UserID) { +func (v *Model) removeTyper(userID discord.UserID) { v.typersMu.Lock() if typer, ok := v.typers[userID]; ok { typer.Stop() @@ -375,7 +385,7 @@ func (v *View) removeTyper(userID discord.UserID) { v.updateFooter() } -func (v *View) updateFooter() { +func (v *Model) updateFooter() { selectedChannel := v.SelectedChannel() if selectedChannel == nil { return diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 7b241e4..1a317a1 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -20,7 +20,7 @@ import ( "github.com/diamondburned/ningen/v3" ) -func (v *View) OpenState(token string) error { +func (v *Model) OpenState(token string) error { identifyProps := http.IdentifyProperties() gateway.DefaultIdentity = identifyProps gateway.DefaultPresence = &gateway.UpdatePresenceCommand{ @@ -56,14 +56,14 @@ func (v *View) OpenState(token string) error { return v.state.Open(context.Background()) } -func (v *View) CloseState() error { +func (v *Model) CloseState() error { if v.state == nil { return nil } return v.state.Close() } -func (v *View) onRequest(r httpdriver.Request) error { +func (v *Model) onRequest(r httpdriver.Request) error { if req, ok := r.(*httpdriver.DefaultRequest); ok { slog.Debug("new HTTP request", "method", req.Method, "url", req.URL) } @@ -71,7 +71,7 @@ func (v *View) onRequest(r httpdriver.Request) error { return nil } -func (v *View) onRaw(event *ws.RawEvent) { +func (v *Model) onRaw(event *ws.RawEvent) { slog.Debug( "new raw event", "code", event.OriginalCode, @@ -80,7 +80,7 @@ func (v *View) onRaw(event *ws.RawEvent) { ) } -func (v *View) onReady(event *gateway.ReadyEvent) { +func (v *Model) onReady(event *gateway.ReadyEvent) { v.app.QueueUpdateDraw(func() { // Rebuild indexes from scratch so reconnects and account switches do not // retain pointers to detached tree nodes. @@ -147,7 +147,7 @@ func (v *View) onReady(event *gateway.ReadyEvent) { }) } -func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) { +func (v *Model) onMessageCreate(message *gateway.MessageCreateEvent) { selectedChannel := v.SelectedChannel() if selectedChannel != nil && selectedChannel.ID == message.ChannelID { v.removeTyper(message.Author.ID) @@ -161,7 +161,7 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) { } } -func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) { +func (v *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) { if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool { return m.ID == message.ID @@ -176,7 +176,7 @@ func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) { } } -func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) { +func (v *Model) onMessageDelete(message *gateway.MessageDeleteEvent) { if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { prevCursor := v.messagesList.Cursor() deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool { @@ -215,15 +215,15 @@ func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) { } } -func (v *View) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) { +func (v *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) { v.messagesList.setFetchingChunk(false, uint(len(event.Members))) } -func (v *View) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { +func (v *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit) } -func (v *View) onTypingStart(event *gateway.TypingStartEvent) { +func (v *Model) onTypingStart(event *gateway.TypingStartEvent) { selectedChannel := v.SelectedChannel() if selectedChannel == nil { return diff --git a/internal/ui/login/events.go b/internal/ui/login/events.go index 8fa4acf..7b765e4 100644 --- a/internal/ui/login/events.go +++ b/internal/ui/login/events.go @@ -1,14 +1,19 @@ package login -import "github.com/gdamore/tcell/v3" +import ( + "log/slog" -type TokenEvent struct { - tcell.EventTime - Token string -} + "github.com/ayn2op/discordo/internal/clipboard" + "github.com/ayn2op/tview" + "github.com/gdamore/tcell/v3" +) -func NewTokenEvent(token string) *TokenEvent { - event := &TokenEvent{Token: token} - event.SetEventNow() - return event +func setClipboard(content string) tview.Command { + return tview.EventCommand(func() tcell.Event { + if err := clipboard.Write(clipboard.FmtText, []byte(content)); err != nil { + slog.Error("failed to copy error message", "err", err) + return tcell.NewEventError(err) + } + return nil + }) } diff --git a/internal/ui/login/form.go b/internal/ui/login/form.go deleted file mode 100644 index d405851..0000000 --- a/internal/ui/login/form.go +++ /dev/null @@ -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() -} diff --git a/internal/ui/login/keybinds.go b/internal/ui/login/keybinds.go new file mode 100644 index 0000000..6b598bd --- /dev/null +++ b/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() +} diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go new file mode 100644 index 0000000..318df99 --- /dev/null +++ b/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) +} diff --git a/internal/ui/login/qr.go b/internal/ui/login/qr.go deleted file mode 100644 index 0ae79d3..0000000 --- a/internal/ui/login/qr.go +++ /dev/null @@ -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 -} diff --git a/internal/ui/login/qr/events.go b/internal/ui/login/qr/events.go new file mode 100644 index 0000000..e671d62 --- /dev/null +++ b/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)) + }) +} diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go new file mode 100644 index 0000000..3e8ebe0 --- /dev/null +++ b/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 +} diff --git a/internal/ui/login/token/events.go b/internal/ui/login/token/events.go new file mode 100644 index 0000000..abf1c68 --- /dev/null +++ b/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) + }) +} diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go new file mode 100644 index 0000000..03d9a6e --- /dev/null +++ b/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) +} diff --git a/internal/ui/root/events.go b/internal/ui/root/events.go index 0b22944..4b210cb 100644 --- a/internal/ui/root/events.go +++ b/internal/ui/root/events.go @@ -2,10 +2,10 @@ package root import ( "log/slog" - "os" "github.com/ayn2op/discordo/internal/clipboard" "github.com/ayn2op/discordo/internal/keyring" + "github.com/ayn2op/tview" "github.com/gdamore/tcell/v3" ) @@ -20,30 +20,57 @@ func newTokenEvent(token string) *tokenEvent { return event } -func getToken() tcell.Event { - token := os.Getenv(tokenEnvVarKey) - if token == "" { - tok, err := keyring.GetToken() +func tokenCommand(token string) tview.Command { + return tview.EventCommand(func() tcell.Event { + return newTokenEvent(token) + }) +} + +type loginEvent struct{ tcell.EventTime } + +func newLoginEvent() *loginEvent { + event := &loginEvent{} + event.SetEventNow() + return event +} + +func getToken() tview.Command { + return tview.EventCommand(func() tcell.Event { + token, err := keyring.GetToken() if err != nil { slog.Info("failed to retrieve token from keyring", "err", err) + return newLoginEvent() } - token = tok - } - return newTokenEvent(token) + return newTokenEvent(token) + }) } -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 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) + } + 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 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() 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 + }) } diff --git a/internal/ui/root/keybinds.go b/internal/ui/root/keybinds.go index 381098a..564ad21 100644 --- a/internal/ui/root/keybinds.go +++ b/internal/ui/root/keybinds.go @@ -5,36 +5,36 @@ import ( "github.com/ayn2op/tview/keybind" ) -var _ help.KeyMap = (*View)(nil) +var _ help.KeyMap = (*Model)(nil) -func (v *View) ShortHelp() []keybind.Keybind { +func (m *Model) ShortHelp() []keybind.Keybind { global := []keybind.Keybind{ - v.cfg.Keybinds.ToggleHelp.Keybind, - v.cfg.Keybinds.Suspend.Keybind, - v.cfg.Keybinds.Quit.Keybind, + m.cfg.Keybinds.ToggleHelp.Keybind, + m.cfg.Keybinds.Suspend.Keybind, + m.cfg.Keybinds.Quit.Keybind, } - if active := v.activeKeyMap(); active != nil { + if active := m.activeKeyMap(); active != nil { short := active.ShortHelp() return append(short, global...) } return global } -func (v *View) FullHelp() [][]keybind.Keybind { +func (m *Model) FullHelp() [][]keybind.Keybind { global := []keybind.Keybind{ - v.cfg.Keybinds.ToggleHelp.Keybind, - v.cfg.Keybinds.Suspend.Keybind, - v.cfg.Keybinds.Quit.Keybind, + m.cfg.Keybinds.ToggleHelp.Keybind, + m.cfg.Keybinds.Suspend.Keybind, + m.cfg.Keybinds.Quit.Keybind, } - if active := v.activeKeyMap(); active != nil { + if active := m.activeKeyMap(); active != nil { full := active.FullHelp() return append(full, global) } return [][]keybind.Keybind{global} } -func (v *View) activeKeyMap() help.KeyMap { - if keyMap, ok := v.inner.(help.KeyMap); ok { +func (m *Model) activeKeyMap() help.KeyMap { + if keyMap, ok := m.inner.(help.KeyMap); ok { return keyMap } return nil diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go new file mode 100644 index 0000000..9ec1340 --- /dev/null +++ b/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() + } +} diff --git a/internal/ui/root/suspend_default.go b/internal/ui/root/suspend_default.go index cf8286c..6f89647 100644 --- a/internal/ui/root/suspend_default.go +++ b/internal/ui/root/suspend_default.go @@ -2,4 +2,4 @@ package root -func (v *View) suspend() {} +func (m *Model) suspend() {} diff --git a/internal/ui/root/suspend_unix.go b/internal/ui/root/suspend_unix.go index 61921bb..39b7011 100644 --- a/internal/ui/root/suspend_unix.go +++ b/internal/ui/root/suspend_unix.go @@ -8,8 +8,8 @@ import ( "syscall" ) -func (v *View) suspend() { - v.app.Suspend(func() { +func (m *Model) suspend() { + m.app.Suspend(func() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGCONT) defer signal.Stop(c) diff --git a/internal/ui/root/view.go b/internal/ui/root/view.go deleted file mode 100644 index 1ba26de..0000000 --- a/internal/ui/root/view.go +++ /dev/null @@ -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() - } -}