chatlist

package
v0.0.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ChatSelectedMsg

type ChatSelectedMsg struct {
	ChatId int64
}

ChatSelectedMsg is emitted when the user selects a chat.

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is the chat list component.

func New

func New(s *store.Store, tg *telegram.Client, r theme.Roles) Model

New creates a new chat list model.

func (*Model) ActiveChatId

func (m *Model) ActiveChatId() int64

ActiveChatId returns the currently selected chat ID.

func (Model) ActiveFolderID

func (m Model) ActiveFolderID() int32

ActiveFolderID returns the ID of the currently active folder tab — telegram.AllChatsFolderID when no folders are set (the default, pre-load state). Exists for the app layer and tests: activeFolder is unexported, and View() renders nothing but a loading spinner while m.loading, so callers otherwise have no way to observe which folder is selected (e.g. to assert a folder-cycling keybinding actually changed something, rather than merely that the key was dispatched).

func (Model) ActiveFolderIndex

func (m Model) ActiveFolderIndex() int

ActiveFolderIndex returns the selected tab's position, or 0 when no folders have loaded yet — the synthesized "All" tab is always first and always present, so index 0 is never wrong, only uninformative.

func (Model) BufferIndex

func (m Model) BufferIndex(chatID int64) int

BufferIndex is a chat's 1-based row in the list as it currently stands, or 0 when the chat is not in it — filtered out, in another folder, or not loaded.

Position rather than a stable id. A vi buffer number is worth having because :b2 goes there; this client has no such command, so the only number worth printing is the one the reader can act on, which is the row they can see to the left of the header.

func (*Model) ClearFilter

func (m *Model) ClearFilter()

ClearFilter drops the filter and closes the input, restoring the full (folder-filtered) chat list. This is what `esc` does, and what the "esc:clear" hint in the filter chip advertises.

func (*Model) ClickAt

func (m *Model) ClickAt(localY int) (chatID int64, ok bool)

ClickAt selects the chat shown at the given local row (inside the panel border) and returns its ID. ok is false when the row has no chat — this includes clicks that land on the folder tab bar, which occupies the first tabBarHeight() rows above the list, and any click while the initial chat load is still in flight (View() renders only the spinner then, with no tab bar and no clickable list — see the tabBarHeight comment above for why that state is handled here rather than by varying tabBarHeight itself).

ClickAt has no column, so a click on the tab bar row is only ever a no-op here — it cannot tell which folder tab was hit. ClickAtXY is the column-aware companion that can, and is what the app calls; this is now the row-only half it delegates to.

func (*Model) ClickAtXY

func (m *Model) ClickAtXY(x, y int) (chatID int64, ok bool)

ClickAtXY is ClickAt's column-aware companion. x and y are both local to the panel's content area (inside its border) — the same coordinate space ClickAt already uses for y. A click whose row lands on the folder tab bar switches to whichever folder tab column x falls within (a miss between/past tabs is a no-op) and never selects a chat; a click on any row below the tab bar behaves exactly like ClickAt(y).

This is what the app calls, from handleMouseClick in internal/app/app.go, with both coordinates made panel-local:

row, col := y-1, x-1
if chatID, ok := m.chatList.ClickAtXY(col, row); ok {

It used to call ClickAt(row), which has no column to hit-test the tab bar with, so a click on a folder tab was silently swallowed instead of switching folders.

func (Model) Count

func (m Model) Count() int

Count returns how many chats the list is currently showing, after the active folder and any live filter have been applied. It is what the hint bar's buffer counter reports, so it must be what the user can actually see rather than the total held in the store.

func (*Model) CycleFolder

func (m *Model) CycleFolder(delta int)

func (Model) FilterActive

func (m Model) FilterActive() bool

FilterActive reports whether the filter input is open and consuming keys. It is false once the input is closed, INCLUDING when a filter is still applied (`enter`) — the applied-but-closed state is a normal browsing state in which j/k/enter/folder keys work as usual, and is advertised by the filter chip in the tab bar row.

func (Model) FilterQuery

func (m Model) FilterQuery() string

FilterQuery returns the currently applied filter ("" when none). It reflects what the list is actually filtered by, not what is being typed — although while the input is open the two are the same, since the filter is applied live on every keystroke.

func (Model) FolderLoadCmd

func (m Model) FolderLoadCmd() tea.Cmd

func (Model) FolderNames

func (m Model) FolderNames() []string

FolderNames returns the folder tab labels in display order.

The tabs are DRAWN by the top bar in TUI 2.0, but selection and key handling stay here — this is the projection that lets the two live in different packages without the folder keymap moving with the pixels.

func (Model) Init

func (m Model) Init() tea.Cmd

Init loads the initial chat list.

func (*Model) MarkLoadedForTest

func (m *Model) MarkLoadedForTest()

CycleFolder moves the active folder tab by delta (wrapping around) and refilters the chat list to match. SetFoldersForTest installs folders by title. It exists so tests in other packages — the app's top-bar click routing, chiefly — can set up a folder list without constructing telegram.ChatFolder values or faking a server response. MarkLoadedForTest puts the list into the state chatsLoadedMsg puts it in.

The message is unexported and arrives from a command this component starts itself, so a test in another package cannot get the list past its spinner without a seam. Same reason and same shape as SetFoldersForTest below.

func (*Model) OpenFilter

func (m *Model) OpenFilter()

OpenFilter opens the local chat-list filter input: `/` in the chat list, matching vi's "search the buffer in front of you" (the global cross-chat search stays on ctrl+g). While it is open FilterActive reports true and the app is expected to route key presses straight to Update, which consumes them all (see updateFilterKey).

Reopening an already-applied filter keeps the existing query and puts the cursor at its end, so `/` is also "edit the current filter".

Unlike ClickAt/ClickAtXY/SelectDelta, this deliberately does NOT bail out while the initial chat load is still in flight. Those three resolve a screen row (or a cursor delta) into a chat and so must refuse to answer while View shows nothing but the spinner; opening a filter resolves nothing. A query typed during the load narrows an empty list — harmlessly, since refreshList rebuilds from an empty store — and is applied to the first real list by the refreshList in Update's chatsLoadedMsg branch. That is strictly better than swallowing the keystroke and leaving the user wondering why "/" did nothing, and it keeps FilterActive() reachable for callers that cannot leave the loading state (internal/app's key tests drive a Model whose telegram client is nil, so no chatsLoadedMsg ever arrives there).

The caller should treat the key that opened the filter as consumed and NOT also forward it to Update. If it does anyway, that first key press is swallowed rather than typed into the query (filterJustOpened).

func (*Model) ScrollBy

func (m *Model) ScrollBy(n int)

ScrollBy moves the selection by n items (negative scrolls up).

func (*Model) SelectDelta

func (m *Model) SelectDelta(delta int) (chatID int64, ok bool)

SelectDelta moves the selection cursor by delta within the current (folder-filtered) item list — the same list ClickAt/ScrollBy operate on — clamping at either end (no wrap), and returns the newly selected chat's ID. It updates activeChatId on success.

Unlike the tea.KeyPressMsg-driven navigation in Update, this is meant to be called directly from a global keybinding (e.g. next/prev chat) regardless of which panel currently has focus, so it does not consult m.focused.

ok is false when the list is empty, the initial chat load is still in flight (mirroring ClickAt's loading guard — see the tabBarHeight comment above for why), or the selected item's ID fails to parse.

func (*Model) SelectFolderIndex

func (m *Model) SelectFolderIndex(index int) bool

SelectFolderIndex activates the folder at index, reporting whether it actually changed anything.

It exists because the folder TABS are drawn by the frame's top bar now, while folder STATE still lives here. The top bar can say which tab was clicked but not what that means; this is the other half.

func (*Model) SetDraftChats

func (m *Model) SetDraftChats(ids map[int64]bool)

SetSize sets the component dimensions. SetDraftChats tells the list which chats hold unsent work, so their preview row can say so. The composer owns the drafts; this is a projection of them, refreshed by the host whenever they change.

func (*Model) SetFocused

func (m *Model) SetFocused(focused bool)

SetFocused sets focus state.

Losing focus while the filter input is open closes the input but keeps the filter applied — the same thing `enter` does. Without this, a panel switch (or an overlay taking focus) would leave FilterActive() true while the keys that reach this component do not, stranding the input open with no way to close it.

func (*Model) SetFolderForTest

func (m *Model) SetFolderForTest(folder *telegram.ChatFolder)

SetFolderForTest installs one custom folder beside "All" and activates it. It exists because the counter tests need a folder that HOLDS a subset: every other test uses All, where the folder total and the account total coincide and a bug between them is invisible.

func (*Model) SetFoldersForTest

func (m *Model) SetFoldersForTest(titles []string)

func (*Model) SetMyUserID

func (m *Model) SetMyUserID(id int64)

func (*Model) SetSize

func (m *Model) SetSize(width, height int)

func (Model) TotalCount

func (m Model) TotalCount() int

TotalCount is how many chats there are before the filter, which is what makes Count legible: a list that drops from twelve rows to three has not lost nine chats.

The SAME denominator the filter header draws (see renderFilterHeader), on purpose. Two surfaces describing one list with different totals is worse than either of them saying nothing, and this is the only way to be sure they agree — the header and the hint bar are in different packages and nothing else would catch them drifting.

func (Model) Update

func (m Model) Update(msg tea.Msg) (Model, tea.Cmd)

Update handles messages.

func (Model) View

func (m Model) View() string

View renders the chat list.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL