Browse Source

refactor(ui/chat): inline state close command

ayn2op 1 month ago
parent
commit
764d4b7a0a
4 changed files with 215 additions and 220 deletions
  1. 7 5
      internal/ui/chat/events.go
  2. 23 23
      internal/ui/chat/keybinds.go
  3. 128 128
      internal/ui/chat/model.go
  4. 57 64
      internal/ui/chat/state.go

+ 7 - 5
internal/ui/chat/events.go

@@ -9,7 +9,7 @@ import (
 
 type LogoutEvent struct{ tcell.EventTime }
 
-func (v *Model) logout() tview.Command {
+func (m *Model) logout() tview.Command {
 	return func() tcell.Event {
 		return &LogoutEvent{}
 	}
@@ -17,11 +17,13 @@ func (v *Model) logout() tview.Command {
 
 type QuitEvent struct{ tcell.EventTime }
 
-func (v *Model) closeState() tview.Command {
+func (m *Model) closeState() tview.Command {
 	return func() tcell.Event {
-		if err := v.CloseState(); err != nil {
-			slog.Error("failed to close the session", "err", err)
-			return tcell.NewEventError(err)
+		if m.state != nil {
+			if err := m.state.Close(); err != nil {
+				slog.Error("failed to close the session", "err", err)
+				return tcell.NewEventError(err)
+			}
 		}
 		return nil
 	}

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

@@ -7,59 +7,59 @@ import (
 
 var _ help.KeyMap = (*Model)(nil)
 
-func (v *Model) ShortHelp() []keybind.Keybind {
+func (m *Model) ShortHelp() []keybind.Keybind {
 	short := make([]keybind.Keybind, 0, 16)
-	if active := v.activeKeyMap(); active != nil {
+	if active := m.activeKeyMap(); active != nil {
 		short = append(short, active.ShortHelp()...)
 	}
-	short = append(short, v.baseShortHelp()...)
+	short = append(short, m.baseShortHelp()...)
 	return short
 }
 
-func (v *Model) FullHelp() [][]keybind.Keybind {
+func (m *Model) FullHelp() [][]keybind.Keybind {
 	full := make([][]keybind.Keybind, 0, 8)
-	if active := v.activeKeyMap(); active != nil {
+	if active := m.activeKeyMap(); active != nil {
 		full = append(full, active.FullHelp()...)
 	}
-	full = append(full, v.baseFullHelp()...)
+	full = append(full, m.baseFullHelp()...)
 	return full
 }
 
-func (v *Model) activeKeyMap() help.KeyMap {
-	if v.GetVisible(channelsPickerLayerName) {
-		return v.channelsPicker
+func (m *Model) activeKeyMap() help.KeyMap {
+	if m.GetVisible(channelsPickerLayerName) {
+		return m.channelsPicker
 	}
 
-	if v.app == nil {
+	if m.app == nil {
 		return nil
 	}
 
-	switch v.app.Focused() {
-	case v.guildsTree:
-		return v.guildsTree
-	case v.messagesList:
-		return v.messagesList
-	case v.messageInput:
-		return v.messageInput
+	switch m.app.Focused() {
+	case m.guildsTree:
+		return m.guildsTree
+	case m.messagesList:
+		return m.messagesList
+	case m.messageInput:
+		return m.messageInput
 	default:
 		return nil
 	}
 }
 
-func (v *Model) baseShortHelp() []keybind.Keybind {
-	cfg := v.cfg.Keybinds
+func (m *Model) baseShortHelp() []keybind.Keybind {
+	cfg := m.cfg.Keybinds
 	short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
-	if !v.messageInput.GetDisabled() {
+	if !m.messageInput.GetDisabled() {
 		short = append(short, cfg.FocusMessageInput.Keybind)
 	}
 	short = append(short, cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind)
 	return short
 }
 
-func (v *Model) baseFullHelp() [][]keybind.Keybind {
-	cfg := v.cfg.Keybinds
+func (m *Model) baseFullHelp() [][]keybind.Keybind {
+	cfg := m.cfg.Keybinds
 	focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind}
-	if !v.messageInput.GetDisabled() {
+	if !m.messageInput.GetDisabled() {
 		focus = append(focus, cfg.FocusMessageInput.Keybind)
 	}
 	return [][]keybind.Keybind{

+ 128 - 128
internal/ui/chat/model.go

@@ -81,131 +81,131 @@ func NewView(app *tview.Application, cfg *config.Config, token string) *Model {
 	return v
 }
 
-func (v *Model) SelectedChannel() *discord.Channel {
-	v.selectedChannelMu.RLock()
-	defer v.selectedChannelMu.RUnlock()
-	return v.selectedChannel
+func (m *Model) SelectedChannel() *discord.Channel {
+	m.selectedChannelMu.RLock()
+	defer m.selectedChannelMu.RUnlock()
+	return m.selectedChannel
 }
 
-func (v *Model) SetSelectedChannel(channel *discord.Channel) {
-	v.selectedChannelMu.Lock()
-	v.selectedChannel = channel
-	v.selectedChannelMu.Unlock()
+func (m *Model) SetSelectedChannel(channel *discord.Channel) {
+	m.selectedChannelMu.Lock()
+	m.selectedChannel = channel
+	m.selectedChannelMu.Unlock()
 }
 
-func (v *Model) buildLayout() {
-	v.Clear()
-	v.rightFlex.Clear()
-	v.mainFlex.Clear()
+func (m *Model) buildLayout() {
+	m.Clear()
+	m.rightFlex.Clear()
+	m.mainFlex.Clear()
 
-	v.rightFlex.
+	m.rightFlex.
 		SetDirection(flex.DirectionRow).
-		AddItem(v.messagesList, 0, 1, false).
-		AddItem(v.messageInput, 3, 1, false)
+		AddItem(m.messagesList, 0, 1, false).
+		AddItem(m.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)
+	m.mainFlex.
+		AddItem(m.guildsTree, 0, 1, true).
+		AddItem(m.rightFlex, 0, 4, false)
 
-	v.AddLayer(v.mainFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true))
-	v.AddLayer(v.messageInput.mentionsList, layers.WithName(mentionsListLayerName), layers.WithResize(false), layers.WithVisible(false))
+	m.AddLayer(m.mainFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true))
+	m.AddLayer(m.messageInput.mentionsList, layers.WithName(mentionsListLayerName), layers.WithResize(false), layers.WithVisible(false))
 }
 
-func (v *Model) togglePicker() {
-	if v.HasLayer(channelsPickerLayerName) {
-		v.closePicker()
+func (m *Model) togglePicker() {
+	if m.HasLayer(channelsPickerLayerName) {
+		m.closePicker()
 	} else {
-		v.openPicker()
+		m.openPicker()
 	}
 }
 
-func (v *Model) openPicker() {
-	v.AddLayer(
-		ui.Centered(v.channelsPicker, v.cfg.Picker.Width, v.cfg.Picker.Height),
+func (m *Model) openPicker() {
+	m.AddLayer(
+		ui.Centered(m.channelsPicker, m.cfg.Picker.Width, m.cfg.Picker.Height),
 		layers.WithName(channelsPickerLayerName),
 		layers.WithResize(true),
 		layers.WithVisible(true),
 		layers.WithOverlay(),
 	).SendToFront(channelsPickerLayerName)
-	v.channelsPicker.update()
+	m.channelsPicker.update()
 }
 
-func (v *Model) closePicker() {
-	v.RemoveLayer(channelsPickerLayerName)
-	v.channelsPicker.Update()
+func (m *Model) closePicker() {
+	m.RemoveLayer(channelsPickerLayerName)
+	m.channelsPicker.Update()
 }
 
-func (v *Model) toggleGuildsTree() {
+func (m *Model) 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)
+	if m.mainFlex.GetItemCount() == 2 {
+		m.mainFlex.RemoveItem(m.guildsTree)
+		if m.guildsTree.HasFocus() {
+			m.app.SetFocus(m.mainFlex)
 		}
 	} else {
-		v.buildLayout()
-		v.app.SetFocus(v.guildsTree)
+		m.buildLayout()
+		m.app.SetFocus(m.guildsTree)
 	}
 }
 
-func (v *Model) focusGuildsTree() bool {
+func (m *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)
+	if m.mainFlex.GetItemCount() == 2 {
+		m.app.SetFocus(m.guildsTree)
 		return true
 	}
 
 	return false
 }
 
-func (v *Model) focusMessageInput() bool {
-	if !v.messageInput.GetDisabled() {
-		v.app.SetFocus(v.messageInput)
+func (m *Model) focusMessageInput() bool {
+	if !m.messageInput.GetDisabled() {
+		m.app.SetFocus(m.messageInput)
 		return true
 	}
 
 	return false
 }
 
-func (v *Model) focusPrevious() {
-	switch v.app.Focused() {
-	case v.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
-		if v.focusGuildsTree() {
+func (m *Model) focusPrevious() {
+	switch m.app.Focused() {
+	case m.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
+		if m.focusGuildsTree() {
 			return
 		}
 		fallthrough
-	case v.guildsTree:
-		if v.focusMessageInput() {
+	case m.guildsTree:
+		if m.focusMessageInput() {
 			return
 		}
 		fallthrough
-	case v.messageInput:
-		v.app.SetFocus(v.messagesList)
+	case m.messageInput:
+		m.app.SetFocus(m.messagesList)
 	}
 }
 
-func (v *Model) focusNext() {
-	switch v.app.Focused() {
-	case v.messagesList:
-		if v.focusMessageInput() {
+func (m *Model) focusNext() {
+	switch m.app.Focused() {
+	case m.messagesList:
+		if m.focusMessageInput() {
 			return
 		}
 		fallthrough
-	case v.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
-		if v.focusGuildsTree() {
+	case m.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
+		if m.focusGuildsTree() {
 			return
 		}
 		fallthrough
-	case v.guildsTree:
-		v.app.SetFocus(v.messagesList)
+	case m.guildsTree:
+		m.app.SetFocus(m.messagesList)
 	}
 }
 
-func (v *Model) HandleEvent(event tcell.Event) tview.Command {
+func (m *Model) HandleEvent(event tcell.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.InitEvent:
 		return func() tcell.Event {
-			if err := v.OpenState(v.token); err != nil {
+			if err := m.OpenState(m.token); err != nil {
 				slog.Error("failed to open chat state", "err", err)
 				return tcell.NewEventError(err)
 			}
@@ -213,18 +213,18 @@ func (v *Model) HandleEvent(event tcell.Event) tview.Command {
 		}
 	case *QuitEvent:
 		return tview.Batch(
-			v.closeState(),
+			m.closeState(),
 			tview.Quit(),
 		)
 	case *tview.ModalDoneEvent:
-		if v.HasLayer(confirmModalLayerName) {
-			v.RemoveLayer(confirmModalLayerName)
-			if v.confirmModalPreviousFocus != nil {
-				v.app.SetFocus(v.confirmModalPreviousFocus)
+		if m.HasLayer(confirmModalLayerName) {
+			m.RemoveLayer(confirmModalLayerName)
+			if m.confirmModalPreviousFocus != nil {
+				m.app.SetFocus(m.confirmModalPreviousFocus)
 			}
-			onDone := v.confirmModalDone
-			v.confirmModalDone = nil
-			v.confirmModalPreviousFocus = nil
+			onDone := m.confirmModalDone
+			m.confirmModalDone = nil
+			m.confirmModalPreviousFocus = nil
 			if onDone != nil {
 				onDone(event.ButtonLabel)
 			}
@@ -232,49 +232,49 @@ func (v *Model) HandleEvent(event tcell.Event) tview.Command {
 		}
 	case *tview.KeyEvent:
 		switch {
-		case keybind.Matches(event, v.cfg.Keybinds.FocusGuildsTree.Keybind):
-			v.messageInput.removeMentionsList()
-			v.focusGuildsTree()
+		case keybind.Matches(event, m.cfg.Keybinds.FocusGuildsTree.Keybind):
+			m.messageInput.removeMentionsList()
+			m.focusGuildsTree()
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.FocusMessagesList.Keybind):
-			v.messageInput.removeMentionsList()
-			v.app.SetFocus(v.messagesList)
+		case keybind.Matches(event, m.cfg.Keybinds.FocusMessagesList.Keybind):
+			m.messageInput.removeMentionsList()
+			m.app.SetFocus(m.messagesList)
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.FocusMessageInput.Keybind):
-			v.focusMessageInput()
+		case keybind.Matches(event, m.cfg.Keybinds.FocusMessageInput.Keybind):
+			m.focusMessageInput()
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.FocusPrevious.Keybind):
-			v.focusPrevious()
+		case keybind.Matches(event, m.cfg.Keybinds.FocusPrevious.Keybind):
+			m.focusPrevious()
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.FocusNext.Keybind):
-			v.focusNext()
+		case keybind.Matches(event, m.cfg.Keybinds.FocusNext.Keybind):
+			m.focusNext()
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind):
-			return tview.Batch(v.closeState(), v.logout())
-		case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind):
-			v.toggleGuildsTree()
+		case keybind.Matches(event, m.cfg.Keybinds.Logout.Keybind):
+			return tview.Batch(m.closeState(), m.logout())
+		case keybind.Matches(event, m.cfg.Keybinds.ToggleGuildsTree.Keybind):
+			m.toggleGuildsTree()
 			return nil
-		case keybind.Matches(event, v.cfg.Keybinds.ToggleChannelsPicker.Keybind):
-			v.togglePicker()
+		case keybind.Matches(event, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
+			m.togglePicker()
 			return nil
 		}
 	case *closeLayerEvent:
-		if v.HasLayer(event.name) {
-			v.HideLayer(event.name)
+		if m.HasLayer(event.name) {
+			m.HideLayer(event.name)
 		}
 		return nil
 	}
-	return v.Layers.HandleEvent(event)
+	return m.Layers.HandleEvent(event)
 }
 
-func (v *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
-	v.confirmModalPreviousFocus = v.app.Focused()
-	v.confirmModalDone = onDone
+func (m *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
+	m.confirmModalPreviousFocus = m.app.Focused()
+	m.confirmModalDone = onDone
 
 	modal := tview.NewModal().
 		SetText(prompt).
 		AddButtons(buttons)
-	v.
+	m.
 		AddLayer(
 			ui.Centered(modal, 0, 0),
 			layers.WithName(confirmModalLayerName),
@@ -285,75 +285,75 @@ func (v *Model) showConfirmModal(prompt string, buttons []string, onDone func(la
 		SendToFront(confirmModalLayerName)
 }
 
-func (v *Model) onReadUpdate(event *read.UpdateEvent) {
-	v.app.QueueUpdateDraw(func() {
+func (m *Model) onReadUpdate(event *read.UpdateEvent) {
+	m.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.
 		if event.GuildID.IsValid() {
-			if guildNode := v.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
-				v.guildsTree.setNodeLineStyle(guildNode, v.guildsTree.getGuildNodeStyle(event.GuildID))
+			if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
+				m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.getGuildNodeStyle(event.GuildID))
 			}
 		}
 
 		// Channel style is always updated for the target channel regardless of
 		// whether it's in a guild or DM.
-		if channelNode := v.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
-			v.guildsTree.setNodeLineStyle(channelNode, v.guildsTree.getChannelNodeStyle(event.ChannelID))
+		if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
+			m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.getChannelNodeStyle(event.ChannelID))
 		}
 	})
 }
 
-func (v *Model) clearTypers() {
-	v.typersMu.Lock()
-	for _, timer := range v.typers {
+func (m *Model) clearTypers() {
+	m.typersMu.Lock()
+	for _, timer := range m.typers {
 		timer.Stop()
 	}
-	clear(v.typers)
-	v.typersMu.Unlock()
-	v.updateFooter()
+	clear(m.typers)
+	m.typersMu.Unlock()
+	m.updateFooter()
 }
 
-func (v *Model) addTyper(userID discord.UserID) {
-	v.typersMu.Lock()
-	typer, ok := v.typers[userID]
+func (m *Model) addTyper(userID discord.UserID) {
+	m.typersMu.Lock()
+	typer, ok := m.typers[userID]
 	if ok {
 		typer.Reset(typingDuration)
 	} else {
-		v.typers[userID] = time.AfterFunc(typingDuration, func() {
-			v.removeTyper(userID)
+		m.typers[userID] = time.AfterFunc(typingDuration, func() {
+			m.removeTyper(userID)
 		})
 	}
-	v.typersMu.Unlock()
-	v.updateFooter()
+	m.typersMu.Unlock()
+	m.updateFooter()
 }
 
-func (v *Model) removeTyper(userID discord.UserID) {
-	v.typersMu.Lock()
-	if typer, ok := v.typers[userID]; ok {
+func (m *Model) removeTyper(userID discord.UserID) {
+	m.typersMu.Lock()
+	if typer, ok := m.typers[userID]; ok {
 		typer.Stop()
-		delete(v.typers, userID)
+		delete(m.typers, userID)
 	}
-	v.typersMu.Unlock()
-	v.updateFooter()
+	m.typersMu.Unlock()
+	m.updateFooter()
 }
 
-func (v *Model) updateFooter() {
-	selectedChannel := v.SelectedChannel()
+func (m *Model) updateFooter() {
+	selectedChannel := m.SelectedChannel()
 	if selectedChannel == nil {
 		return
 	}
 	guildID := selectedChannel.GuildID
 
-	v.typersMu.RLock()
-	defer v.typersMu.RUnlock()
+	m.typersMu.RLock()
+	defer m.typersMu.RUnlock()
 
 	var footer string
-	if len(v.typers) > 0 {
+	if len(m.typers) > 0 {
 		var names []string
-		for userID := range v.typers {
+		for userID := range m.typers {
 			var name string
 			if guildID.IsValid() {
-				member, err := v.state.Cabinet.Member(guildID, userID)
+				member, err := m.state.Cabinet.Member(guildID, userID)
 				if err != nil {
 					slog.Error("failed to get member from state", "err", err, "guild_id", guildID, "user_id", userID)
 					continue
@@ -390,5 +390,5 @@ func (v *Model) updateFooter() {
 		}
 	}
 
-	go v.app.QueueUpdateDraw(func() { v.messagesList.SetFooter(footer) })
+	go m.app.QueueUpdateDraw(func() { m.messagesList.SetFooter(footer) })
 }

+ 57 - 64
internal/ui/chat/state.go

@@ -20,11 +20,11 @@ import (
 	"github.com/diamondburned/ningen/v3"
 )
 
-func (v *Model) OpenState(token string) error {
+func (m *Model) OpenState(token string) error {
 	identifyProps := http.IdentifyProperties()
 	gateway.DefaultIdentity = identifyProps
 	gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
-		Status: v.cfg.Status,
+		Status: m.cfg.Status,
 	}
 
 	id := gateway.DefaultIdentifier(token)
@@ -32,38 +32,31 @@ func (v *Model) OpenState(token string) error {
 
 	session := session.NewCustom(id, http.NewClient(token), handler.New())
 	state := state.NewFromSession(session, defaultstore.New())
-	v.state = ningen.FromState(state)
+	m.state = ningen.FromState(state)
 
 	// Handlers
-	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)
-
-	if v.cfg.TypingIndicator.Receive {
-		v.state.AddHandler(v.onTypingStart)
+	m.state.AddHandler(m.onRaw)
+	m.state.AddHandler(m.onReady)
+	m.state.AddHandler(m.onMessageCreate)
+	m.state.AddHandler(m.onMessageUpdate)
+	m.state.AddHandler(m.onMessageDelete)
+	m.state.AddHandler(m.onReadUpdate)
+	m.state.AddHandler(m.onGuildMembersChunk)
+	m.state.AddHandler(m.onGuildMemberRemove)
+
+	if m.cfg.TypingIndicator.Receive {
+		m.state.AddHandler(m.onTypingStart)
 	}
 
-	v.state.StateLog = func(err error) {
+	m.state.StateLog = func(err error) {
 		slog.Error("state log", "err", err)
 	}
 
-	v.state.OnRequest = append(v.state.OnRequest, httputil.WithHeaders(http.Headers()), v.onRequest)
-	return v.state.Open(context.Background())
+	m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest)
+	return m.state.Open(context.Background())
 }
 
-func (v *Model) CloseState() error {
-	if v.state == nil {
-		return nil
-	}
-	return v.state.Close()
-}
-
-func (v *Model) onRequest(r httpdriver.Request) error {
+func (m *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 +64,7 @@ func (v *Model) onRequest(r httpdriver.Request) error {
 	return nil
 }
 
-func (v *Model) onRaw(event *ws.RawEvent) {
+func (m *Model) onRaw(event *ws.RawEvent) {
 	slog.Debug(
 		"new raw event",
 		"code", event.OriginalCode,
@@ -80,16 +73,16 @@ func (v *Model) onRaw(event *ws.RawEvent) {
 	)
 }
 
-func (v *Model) onReady(event *gateway.ReadyEvent) {
-	v.app.QueueUpdateDraw(func() {
+func (m *Model) onReady(event *gateway.ReadyEvent) {
+	m.app.QueueUpdateDraw(func() {
 		// Rebuild indexes from scratch so reconnects and account switches do not
 		// retain pointers to detached tree nodes.
-		v.guildsTree.resetNodeIndex()
+		m.guildsTree.resetNodeIndex()
 
 		dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
-		v.guildsTree.dmRootNode = dmNode
+		m.guildsTree.dmRootNode = dmNode
 
-		root := v.guildsTree.
+		root := m.guildsTree.
 			GetRoot().
 			ClearChildren().
 			AddChild(dmNode)
@@ -127,7 +120,7 @@ func (v *Model) onReady(event *gateway.ReadyEvent) {
 
 			// Orphan guild - add directly to root in order
 			if guildEvent, ok := guildsByID[guildID]; ok {
-				v.guildsTree.createGuildNode(root, guildEvent.Guild)
+				m.guildsTree.createGuildNode(root, guildEvent.Guild)
 			}
 		}
 
@@ -135,59 +128,59 @@ func (v *Model) onReady(event *gateway.ReadyEvent) {
 		for _, folder := range event.UserSettings.GuildFolders {
 			if folder.ID == 0 && len(folder.GuildIDs) == 1 {
 				if guild, ok := guildsByID[folder.GuildIDs[0]]; ok {
-					v.guildsTree.createGuildNode(root, guild.Guild)
+					m.guildsTree.createGuildNode(root, guild.Guild)
 				}
 			} else {
-				v.guildsTree.createFolderNode(folder, guildsByID)
+				m.guildsTree.createFolderNode(folder, guildsByID)
 			}
 		}
 
-		v.guildsTree.SetCurrentNode(root)
-		v.app.SetFocus(v.guildsTree)
+		m.guildsTree.SetCurrentNode(root)
+		m.app.SetFocus(m.guildsTree)
 	})
 }
 
-func (v *Model) onMessageCreate(message *gateway.MessageCreateEvent) {
-	selectedChannel := v.SelectedChannel()
+func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) {
+	selectedChannel := m.SelectedChannel()
 	if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
-		v.removeTyper(message.Author.ID)
-		v.app.QueueUpdateDraw(func() {
-			v.messagesList.addMessage(message.Message)
+		m.removeTyper(message.Author.ID)
+		m.app.QueueUpdateDraw(func() {
+			m.messagesList.addMessage(message.Message)
 		})
 	} else {
-		if err := notifications.Notify(v.state, message, v.cfg); err != nil {
+		if err := notifications.Notify(m.state, message, m.cfg); err != nil {
 			slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
 		}
 	}
 }
 
-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 {
+func (m *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) {
+	if selected := m.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+		index := slices.IndexFunc(m.messagesList.messages, func(m discord.Message) bool {
 			return m.ID == message.ID
 		})
 		if index < 0 {
 			return
 		}
 
-		v.app.QueueUpdateDraw(func() {
-			v.messagesList.setMessage(index, message.Message)
+		m.app.QueueUpdateDraw(func() {
+			m.messagesList.setMessage(index, message.Message)
 		})
 	}
 }
 
-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 {
+func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
+	if selected := m.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
+		prevCursor := m.messagesList.Cursor()
+		deletedIndex := slices.IndexFunc(m.messagesList.messages, func(m discord.Message) bool {
 			return m.ID == message.ID
 		})
 		if deletedIndex < 0 {
 			return
 		}
 
-		v.app.QueueUpdateDraw(func() {
-			v.messagesList.deleteMessage(deletedIndex)
+		m.app.QueueUpdateDraw(func() {
+			m.messagesList.deleteMessage(deletedIndex)
 		})
 
 		// Keep cursor stable when possible after removal.
@@ -196,7 +189,7 @@ func (v *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
 			// Prefer previous item; fall forward if we deleted the first.
 			newCursor = deletedIndex - 1
 			if newCursor < 0 {
-				if deletedIndex < len(v.messagesList.messages) {
+				if deletedIndex < len(m.messagesList.messages) {
 					newCursor = deletedIndex
 				} else {
 					newCursor = -1
@@ -208,23 +201,23 @@ func (v *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
 		}
 		if newCursor != prevCursor {
 			// Avoid redundant cursor updates if nothing changed.
-			v.app.QueueUpdateDraw(func() {
-				v.messagesList.SetCursor(newCursor)
+			m.app.QueueUpdateDraw(func() {
+				m.messagesList.SetCursor(newCursor)
 			})
 		}
 	}
 }
 
-func (v *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
-	v.messagesList.setFetchingChunk(false, uint(len(event.Members)))
+func (m *Model) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
+	m.messagesList.setFetchingChunk(false, uint(len(event.Members)))
 }
 
-func (v *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
-	v.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, v.state.MemberState.SearchLimit)
+func (m *Model) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
+	m.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, m.state.MemberState.SearchLimit)
 }
 
-func (v *Model) onTypingStart(event *gateway.TypingStartEvent) {
-	selectedChannel := v.SelectedChannel()
+func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {
+	selectedChannel := m.SelectedChannel()
 	if selectedChannel == nil {
 		return
 	}
@@ -233,10 +226,10 @@ func (v *Model) onTypingStart(event *gateway.TypingStartEvent) {
 		return
 	}
 
-	me, _ := v.state.Cabinet.Me()
+	me, _ := m.state.Cabinet.Me()
 	if event.UserID == me.ID {
 		return
 	}
 
-	v.addTyper(event.UserID)
+	m.addTyper(event.UserID)
 }