Documentation
¶
Index ¶
- type ChatSelectedMsg
- type Model
- func (m *Model) ActiveChatId() int64
- func (m Model) ActiveFolderID() int32
- func (m Model) ActiveFolderIndex() int
- func (m Model) BufferIndex(chatID int64) int
- func (m *Model) ClearFilter()
- func (m *Model) ClickAt(localY int) (chatID int64, ok bool)
- func (m *Model) ClickAtXY(x, y int) (chatID int64, ok bool)
- func (m Model) Count() int
- func (m Model) CursorChatId() int64
- func (m *Model) CycleFolder(delta int)
- func (m Model) FilterActive() bool
- func (m Model) FilterQuery() string
- func (m Model) FolderLoadCmd() tea.Cmd
- func (m Model) FolderNames() []string
- func (m Model) Init() tea.Cmd
- func (m *Model) MarkLoadedForTest()
- func (m *Model) OpenCursor() (int64, bool)
- func (m *Model) OpenFilter()
- func (m *Model) ScrollBy(n int) tea.Cmd
- func (m *Model) SelectDelta(delta int) (chatID int64, ok bool)
- func (m *Model) SelectFolderIndex(index int) bool
- func (m *Model) SelectNextUnread() (chatID int64, ok bool)
- func (m *Model) SetDraftChats(ids map[int64]bool)
- func (m *Model) SetFocused(focused bool)
- func (m *Model) SetFolderForTest(folder *telegram.ChatFolder)
- func (m *Model) SetFoldersForTest(titles []string)
- func (m *Model) SetMyUserID(id int64)
- func (m *Model) SetSize(width, height int)
- func (m Model) TotalCount() int
- func (m Model) Update(msg tea.Msg) (Model, tea.Cmd)
- func (m Model) View() string
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 (*Model) ActiveChatId ¶
ActiveChatId returns the OPEN chat's ID — the one whose history the chat view is showing. Distinct from the cursor, which moves without opening anything; see Model.CursorChatId.
func (Model) ActiveFolderID ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) CursorChatId ¶ added in v0.0.13
CursorChatId is the chat under the highlight, 0 when the list is empty or still loading.
The two are deliberately separate — j and k move the cursor without loading a history, which is why holding j down is cheap. What they are not is interchangeable: every key that leaves the list rightward (l, Enter, i) acts on the CURSOR, and reading the open chat there is what made jjjl land in the wrong conversation (decision I-2).
func (*Model) CycleFolder ¶
func (Model) FilterActive ¶
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 ¶
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 (Model) FolderNames ¶
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) 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) OpenCursor ¶ added in v0.0.13
OpenCursor marks the cursored chat as the open one and returns it, reporting false when there is nothing under the cursor.
The bookkeeping is here rather than at the call site because every other way of opening a chat from this panel — Enter, a click, SelectDelta — already does it here, and an open chat the list does not know about is one the unread counts and the notification suppression get wrong.
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). OpenFilter opens the filter input. The CALLER has consumed the key that asked for it — internal/app matches keys.search and returns — so nothing is latched here: the next key this component sees is the first character of the query, and a "/" among them is a literal.
Latching here is what made a query starting with "/" impossible to type: the app never re-delivers the opening key, so the latch was still armed when the user's own slash arrived. See openFilterFromKey for the path that does need it.
func (*Model) ScrollBy ¶
ScrollBy moves the selection by n items (negative scrolls up). ScrollBy moves the cursor by n and returns any work that move implies — today, a request for the next page of dialogs when it lands near the end.
It returns a command because the wheel moves the same cursor the keyboard does, and a caller that ignored the result would give mouse users a chat list that stops at the first page.
func (*Model) SelectDelta ¶
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 ¶
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) SelectNextUnread ¶ added in v0.0.13
SelectNextUnread moves the cursor to the next chat with unread messages and returns it, searching DOWN from the cursor within the active folder and wrapping once past the end.
Down-then-wrap rather than "the first unread in the list": the list is ordered by recency, so starting from the top would mean pressing u twice in a row went back to the same conversation. Wrapping once, and only once, is what makes a run of u presses walk every unread chat and then stop.
It reports false when nothing in the folder is unread, so the caller can say so instead of leaving a key that looks broken.
func (*Model) SetDraftChats ¶
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 ¶
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 (*Model) SetMyUserID ¶
func (Model) TotalCount ¶
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.