Przeglądaj źródła

refactor(ui): rename chat view type and file

ayn2op 4 miesięcy temu
rodzic
commit
07fa9b172a

+ 2 - 2
internal/app/app.go

@@ -15,7 +15,7 @@ import (
 
 type App struct {
 	inner    *tview.Application
-	chatView *chat.ChatView
+	chatView *chat.View
 	cfg      *config.Config
 }
 
@@ -70,7 +70,7 @@ func (a *App) Run(token string) error {
 }
 
 func (a *App) showChatView(token string) error {
-	a.chatView = chat.NewChatView(a.inner, a.cfg, a.quit)
+	a.chatView = chat.NewView(a.inner, a.cfg, a.quit)
 	if err := a.chatView.OpenState(token); err != nil {
 		return err
 	}

+ 0 - 316
internal/ui/chat/chatview.go

@@ -1,316 +0,0 @@
-package chat
-
-import (
-	"log/slog"
-	"sync"
-
-	"github.com/ayn2op/discordo/internal/config"
-	"github.com/ayn2op/discordo/internal/keyring"
-	"github.com/ayn2op/discordo/internal/notifications"
-	"github.com/ayn2op/discordo/internal/ui"
-	"github.com/ayn2op/tview"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/gateway"
-	"github.com/diamondburned/ningen/v3"
-	"github.com/diamondburned/ningen/v3/states/read"
-	"github.com/gdamore/tcell/v3"
-)
-
-const (
-	flexPageName            = "flex"
-	mentionsListPageName    = "mentionsList"
-	attachmentsListPageName = "attachmentsList"
-	confirmModalPageName    = "confirmModal"
-)
-
-type ChatView struct {
-	*tview.Pages
-
-	mainFlex  *tview.Flex
-	rightFlex *tview.Flex
-
-	guildsTree   *guildsTree
-	messagesList *messagesList
-	messageInput *messageInput
-
-	selectedChannel   *discord.Channel
-	selectedChannelMu sync.RWMutex
-
-	app   *tview.Application
-	cfg   *config.Config
-	state *ningen.State
-
-	onLogout func()
-}
-
-func NewChatView(app *tview.Application, cfg *config.Config, onLogout func()) *ChatView {
-	chatView := &ChatView{
-		Pages: tview.NewPages(),
-
-		mainFlex:  tview.NewFlex(),
-		rightFlex: tview.NewFlex(),
-
-		app:      app,
-		cfg:      cfg,
-		onLogout: onLogout,
-	}
-	chatView.guildsTree = newGuildsTree(cfg, chatView)
-	chatView.messagesList = newMessagesList(cfg, chatView)
-	chatView.messageInput = newMessageInput(cfg, chatView)
-
-	chatView.SetInputCapture(chatView.onInputCapture)
-	chatView.buildLayout()
-	return chatView
-}
-
-func (cv *ChatView) SelectedChannel() *discord.Channel {
-	cv.selectedChannelMu.RLock()
-	defer cv.selectedChannelMu.RUnlock()
-	return cv.selectedChannel
-}
-
-func (cv *ChatView) SetSelectedChannel(channel *discord.Channel) {
-	cv.selectedChannelMu.Lock()
-	cv.selectedChannel = channel
-	cv.selectedChannelMu.Unlock()
-}
-
-func (cv *ChatView) buildLayout() {
-	cv.Clear()
-	cv.rightFlex.Clear()
-	cv.mainFlex.Clear()
-
-	cv.rightFlex.
-		SetDirection(tview.FlexRow).
-		AddItem(cv.messagesList, 0, 1, false).
-		AddItem(cv.messageInput, 3, 1, false)
-	// The guilds tree is always focused first at start-up.
-	cv.mainFlex.
-		AddItem(cv.guildsTree, 0, 1, true).
-		AddItem(cv.rightFlex, 0, 4, false)
-
-	cv.AddAndSwitchToPage(flexPageName, cv.mainFlex, true)
-}
-
-func (cv *ChatView) toggleGuildsTree() {
-	// The guilds tree is visible if the number of items is two.
-	if cv.mainFlex.GetItemCount() == 2 {
-		cv.mainFlex.RemoveItem(cv.guildsTree)
-		if cv.guildsTree.HasFocus() {
-			cv.app.SetFocus(cv.mainFlex)
-		}
-	} else {
-		cv.buildLayout()
-		cv.app.SetFocus(cv.guildsTree)
-	}
-}
-
-func (cv *ChatView) focusGuildsTree() bool {
-	// The guilds tree is not hidden if the number of items is two.
-	if cv.mainFlex.GetItemCount() == 2 {
-		cv.app.SetFocus(cv.guildsTree)
-		return true
-	}
-
-	return false
-}
-
-func (cv *ChatView) focusMessageInput() bool {
-	if !cv.messageInput.GetDisabled() {
-		cv.app.SetFocus(cv.messageInput)
-		return true
-	}
-
-	return false
-}
-
-func (cv *ChatView) focusPrevious() {
-	switch cv.app.GetFocus() {
-	case cv.guildsTree:
-		cv.focusMessageInput()
-	case cv.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
-		if ok := cv.focusGuildsTree(); !ok {
-			cv.app.SetFocus(cv.messageInput)
-		}
-	case cv.messageInput:
-		cv.app.SetFocus(cv.messagesList)
-	}
-}
-
-func (cv *ChatView) focusNext() {
-	switch cv.app.GetFocus() {
-	case cv.guildsTree:
-		cv.app.SetFocus(cv.messagesList)
-	case cv.messagesList:
-		cv.focusMessageInput()
-	case cv.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
-		if ok := cv.focusGuildsTree(); !ok {
-			cv.app.SetFocus(cv.messagesList)
-		}
-	}
-}
-
-func (cv *ChatView) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case cv.cfg.Keys.FocusGuildsTree:
-		cv.messageInput.removeMentionsList()
-		cv.focusGuildsTree()
-		return nil
-	case cv.cfg.Keys.FocusMessagesList:
-		cv.messageInput.removeMentionsList()
-		cv.app.SetFocus(cv.messagesList)
-		return nil
-	case cv.cfg.Keys.FocusMessageInput:
-		cv.focusMessageInput()
-		return nil
-	case cv.cfg.Keys.FocusPrevious:
-		cv.focusPrevious()
-		return nil
-	case cv.cfg.Keys.FocusNext:
-		cv.focusNext()
-		return nil
-	case cv.cfg.Keys.Logout:
-		if cv.onLogout != nil {
-			cv.onLogout()
-		}
-
-		if err := keyring.DeleteToken(); err != nil {
-			slog.Error("failed to delete token from keyring", "err", err)
-			return nil
-		}
-
-		return nil
-	case cv.cfg.Keys.ToggleGuildsTree:
-		cv.toggleGuildsTree()
-		return nil
-	}
-
-	return event
-}
-
-func (cv *ChatView) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
-	previousFocus := cv.app.GetFocus()
-
-	modal := tview.NewModal().
-		SetText(prompt).
-		AddButtons(buttons).
-		SetDoneFunc(func(_ int, buttonLabel string) {
-			cv.RemovePage(confirmModalPageName).SwitchToPage(flexPageName)
-			cv.app.SetFocus(previousFocus)
-
-			if onDone != nil {
-				onDone(buttonLabel)
-			}
-		})
-
-	cv.
-		AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true).
-		ShowPage(flexPageName)
-}
-
-func (cv *ChatView) onReadUpdate(event *read.UpdateEvent) {
-	var guildNode *tview.TreeNode
-	cv.guildsTree.
-		GetRoot().
-		Walk(func(node, parent *tview.TreeNode) bool {
-			switch node.GetReference() {
-			case event.GuildID:
-				node.SetTextStyle(cv.guildsTree.getGuildNodeStyle(event.GuildID))
-				guildNode = node
-				return false
-			case event.ChannelID:
-				// private channel
-				if !event.GuildID.IsValid() {
-					style := cv.guildsTree.getChannelNodeStyle(event.ChannelID)
-					node.SetTextStyle(style)
-					return false
-				}
-			}
-
-			return true
-		})
-
-	if guildNode != nil {
-		guildNode.Walk(func(node, parent *tview.TreeNode) bool {
-			if node.GetReference() == event.ChannelID {
-				node.SetTextStyle(cv.guildsTree.getChannelNodeStyle(event.ChannelID))
-				return false
-			}
-
-			return true
-		})
-	}
-
-	cv.app.Draw()
-}
-
-func (cv *ChatView) onReady(r *gateway.ReadyEvent) {
-	dmNode := tview.NewTreeNode("Direct Messages")
-	root := cv.guildsTree.
-		GetRoot().
-		ClearChildren().
-		AddChild(dmNode)
-
-	for _, folder := range r.UserSettings.GuildFolders {
-		if folder.ID == 0 && len(folder.GuildIDs) == 1 {
-			guild, err := cv.state.Cabinet.Guild(folder.GuildIDs[0])
-			if err != nil {
-				slog.Error(
-					"failed to get guild from state",
-					"guild_id",
-					folder.GuildIDs[0],
-					"err",
-					err,
-				)
-				continue
-			}
-
-			cv.guildsTree.createGuildNode(root, *guild)
-		} else {
-			cv.guildsTree.createFolderNode(folder)
-		}
-	}
-
-	cv.guildsTree.SetCurrentNode(root)
-	cv.app.SetFocus(cv.guildsTree)
-	cv.app.Draw()
-}
-
-func (cv *ChatView) onMessageCreate(message *gateway.MessageCreateEvent) {
-	if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
-		cv.messagesList.drawMessage(cv.messagesList, message.Message)
-		cv.app.Draw()
-	}
-
-	if err := notifications.Notify(cv.state, message, cv.cfg); err != nil {
-		slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
-	}
-}
-
-func (cv *ChatView) onMessageUpdate(message *gateway.MessageUpdateEvent) {
-	if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
-		cv.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
-	}
-}
-
-func (cv *ChatView) onMessageDelete(message *gateway.MessageDeleteEvent) {
-	if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
-		messages, err := cv.state.Cabinet.Messages(message.ChannelID)
-		if err != nil {
-			slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
-			return
-		}
-
-		cv.messagesList.reset()
-		cv.messagesList.drawMessages(messages)
-		cv.app.Draw()
-	}
-}
-
-func (cv *ChatView) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
-	cv.messagesList.setFetchingChunk(false, uint(len(event.Members)))
-}
-
-func (cv *ChatView) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
-	cv.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, cv.state.MemberState.SearchLimit)
-}

+ 25 - 25
internal/ui/chat/guilds_tree.go

@@ -19,14 +19,14 @@ import (
 type guildsTree struct {
 	*tview.TreeView
 	cfg  *config.Config
-	chat *ChatView
+	chatView *View
 }
 
-func newGuildsTree(cfg *config.Config, chat *ChatView) *guildsTree {
+func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree {
 	gt := &guildsTree{
 		TreeView: tview.NewTreeView(),
 		cfg:      cfg,
-		chat:     chat,
+		chatView: chatView,
 	}
 
 	gt.Box = ui.ConfigureBox(gt.Box, &cfg.Theme)
@@ -52,7 +52,7 @@ func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) {
 	gt.GetRoot().AddChild(folderNode)
 
 	for _, gID := range folder.GuildIDs {
-		guild, err := gt.chat.state.Cabinet.Guild(gID)
+		guild, err := gt.chatView.state.Cabinet.Guild(gID)
 		if err != nil {
 			slog.Error("failed to get guild from state", "guild_id", gID, "err", err)
 			continue
@@ -78,12 +78,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl
 }
 
 func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style {
-	indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}})
+	indication := gt.chatView.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.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
+	indication := gt.chatView.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
 	return gt.unreadStyle(indication)
 }
 
@@ -95,7 +95,7 @@ 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 && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
+	if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
 		return
 	}
 
@@ -151,9 +151,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 
 	switch ref := node.GetReference().(type) {
 	case discord.GuildID:
-		go gt.chat.state.MemberState.Subscribe(ref)
+		go gt.chatView.state.MemberState.Subscribe(ref)
 
-		channels, err := gt.chat.state.Cabinet.Channels(ref)
+		channels, err := gt.chatView.state.Cabinet.Channels(ref)
 		if err != nil {
 			slog.Error("failed to get channels", "err", err, "guild_id", ref)
 			return
@@ -165,7 +165,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 
 		gt.createChannelNodes(node, channels)
 	case discord.ChannelID:
-		channel, err := gt.chat.state.Cabinet.Channel(ref)
+		channel, err := gt.chatView.state.Cabinet.Channel(ref)
 		if err != nil {
 			slog.Error("failed to get channel", "channel_id", ref)
 			return
@@ -174,7 +174,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.chat.state.Cabinet.Channels(channel.GuildID)
+			allChannels, err := gt.chatView.state.Cabinet.Channels(channel.GuildID)
 			if err != nil {
 				slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID)
 				return
@@ -200,38 +200,38 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
 			return
 		}
 
-		go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
+		go gt.chatView.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
 
-		messages, err := gt.chat.state.Messages(channel.ID, uint(gt.cfg.MessagesLimit))
+		messages, err := gt.chatView.state.Messages(channel.ID, uint(gt.cfg.MessagesLimit))
 		if err != nil {
 			slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", gt.cfg.MessagesLimit)
 			return
 		}
 
 		if guildID := channel.GuildID; guildID.IsValid() {
-			gt.chat.messagesList.requestGuildMembers(guildID, messages)
+			gt.chatView.messagesList.requestGuildMembers(guildID, messages)
 		}
 
-		gt.chat.SetSelectedChannel(channel)
+		gt.chatView.SetSelectedChannel(channel)
 
-		gt.chat.messagesList.reset()
-		gt.chat.messagesList.setTitle(*channel)
-		gt.chat.messagesList.drawMessages(messages)
-		gt.chat.messagesList.ScrollToEnd()
+		gt.chatView.messagesList.reset()
+		gt.chatView.messagesList.setTitle(*channel)
+		gt.chatView.messagesList.drawMessages(messages)
+		gt.chatView.messagesList.ScrollToEnd()
 
-		hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
-		gt.chat.messageInput.SetDisabled(hasNoPerm)
+		hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chatView.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
+		gt.chatView.messageInput.SetDisabled(hasNoPerm)
 		if hasNoPerm {
-			gt.chat.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.")
+			gt.chatView.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.")
 		} else {
-			gt.chat.messageInput.SetPlaceholder("Message...")
+			gt.chatView.messageInput.SetPlaceholder("Message...")
 			if gt.cfg.AutoFocus {
-				gt.chat.app.SetFocus(gt.chat.messageInput)
+				gt.chatView.app.SetFocus(gt.chatView.messageInput)
 			}
 		}
 
 	case nil: // Direct messages folder
-		channels, err := gt.chat.state.PrivateChannels()
+		channels, err := gt.chatView.state.PrivateChannels()
 		if err != nil {
 			slog.Error("failed to get private channels", "err", err)
 			return

+ 43 - 43
internal/ui/chat/message_input.go

@@ -40,7 +40,7 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
 type messageInput struct {
 	*tview.TextArea
 	cfg  *config.Config
-	chat *ChatView
+	chatView *View
 
 	edit            bool
 	sendMessageData *api.SendMessageData
@@ -49,11 +49,11 @@ type messageInput struct {
 	lastSearch      time.Time
 }
 
-func newMessageInput(cfg *config.Config, chat *ChatView) *messageInput {
+func newMessageInput(cfg *config.Config, chatView *View) *messageInput {
 	mi := &messageInput{
 		TextArea:        tview.NewTextArea(),
 		cfg:             cfg,
-		chat:            chat,
+		chatView: chatView,
 		sendMessageData: &api.SendMessageData{},
 		cache:           cache.NewCache(),
 		mentionsList:    tview.NewList(),
@@ -96,7 +96,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 		return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)
 
 	case mi.cfg.Keys.MessageInput.Send:
-		if mi.chat.GetVisibile(mentionsListPageName) {
+		if mi.chatView.GetVisibile(mentionsListPageName) {
 			mi.tabComplete()
 			return nil
 		}
@@ -112,7 +112,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 		mi.openFilePicker()
 		return nil
 	case mi.cfg.Keys.MessageInput.Cancel:
-		if mi.chat.GetVisibile(mentionsListPageName) {
+		if mi.chatView.GetVisibile(mentionsListPageName) {
 			mi.stopTabCompletion()
 		} else {
 			mi.reset()
@@ -120,12 +120,12 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 
 		return nil
 	case mi.cfg.Keys.MessageInput.TabComplete:
-		go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() })
+		go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() })
 		return nil
 	}
 
 	if mi.cfg.AutocompleteLimit > 0 {
-		if mi.chat.GetVisibile(mentionsListPageName) {
+		if mi.chatView.GetVisibile(mentionsListPageName) {
 			switch event.Name() {
 			case mi.cfg.Keys.MentionsList.Up:
 				mi.mentionsList.InputHandler()(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
@@ -136,7 +136,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 			}
 		}
 
-		go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
+		go mi.chatView.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
 	}
 
 	return event
@@ -150,7 +150,7 @@ func (mi *messageInput) paste() {
 }
 
 func (mi *messageInput) send() {
-	selected := mi.chat.SelectedChannel()
+	selected := mi.chatView.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -169,17 +169,17 @@ func (mi *messageInput) send() {
 		}
 	}()
 
-	text = processText(mi.chat.state, selected, []byte(text))
+	text = processText(mi.chatView.state, selected, []byte(text))
 
 	if mi.edit {
-		m, err := mi.chat.messagesList.selectedMessage()
+		m, err := mi.chatView.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.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
+		if _, err := mi.chatView.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
 			slog.Error("failed to edit message", "err", err)
 		}
 
@@ -187,14 +187,14 @@ func (mi *messageInput) send() {
 	} else {
 		data := mi.sendMessageData
 		data.Content = text
-		if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil {
+		if _, err := mi.chatView.state.SendMessageComplex(selected.ID, *data); err != nil {
 			slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err)
 		}
 	}
 
 	mi.reset()
-	mi.chat.messagesList.Highlight()
-	mi.chat.messagesList.ScrollToEnd()
+	mi.chatView.messagesList.Highlight()
+	mi.chatView.messagesList.ScrollToEnd()
 }
 
 func processText(state *ningen.State, channel *discord.Channel, src []byte) string {
@@ -269,7 +269,7 @@ func (mi *messageInput) tabComplete() {
 	}
 	pos := posEnd - (len(name) + 1)
 
-	selected := mi.chat.SelectedChannel()
+	selected := mi.chatView.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -284,7 +284,7 @@ func (mi *messageInput) tabComplete() {
 			}
 		} else {
 			mi.searchMember(gID, name)
-			members, err := mi.chat.state.Cabinet.Members(gID)
+			members, err := mi.chatView.state.Cabinet.Members(gID)
 			if err != nil {
 				slog.Error("failed to get members from state", "guild_id", gID, "err", err)
 				return
@@ -292,7 +292,7 @@ func (mi *messageInput) tabComplete() {
 
 			res := fuzzy.FindFrom(name, memberList(members))
 			for _, r := range res {
-				if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) {
+				if channelHasUser(mi.chatView.state, selected.ID, members[r.Index].User.ID) {
 					mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
 					return
 				}
@@ -316,7 +316,7 @@ func (mi *messageInput) tabSuggestion() {
 		mi.stopTabCompletion()
 		return
 	}
-	selected := mi.chat.SelectedChannel()
+	selected := mi.chatView.SelectedChannel()
 	if selected == nil {
 		return
 	}
@@ -329,7 +329,7 @@ func (mi *messageInput) tabSuggestion() {
 	if name == "" {
 		shown = make(map[string]struct{})
 		// Don't show @me in the list of recent authors
-		me, err := mi.chat.state.Cabinet.Me()
+		me, err := mi.chatView.state.Cabinet.Me()
 		if err != nil {
 			slog.Error("failed to get client user (me)", "err", err)
 		} else {
@@ -340,7 +340,7 @@ func (mi *messageInput) tabSuggestion() {
 	// DMs have recipients, not members
 	if !gID.IsValid() {
 		if name == "" { // show recent messages' authors
-			msgs, err := mi.chat.state.Cabinet.Messages(cID)
+			msgs, err := mi.chatView.state.Cabinet.Messages(cID)
 			if err != nil {
 				return
 			}
@@ -353,7 +353,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 		} else {
 			users := selected.DMRecipients
-			me, err := mi.chat.state.Cabinet.Me()
+			me, err := mi.chatView.state.Cabinet.Me()
 			if err != nil {
 				slog.Error("failed to get client user (me)", "err", err)
 			} else {
@@ -365,7 +365,7 @@ func (mi *messageInput) tabSuggestion() {
 			}
 		}
 	} else if name == "" { // show recent messages' authors
-		msgs, err := mi.chat.state.Cabinet.Messages(cID)
+		msgs, err := mi.chatView.state.Cabinet.Messages(cID)
 		if err != nil {
 			return
 		}
@@ -374,8 +374,8 @@ func (mi *messageInput) tabSuggestion() {
 				continue
 			}
 			shown[m.Author.Username] = userDone
-			mi.chat.state.MemberState.RequestMember(gID, m.Author.ID)
-			if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil {
+			mi.chatView.state.MemberState.RequestMember(gID, m.Author.ID)
+			if mem, err := mi.chatView.state.Cabinet.Member(gID, m.Author.ID); err == nil {
 				if mi.addMentionMember(gID, mem) {
 					break
 				}
@@ -383,7 +383,7 @@ func (mi *messageInput) tabSuggestion() {
 		}
 	} else {
 		mi.searchMember(gID, name)
-		mems, err := mi.chat.state.Cabinet.Members(gID)
+		mems, err := mi.chatView.state.Cabinet.Members(gID)
 		if err != nil {
 			slog.Error("fetching members failed", "err", err)
 			return
@@ -393,7 +393,7 @@ func (mi *messageInput) tabSuggestion() {
 			res = res[:int(mi.cfg.AutocompleteLimit)]
 		}
 		for _, r := range res {
-			if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) &&
+			if channelHasUser(mi.chatView.state, cID, mems[r.Index].User.ID) &&
 				mi.addMentionMember(gID, &mems[r.Index]) {
 				break
 			}
@@ -447,22 +447,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.chat.state.MemberState.SearchLimit {
+		if c := mi.cache.Get(k); c < mi.chatView.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.chat.state.MemberState.SearchFrequency).After(time.Now()) {
+	if mi.lastSearch.Add(mi.chatView.state.MemberState.SearchFrequency).After(time.Now()) {
 		return
 	}
 
 	mi.lastSearch = time.Now()
-	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())
+	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())
 }
 
 func (mi *messageInput) showMentionList() {
@@ -473,7 +473,7 @@ func (mi *messageInput) showMentionList() {
 	l := mi.mentionsList
 	x, _, _, _ := mi.GetInnerRect()
 	_, y, _, _ := mi.GetRect()
-	_, _, maxW, maxH := mi.chat.messagesList.GetInnerRect()
+	_, _, maxW, maxH := mi.chatView.messagesList.GetInnerRect()
 	if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
 		maxH = min(maxH, t)
 	}
@@ -496,10 +496,10 @@ func (mi *messageInput) showMentionList() {
 
 	l.SetRect(x, y, w, h)
 
-	mi.chat.
+	mi.chatView.
 		AddAndSwitchToPage(mentionsListPageName, l, false).
 		ShowPage(flexPageName)
-	mi.chat.app.SetFocus(mi)
+	mi.chatView.app.SetFocus(mi)
 }
 
 func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
@@ -514,14 +514,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.chat.state.Cabinet.Role(gID, id)
+		r, _ := mi.chatView.state.Cabinet.Role(gID, id)
 		return r
 	})
 	if ok {
 		name = fmt.Sprintf("[%s]%s[-]", color, name)
 	}
 
-	presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID)
+	presence, err := mi.chatView.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 {
@@ -538,7 +538,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 	}
 
 	name := user.DisplayOrUsername()
-	presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID)
+	presence, err := mi.chatView.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 {
@@ -550,7 +550,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
 
 // used by chatView
 func (mi *messageInput) removeMentionsList() {
-	mi.chat.
+	mi.chatView.
 		RemovePage(mentionsListPageName).
 		SwitchToPage(flexPageName)
 }
@@ -559,7 +559,7 @@ func (mi *messageInput) stopTabCompletion() {
 	if mi.cfg.AutocompleteLimit > 0 {
 		mi.mentionsList.Clear()
 		mi.removeMentionsList()
-		mi.chat.app.SetFocus(mi)
+		mi.chatView.app.SetFocus(mi)
 	}
 }
 
@@ -579,7 +579,7 @@ func (mi *messageInput) editor() {
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 
-	mi.chat.app.Suspend(func() {
+	mi.chatView.app.Suspend(func() {
 		err := cmd.Run()
 		if err != nil {
 			slog.Error("failed to run command", "args", cmd.Args, "err", err)
@@ -597,7 +597,7 @@ func (mi *messageInput) editor() {
 }
 
 func (mi *messageInput) openFilePicker() {
-	if mi.chat.SelectedChannel() == nil {
+	if mi.chatView.SelectedChannel() == nil {
 		return
 	}
 

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

@@ -36,7 +36,7 @@ import (
 type messagesList struct {
 	*tview.TextView
 	cfg               *config.Config
-	chat              *ChatView
+	chatView *View
 	selectedMessageID discord.MessageID
 
 	renderer *markdown.Renderer
@@ -49,11 +49,11 @@ type messagesList struct {
 	}
 }
 
-func newMessagesList(cfg *config.Config, chat *ChatView) *messagesList {
+func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
 	ml := &messagesList{
 		TextView: tview.NewTextView(),
 		cfg:      cfg,
-		chat:     chat,
+		chatView: chatView,
 		renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
 	}
 
@@ -100,7 +100,7 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
 	fmt.Fprintf(writer, `["%s"]`, message.ID)
 
 	if ml.cfg.HideBlockedUsers {
-		isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID)
+		isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
 		if isBlocked {
 			io.WriteString(writer, "[:red:b]Blocked message[:-:-]")
 			return
@@ -148,7 +148,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
 
 	// Webhooks do not have nicknames or roles.
 	if message.GuildID.IsValid() && !message.WebhookID.IsValid() {
-		member, err := ml.chat.state.Cabinet.Member(message.GuildID, message.Author.ID)
+		member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
 		if err != nil {
 			slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
 		} else {
@@ -157,7 +157,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
 			}
 
 			color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
-				r, _ := ml.chat.state.Cabinet.Role(message.GuildID, id)
+				r, _ := ml.chatView.state.Cabinet.Role(message.GuildID, id)
 				return r
 			})
 			if ok {
@@ -171,8 +171,8 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
 
 func (ml *messagesList) drawContent(w io.Writer, message discord.Message) {
 	c := []byte(tview.Escape(message.Content))
-	if ml.chat.cfg.Markdown {
-		ast := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false)
+	if ml.chatView.cfg.Markdown {
+		ast := discordmd.ParseWithMessage(c, *ml.chatView.state.Cabinet, &message, false)
 		ml.renderer.Render(w, c, ast)
 	} else {
 		w.Write(c) // write the content as is
@@ -243,12 +243,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
 		return nil, errors.New("no message is currently selected")
 	}
 
-	selected := ml.chat.SelectedChannel()
+	selected := ml.chatView.SelectedChannel()
 	if selected == nil {
 		return nil, errors.New("no channel is currently selected")
 	}
 
-	m, err := ml.chat.state.Cabinet.Message(selected.ID, ml.selectedMessageID)
+	m, err := ml.chatView.state.Cabinet.Message(selected.ID, ml.selectedMessageID)
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve selected message: %w", err)
 	}
@@ -288,12 +288,12 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 }
 
 func (ml *messagesList) _select(name string) {
-	selectedChannel := ml.chat.SelectedChannel()
+	selectedChannel := ml.chatView.SelectedChannel()
 	if selectedChannel == nil {
 		return
 	}
 
-	messages, err := ml.chat.state.Cabinet.Messages(selectedChannel.ID)
+	messages, err := ml.chatView.state.Cabinet.Messages(selectedChannel.ID)
 	if err != nil {
 		slog.Error("failed to get messages", "err", err, "channel_id", selectedChannel.ID)
 		return
@@ -455,8 +455,8 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 		SetHighlightFullLine(true).
 		ShowSecondaryText(false).
 		SetDoneFunc(func() {
-			ml.chat.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName)
-			ml.chat.app.SetFocus(ml)
+			ml.chatView.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName)
+			ml.chatView.app.SetFocus(ml)
 		})
 	list.
 		SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@@ -491,7 +491,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 		})
 	}
 
-	ml.chat.
+	ml.chatView.
 		AddAndSwitchToPage(attachmentsListPageName, ui.Centered(list, 0, 0), true).
 		ShowPage(flexPageName)
 }
@@ -544,7 +544,7 @@ func (ml *messagesList) reply(mention bool) {
 
 	name := msg.Author.DisplayOrUsername()
 	if msg.GuildID.IsValid() {
-		member, err := ml.chat.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
+		member, err := ml.chatView.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
 		if err != nil {
 			slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err)
 		} else {
@@ -554,7 +554,7 @@ func (ml *messagesList) reply(mention bool) {
 		}
 	}
 
-	data := ml.chat.messageInput.sendMessageData
+	data := ml.chatView.messageInput.sendMessageData
 	data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID}
 	data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
 
@@ -564,9 +564,9 @@ func (ml *messagesList) reply(mention bool) {
 		title = "[@] " + title
 	}
 
-	ml.chat.messageInput.sendMessageData = data
-	ml.chat.messageInput.SetTitle(title + name)
-	ml.chat.app.SetFocus(ml.chat.messageInput)
+	ml.chatView.messageInput.sendMessageData = data
+	ml.chatView.messageInput.SetTitle(title + name)
+	ml.chatView.app.SetFocus(ml.chatView.messageInput)
 }
 
 func (ml *messagesList) edit() {
@@ -576,7 +576,7 @@ func (ml *messagesList) edit() {
 		return
 	}
 
-	me, err := ml.chat.state.Cabinet.Me()
+	me, err := ml.chatView.state.Cabinet.Me()
 	if err != nil {
 		slog.Error("failed to get client user (me)", "err", err)
 		return
@@ -587,10 +587,10 @@ func (ml *messagesList) edit() {
 		return
 	}
 
-	ml.chat.messageInput.SetTitle("Editing")
-	ml.chat.messageInput.edit = true
-	ml.chat.messageInput.SetText(message.Content, true)
-	ml.chat.app.SetFocus(ml.chat.messageInput)
+	ml.chatView.messageInput.SetTitle("Editing")
+	ml.chatView.messageInput.edit = true
+	ml.chatView.messageInput.SetText(message.Content, true)
+	ml.chatView.app.SetFocus(ml.chatView.messageInput)
 }
 
 func (ml *messagesList) confirmDelete() {
@@ -600,7 +600,7 @@ func (ml *messagesList) confirmDelete() {
 		}
 	}
 
-	ml.chat.showConfirmModal(
+	ml.chatView.showConfirmModal(
 		"Are you sure you want to delete this message?",
 		[]string{"Yes", "No"},
 		onChoice,
@@ -615,24 +615,24 @@ func (ml *messagesList) delete() {
 	}
 
 	if msg.GuildID.IsValid() {
-		me, err := ml.chat.state.Cabinet.Me()
+		me, err := ml.chatView.state.Cabinet.Me()
 		if err != nil {
 			slog.Error("failed to get client user (me)", "err", err)
 			return
 		}
 
-		if msg.Author.ID != me.ID && !ml.chat.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
+		if msg.Author.ID != me.ID && !ml.chatView.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
 			slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID)
 			return
 		}
 	}
 
-	selected := ml.chat.SelectedChannel()
+	selected := ml.chatView.SelectedChannel()
 	if selected == nil {
 		return
 	}
 
-	if err := ml.chat.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil {
+	if err := ml.chatView.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil {
 		slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
 		return
 	}
@@ -640,7 +640,7 @@ func (ml *messagesList) delete() {
 	ml.selectedMessageID = 0
 	ml.Highlight()
 
-	if err := ml.chat.state.MessageRemove(selected.ID, msg.ID); err != nil {
+	if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil {
 		slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
 		return
 	}
@@ -657,13 +657,13 @@ func (ml *messagesList) requestGuildMembers(gID discord.GuildID, ms []discord.Me
 			continue
 		}
 
-		if member, _ := ml.chat.state.Cabinet.Member(gID, m.Author.ID); member == nil {
+		if member, _ := ml.chatView.state.Cabinet.Member(gID, m.Author.ID); member == nil {
 			usersToFetch = append(usersToFetch, m.Author.ID)
 		}
 	}
 
 	if len(usersToFetch) > 0 {
-		err := ml.chat.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
+		err := ml.chatView.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
 			GuildIDs: []discord.GuildID{gID},
 			UserIDs:  slices.Compact(usersToFetch),
 		})

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

@@ -16,11 +16,11 @@ import (
 	"github.com/diamondburned/ningen/v3"
 )
 
-func (cv *ChatView) OpenState(token string) error {
+func (v *View) OpenState(token string) error {
 	identifyProps := http.IdentifyProperties()
 	gateway.DefaultIdentity = identifyProps
 	gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
-		Status: cv.cfg.Status,
+		Status: v.cfg.Status,
 	}
 
 	id := gateway.DefaultIdentifier(token)
@@ -28,34 +28,34 @@ func (cv *ChatView) OpenState(token string) error {
 
 	session := session.NewCustom(id, http.NewClient(token), handler.New())
 	state := state.NewFromSession(session, defaultstore.New())
-	cv.state = ningen.FromState(state)
+	v.state = ningen.FromState(state)
 
 	// Handlers
-	cv.state.AddHandler(cv.onRaw)
-	cv.state.AddHandler(cv.onReady)
-	cv.state.AddHandler(cv.onMessageCreate)
-	cv.state.AddHandler(cv.onMessageUpdate)
-	cv.state.AddHandler(cv.onMessageDelete)
-	cv.state.AddHandler(cv.onReadUpdate)
-	cv.state.AddHandler(cv.onGuildMembersChunk)
-	cv.state.AddHandler(cv.onGuildMemberRemove)
+	v.state.AddHandler(v.onRaw)
+	v.state.AddHandler(v.onReady)
+	v.state.AddHandler(v.onMessageCreate)
+	v.state.AddHandler(v.onMessageUpdate)
+	v.state.AddHandler(v.onMessageDelete)
+	v.state.AddHandler(v.onReadUpdate)
+	v.state.AddHandler(v.onGuildMembersChunk)
+	v.state.AddHandler(v.onGuildMemberRemove)
 
-	cv.state.StateLog = func(err error) {
+	v.state.StateLog = func(err error) {
 		slog.Error("state log", "err", err)
 	}
 
-	cv.state.OnRequest = append(cv.state.OnRequest, httputil.WithHeaders(http.Headers()), cv.onRequest)
-	return cv.state.Open(context.TODO())
+	v.state.OnRequest = append(v.state.OnRequest, httputil.WithHeaders(http.Headers()), v.onRequest)
+	return v.state.Open(context.TODO())
 }
 
-func (cv *ChatView) CloseState() error {
-	if cv.state == nil {
+func (v *View) CloseState() error {
+	if v.state == nil {
 		return nil
 	}
-	return cv.state.Close()
+	return v.state.Close()
 }
 
-func (cv *ChatView) onRequest(r httpdriver.Request) error {
+func (v *View) onRequest(r httpdriver.Request) error {
 	if req, ok := r.(*httpdriver.DefaultRequest); ok {
 		slog.Debug("new HTTP request", "method", req.Method, "url", req.URL)
 	}
@@ -63,7 +63,7 @@ func (cv *ChatView) onRequest(r httpdriver.Request) error {
 	return nil
 }
 
-func (cv *ChatView) onRaw(event *ws.RawEvent) {
+func (v *View) onRaw(event *ws.RawEvent) {
 	slog.Debug(
 		"new raw event",
 		"code", event.OriginalCode,

+ 316 - 0
internal/ui/chat/view.go

@@ -0,0 +1,316 @@
+package chat
+
+import (
+	"log/slog"
+	"sync"
+
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/discordo/internal/keyring"
+	"github.com/ayn2op/discordo/internal/notifications"
+	"github.com/ayn2op/discordo/internal/ui"
+	"github.com/ayn2op/tview"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/gateway"
+	"github.com/diamondburned/ningen/v3"
+	"github.com/diamondburned/ningen/v3/states/read"
+	"github.com/gdamore/tcell/v3"
+)
+
+const (
+	flexPageName            = "flex"
+	mentionsListPageName    = "mentionsList"
+	attachmentsListPageName = "attachmentsList"
+	confirmModalPageName    = "confirmModal"
+)
+
+type View struct {
+	*tview.Pages
+
+	mainFlex  *tview.Flex
+	rightFlex *tview.Flex
+
+	guildsTree   *guildsTree
+	messagesList *messagesList
+	messageInput *messageInput
+
+	selectedChannel   *discord.Channel
+	selectedChannelMu sync.RWMutex
+
+	app   *tview.Application
+	cfg   *config.Config
+	state *ningen.State
+
+	onLogout func()
+}
+
+func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View {
+	v := &View{
+		Pages: tview.NewPages(),
+
+		mainFlex:  tview.NewFlex(),
+		rightFlex: tview.NewFlex(),
+
+		app:      app,
+		cfg:      cfg,
+		onLogout: onLogout,
+	}
+	v.guildsTree = newGuildsTree(cfg, v)
+	v.messagesList = newMessagesList(cfg, v)
+	v.messageInput = newMessageInput(cfg, v)
+
+	v.SetInputCapture(v.onInputCapture)
+	v.buildLayout()
+	return v
+}
+
+func (v *View) SelectedChannel() *discord.Channel {
+	v.selectedChannelMu.RLock()
+	defer v.selectedChannelMu.RUnlock()
+	return v.selectedChannel
+}
+
+func (v *View) SetSelectedChannel(channel *discord.Channel) {
+	v.selectedChannelMu.Lock()
+	v.selectedChannel = channel
+	v.selectedChannelMu.Unlock()
+}
+
+func (v *View) buildLayout() {
+	v.Clear()
+	v.rightFlex.Clear()
+	v.mainFlex.Clear()
+
+	v.rightFlex.
+		SetDirection(tview.FlexRow).
+		AddItem(v.messagesList, 0, 1, false).
+		AddItem(v.messageInput, 3, 1, false)
+	// The guilds tree is always focused first at start-up.
+	v.mainFlex.
+		AddItem(v.guildsTree, 0, 1, true).
+		AddItem(v.rightFlex, 0, 4, false)
+
+	v.AddAndSwitchToPage(flexPageName, v.mainFlex, true)
+}
+
+func (v *View) toggleGuildsTree() {
+	// The guilds tree is visible if the number of items is two.
+	if v.mainFlex.GetItemCount() == 2 {
+		v.mainFlex.RemoveItem(v.guildsTree)
+		if v.guildsTree.HasFocus() {
+			v.app.SetFocus(v.mainFlex)
+		}
+	} else {
+		v.buildLayout()
+		v.app.SetFocus(v.guildsTree)
+	}
+}
+
+func (v *View) 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)
+		return true
+	}
+
+	return false
+}
+
+func (v *View) focusMessageInput() bool {
+	if !v.messageInput.GetDisabled() {
+		v.app.SetFocus(v.messageInput)
+		return true
+	}
+
+	return false
+}
+
+func (v *View) focusPrevious() {
+	switch v.app.GetFocus() {
+	case v.guildsTree:
+		v.focusMessageInput()
+	case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
+		if ok := v.focusGuildsTree(); !ok {
+			v.app.SetFocus(v.messageInput)
+		}
+	case v.messageInput:
+		v.app.SetFocus(v.messagesList)
+	}
+}
+
+func (v *View) focusNext() {
+	switch v.app.GetFocus() {
+	case v.guildsTree:
+		v.app.SetFocus(v.messagesList)
+	case v.messagesList:
+		v.focusMessageInput()
+	case v.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
+		if ok := v.focusGuildsTree(); !ok {
+			v.app.SetFocus(v.messagesList)
+		}
+	}
+}
+
+func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch event.Name() {
+	case v.cfg.Keys.FocusGuildsTree:
+		v.messageInput.removeMentionsList()
+		v.focusGuildsTree()
+		return nil
+	case v.cfg.Keys.FocusMessagesList:
+		v.messageInput.removeMentionsList()
+		v.app.SetFocus(v.messagesList)
+		return nil
+	case v.cfg.Keys.FocusMessageInput:
+		v.focusMessageInput()
+		return nil
+	case v.cfg.Keys.FocusPrevious:
+		v.focusPrevious()
+		return nil
+	case v.cfg.Keys.FocusNext:
+		v.focusNext()
+		return nil
+	case v.cfg.Keys.Logout:
+		if v.onLogout != nil {
+			v.onLogout()
+		}
+
+		if err := keyring.DeleteToken(); err != nil {
+			slog.Error("failed to delete token from keyring", "err", err)
+			return nil
+		}
+
+		return nil
+	case v.cfg.Keys.ToggleGuildsTree:
+		v.toggleGuildsTree()
+		return nil
+	}
+
+	return event
+}
+
+func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
+	previousFocus := v.app.GetFocus()
+
+	modal := tview.NewModal().
+		SetText(prompt).
+		AddButtons(buttons).
+		SetDoneFunc(func(_ int, buttonLabel string) {
+			v.RemovePage(confirmModalPageName).SwitchToPage(flexPageName)
+			v.app.SetFocus(previousFocus)
+
+			if onDone != nil {
+				onDone(buttonLabel)
+			}
+		})
+
+	v.
+		AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true).
+		ShowPage(flexPageName)
+}
+
+func (v *View) onReadUpdate(event *read.UpdateEvent) {
+	var guildNode *tview.TreeNode
+	v.guildsTree.
+		GetRoot().
+		Walk(func(node, parent *tview.TreeNode) bool {
+			switch node.GetReference() {
+			case event.GuildID:
+				node.SetTextStyle(v.guildsTree.getGuildNodeStyle(event.GuildID))
+				guildNode = node
+				return false
+			case event.ChannelID:
+				// private channel
+				if !event.GuildID.IsValid() {
+					style := v.guildsTree.getChannelNodeStyle(event.ChannelID)
+					node.SetTextStyle(style)
+					return false
+				}
+			}
+
+			return true
+		})
+
+	if guildNode != nil {
+		guildNode.Walk(func(node, parent *tview.TreeNode) bool {
+			if node.GetReference() == event.ChannelID {
+				node.SetTextStyle(v.guildsTree.getChannelNodeStyle(event.ChannelID))
+				return false
+			}
+
+			return true
+		})
+	}
+
+	v.app.Draw()
+}
+
+func (v *View) onReady(r *gateway.ReadyEvent) {
+	dmNode := tview.NewTreeNode("Direct Messages")
+	root := v.guildsTree.
+		GetRoot().
+		ClearChildren().
+		AddChild(dmNode)
+
+	for _, folder := range r.UserSettings.GuildFolders {
+		if folder.ID == 0 && len(folder.GuildIDs) == 1 {
+			guild, err := v.state.Cabinet.Guild(folder.GuildIDs[0])
+			if err != nil {
+				slog.Error(
+					"failed to get guild from state",
+					"guild_id",
+					folder.GuildIDs[0],
+					"err",
+					err,
+				)
+				continue
+			}
+
+			v.guildsTree.createGuildNode(root, *guild)
+		} else {
+			v.guildsTree.createFolderNode(folder)
+		}
+	}
+
+	v.guildsTree.SetCurrentNode(root)
+	v.app.SetFocus(v.guildsTree)
+	v.app.Draw()
+}
+
+func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
+	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+		v.messagesList.drawMessage(v.messagesList, message.Message)
+		v.app.Draw()
+	}
+
+	if err := notifications.Notify(v.state, message, v.cfg); err != nil {
+		slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
+	}
+}
+
+func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) {
+	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+		v.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
+	}
+}
+
+func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) {
+	if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+		messages, err := v.state.Cabinet.Messages(message.ChannelID)
+		if err != nil {
+			slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
+			return
+		}
+
+		v.messagesList.reset()
+		v.messagesList.drawMessages(messages)
+		v.app.Draw()
+	}
+}
+
+func (v *View) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
+	v.messagesList.setFetchingChunk(false, uint(len(event.Members)))
+}
+
+func (v *View) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
+	v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit)
+}