chatview

package
v0.0.18 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Keys

type Keys struct {
	Reply, Edit, Delete string
	Forward             string
	MarkRead            string
}

Keys is the subset of config.KeyConfig that chatview consults directly. chatview cannot import internal/config or internal/app (app imports this package), so the caller — internal/app, in New() — is responsible for reading config.toml's keys table, running each field through config.NormalizeKey, and passing the result here via SetKeys.

A zero/empty field means "keep the built-in default", so a Model that never has SetKeys called behaves exactly as it did before chatview became configurable (New calls SetKeys(Keys{}) itself for exactly this reason).

Every field is a mnemonic and every field REPLACES its built-in letter (decision I-13). The motion fields that used to live here — ScrollUp/ScrollDown/PageUp/PageDown, which ADDED a spelling alongside j/k, the arrows and the page keys — are gone with them: motions are vi's, and a second semantic in one table cost more to explain than the configurability was worth.

type LoadMoreHistoryMsg

type LoadMoreHistoryMsg struct {
	ChatId int64
}

type MediaPlayMsg

type MediaPlayMsg struct {
	Status string // "playing", "downloading", "error", "opened"
	Info   string
}

MediaPlayMsg is sent when media playback starts.

type MessageActionMsg

type MessageActionMsg struct {
	Action    string // "reply", "edit", "delete", "forward"
	ChatId    int64
	MessageId int64
}

type Model

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

func New

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

func (Model) ActiveKeys

func (m Model) ActiveKeys() Keys

ActiveKeys reports what handleKey actually matches right now, after SetKeys's defaulting and collision resolution — not the raw Keys a caller last passed in. This is the source of truth for anything outside this package that advertises a chatview binding (the "?" help card, the hint bar, and so on): it exists precisely so nothing has to reimplement SetKeys's collision rule to stay honest. A caller that instead re-derived a "resolved" reply by re-running the config through its own defaulting logic could end up advertising a binding that collided and was dropped — e.g. showing "j" for reply after a user sets reply = "j", when the panel still (correctly) matches "r". Always read this instead.

A field comes back as the single spelling that is live: the configured value if it was accepted, the built-in letter if it fell back to that, or "" if SetKeys's pass 3 found the action UNREACHABLE. A caller rendering a help card should show "" as "unbound", not as an empty row that looks like an oversight.

func (*Model) ApplyMedia

func (m *Model) ApplyMedia(cfg config.MediaConfig)

ApplyMedia applies media config: image protocol and bubble size, external players, and photo auto-download. Voice notes download on play regardless of AutoDownloadVoice; they are not eagerly prefetched.

func (*Model) ApplyStorage

func (m *Model) ApplyStorage(cfg config.StorageConfig)

ApplyStorage takes the [storage] settings this panel needs: where `s` saves to. The media cache is the client's business, not this panel's.

func (*Model) ApplyUI

func (m *Model) ApplyUI(cfg config.UIConfig)

ApplyUI applies the [ui] settings the renderer needs: the inline-image policy and whether links carry OSC 8. The rail preference is the host's own business.

func (Model) ChatId

func (m Model) ChatId() int64

ChatId is the chat currently open in this panel, 0 when none.

func (*Model) ClickAt added in v0.0.13

func (m *Model) ClickAt(row int) bool

ClickAt moves the cursor to the message drawn on a panel-local row, and reports whether it moved anything.

A mouse user had no way to choose what r, y or + would act on: a click focused the panel and left the cursor wherever the keyboard had put it (decision I-11). The row is resolved through the same line index View draws from, so what the click selects is what is under the pointer.

A click on the header, on a day or unread divider, or on the blank space above a short history moves nothing. Those are not messages, and moving the cursor to the nearest one instead would be a guess the reader did not make.

func (Model) DownloadCmd

func (m Model) DownloadCmd() tea.Cmd

DownloadCmd and OpenExternallyCmd are the media overlay's two other keys, exported because while the overlay is up the host owns the keyboard and the chat view never sees the press. They are the same actions s and o perform in the thread, deliberately: the overlay's hint row advertises the same letters, and two spellings of "save this" would be two things to keep in agreement.

func (Model) HasSearchResults

func (m Model) HasSearchResults() bool

HasSearchResults reports whether a completed in-chat search still holds hits, i.e. whether n/N are meaningful.

func (*Model) LazyMediaCmd

func (m *Model) LazyMediaCmd() tea.Cmd

LazyMediaCmd starts the thumbnail download for any photo that the current scroll position brought near the viewport but that the open-time prefetch cap skipped. The keyboard scroll handlers call it themselves; it is exported for the host's mouse-wheel path, which drives ScrollByLines and would otherwise never trigger a lazy load.

func (*Model) MarkLoadedForTest

func (m *Model) MarkLoadedForTest()

MarkLoadedForTest puts the panel into the state a finished history load puts it in, for messages a test has already put in the store.

historyLoadedMsg is unexported and arrives from a command this component starts itself, so a test in another package cannot get the panel past its "Loading messages..." line without a seam. Same reason and same shape as chatlist.MarkLoadedForTest.

func (*Model) MarkReadCmd

func (m *Model) MarkReadCmd() tea.Cmd

MarkReadCmd marks the open chat read up to its newest loaded message, without moving the scroll position — the point of an explicit mark-read is to clear the badge while you keep reading where you are.

Unlike the automatic read receipts in Update, this does NOT wait for terminal focus. Those are inferred from "the user is looking at it", which is only true when the terminal has focus; this one was asked for explicitly, so deferring it would just make the command look broken.

Returns nil when there is nothing to mark, so a caller can treat a nil Cmd as "no chat open or no messages loaded".

func (*Model) OpenChat

func (m *Model) OpenChat(chatID int64, title string) tea.Cmd

OpenChat opens a chat scrolled to its newest message.

func (*Model) OpenChatAt

func (m *Model) OpenChatAt(chatID int64, title string, targetMsgID int64) tea.Cmd

OpenChatAt opens a chat and, once history has loaded, scrolls so that targetMsgID is visible (roughly centred). targetMsgID 0 means "newest message at the bottom", i.e. plain OpenChat behaviour. If the target is not in the first page, up to maxTargetPages further pages are fetched backwards; if it is still not found the view settles at the oldest loaded message and a notice is shown in the header.

func (Model) OpenExternallyCmd

func (m Model) OpenExternallyCmd() tea.Cmd

func (*Model) OpenFind

func (m *Model) OpenFind()

OpenFind opens the in-chat search input, exactly as the panel's own ctrl+f binding does. It exists so the host can route a contextual binding (e.g. "/" pressed with the chat view focused) straight into this component with a method call. Re-emitting a synthetic ctrl+f key event through the command loop is the alternative, and it livelocks the moment a user configures keys.search = "ctrl+f": the forwarded key re-matches the host binding, which forwards it again, forever.

A no-op when the input is already open (so a repeated press does not wipe a half-typed query) or when no chat is open (there is nothing to search, and the input would render over the placeholder view).

func (Model) OverlayPhotoCmd

func (m Model) OverlayPhotoCmd() tea.Cmd

OverlayPhotoCmd raises the media overlay on the cursored message and downloads the picture for it.

Photos only. A video, a document or a voice note has no in-terminal representation this client can draw, so those keep the existing behaviour of handing the file to the platform — an overlay that says "cannot draw this" is worse than the thing that already works.

Returns nil when the cursor is not on a photo, which is the host's signal to fall through to that existing behaviour.

func (Model) PlayVoiceCmd

func (m Model) PlayVoiceCmd() tea.Cmd

PlayVoiceCmd plays the cursored voice note or audio message.

Voice only, deliberately. Space is a big, easy key and the point of it is that a voice note is one press away; making it also open documents and spawn video players would make "the big key" mean "do whatever this message implies", which is what enter is for.

func (*Model) ScrollByLines

func (m *Model) ScrollByLines(n int)

ScrollByLines scrolls the message view by n lines (positive = up/older).

func (Model) SearchActive

func (m Model) SearchActive() bool

SearchActive reports whether the in-chat search input is open. While it is, every key belongs to the input: the host must route input events to this panel without consuming them first (esc, quick-type, etc.).

func (*Model) SetBufferIndex

func (m *Model) SetBufferIndex(n int)

SetBufferIndex tells the header which row of the chat list this thread is. Zero clears it.

func (*Model) SetFocused

func (m *Model) SetFocused(focused bool)

func (*Model) SetKeys

func (m *Model) SetKeys(k Keys)

SetKeys resolves and stores the bindings handleKey consults. See SetReservedKeys's doc comment for the app-level surface this also protects — call that first if the caller has one.

Resolution runs in three passes over a single "claimed" set, so that explicit configuration always outranks a built-in default regardless of field order, and a collision never produces a silent double-bind:

  1. Claim chatViewFixedKeys() and every key from SetReservedKeys. These are claimed unconditionally, before anything from k is considered.
  2. For each field in order, if it was EXPLICITLY configured (non-empty), accept it and claim it — unless it is already claimed, in which case the configured value is rejected outright (not partially honored) and the field is treated as unconfigured for pass 3.
  3. For any field that did not get an accepted binding in pass 2, try its built-in letter ("r"/"e"/"d"/"m"). If that letter is itself already claimed — by a fixed key, a reserved key, or another field's pass-2 binding — the action is UNREACHABLE this call: it is left as "" rather than bound anyway, and ActiveKeys reports the same "" so a caller can advertise it honestly as unbound instead of lying about which key fires it. This never happens with an unconfigured Keys{}, since r/e/d/m never collide with each other or with anything in chatViewFixedKeys().

Two collisions this resolves that a naive single-pass, field-order resolver got wrong:

  • reply = "e" (edit_message left at its default "e") used to leave both reply and edit bound to "e", with edit silently unreachable because reply's switch case came first — and nothing reported it. Now edit resolves to "" (unreachable) instead of double-binding.
  • edit_message = "r" (reply left at its default "r") used to be silently discarded, because reply's default claimed "r" before edit_message's explicit config was ever considered. Now pass 2 runs every explicit config before pass 3 tries any default, so the explicit edit_message = "r" wins and reply falls back to "" instead (there is no other letter for it to try).

func (*Model) SetMyUserId

func (m *Model) SetMyUserId(id int64)

func (*Model) SetReservedKeys

func (m *Model) SetReservedKeys(reserved []string)

SetReservedKeys tells chatview which keys are already claimed by bindings outside this package — every app-level key (quit, panel focus, tab, i/c into the composer, h/l panel movement, /, ?, and so on) — so SetKeys's collision resolution can refuse to accept a configured mnemonic that would silently shadow one of them. Without this, e.g. reply = "q" would be accepted, advertised on the help card as Reply, and quit the app the moment it fired, because chatview had no way to know app.go had already claimed "q".

Call order: SetReservedKeys must be called before SetKeys, since SetKeys resolves and freezes the claimed-key set immediately when it runs. Calling SetKeys without ever calling SetReservedKeys first is safe: the app-level surface is simply treated as empty, which is this package's original, pre-reservation behavior.

func (*Model) SetSize

func (m *Model) SetSize(w, h int)

SetSize resizes the view. A width change invalidates every cached bubble, since bubbles are laid out for a specific panel width.

func (Model) TargetMessageId

func (m Model) TargetMessageId() int64

TargetMessageId is the message the panel is still trying to scroll to, or 0 when it is simply showing the newest.

Exported for the callers that OPEN a chat at a message — a search result, a channel post's discussion — because "did it aim at the right message" is otherwise only observable after the history has loaded and scrolled, which is two round trips a test does not have.

func (Model) Update

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

func (Model) View

func (m Model) View() string

func (Model) YankCmd

func (m Model) YankCmd() tea.Cmd

YankCmd copies the cursored message's text to the system clipboard.

The message's TEXT, exactly as Telegram sent it — not the rendered body. The render has a gutter, wraps at the pane width, and draws a code block inside a frame; none of that is wanted in a paste buffer. Taking the source also means a message that is nothing but a code block yanks as the code, which is what "copy a message or a code block" comes down to once there is no second binding for it.

Returns nil when there is nothing to copy, which the host reads as "say so" rather than as success.

type OpenPhotoMsg

type OpenPhotoMsg struct {
	Caption string
}

OpenPhotoMsg asks the host to raise the media overlay. It carries the caption for the overlay's header; the download follows in OpenedPhotoMsg.

type OpenedPhotoMsg

type OpenedPhotoMsg struct {
	Path string
	Err  error
}

OpenedPhotoMsg is the downloaded file for the overlay, or why there is none.

type ScrollToBottomMsg

type ScrollToBottomMsg struct{}

type YankMsg

type YankMsg struct {
	// Runes is how much was copied, so the notice can say so. A reader who
	// yanks a long message and gets a silent confirmation has no way to
	// tell it from a yank that copied an empty caption.
	Runes int
	Err   error
}

YankMsg reports what a copy did. The host phrases the notice, because the hint bar is the host's row and the wording belongs with the other notices rather than beside the clipboard call.

Jump to

Keyboard shortcuts

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