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

This commit is contained in:
ayn2op 2026-03-10 18:05:36 -04:00
parent 082c018f53
commit 5917a460d3
27 changed files with 1155 additions and 940 deletions

View file

@ -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()
}

4
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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,

View file

@ -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.

View file

@ -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
})
}

View file

@ -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

View file

@ -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() {

View file

@ -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)
}

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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
})
}

View file

@ -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()
}

View file

@ -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
internal/ui/login/model.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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))
})
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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)
}

View file

@ -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
})
}

View file

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

170
internal/ui/root/model.go Normal file
View file

@ -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()
}
}

View file

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

View file

@ -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)

View file

@ -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()
}
}