Skip to content

Commit b7fa650

Browse files
committed
Fixed slowness
The store now has migration 0013_denormalized_chat_preview, which adds/backfills chats.last_preview, and ListChats/chat lookup reads that stored value instead of running the correlated preview subquery each refresh. Preview state is refreshed transactionally after message insert/edit/delete, media metadata upsert, and chat alias merge. SQLite startup now also sets cache_size, mmap_size, and runs PRAGMA optimize.
1 parent bad9a0c commit b7fa650

5 files changed

Lines changed: 258 additions & 16 deletions

File tree

‎PLAN.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
## Current Stage
44

5-
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, protocol-backed read receipts, reactions, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported, catch-up sync overlay/batching, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
5+
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, protocol-backed read receipts, reactions, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported, catch-up sync overlay/batching, denormalized chat-list previews, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
66

77
### Implemented now
88

99
- Go CLI entrypoint with `vimwhat`, `doctor`, `demo seed`, and `demo clear`.
1010
- Native Linux and Windows config/data/cache path resolution, first-run default config generation, and config loading, including emoji rendering mode, per-mode status indicator color overrides, and flat configurable keybindings.
1111
- GitHub Actions CI validates Linux tests/vet, checks the Windows build graph, uploads Windows amd64/arm64 binaries for machines where local Windows builds are not available, and publishes a stable branch-build `windows-latest` release for direct tester updates.
12-
- SQLite-backed local state with migrations, chat/message/media/draft storage, stats, and FTS-backed search.
12+
- SQLite-backed local state with migrations, chat/message/media/draft storage, denormalized chat previews, startup cache/mmap pragmas, stats, and FTS-backed search.
1313
- Bubble Tea TUI with modal interaction (`normal`, `insert`, `visual`, `command`, `search`), chat list, message viewport, optional info pane, composer, filters, and help.
1414
- Local draft persistence, local outgoing message persistence, normal/visual message yanking, text clipboard integration, clipboard image copy plus clipboard attachment paste/file-drop staging, attachment staging, message delete flow, and search routing by pane.
1515
- Media backend detection and in-chat preview behavior with `sixel`, Unix `ueberzug++`, `chafa`, compact audio playback rows, plus native external open/save fallback paths. Windows auto preview now prefers Sixel in WezTerm when a Sixel renderer is installed, falls back to inline `chafa` before external openers when `preview_backend = "auto"`, draws Sixel out-of-band instead of embedding image escape payloads in Bubble Tea text frames, and still supports explicit external/Chafa/none overrides.
@@ -28,7 +28,7 @@ Implementation is past the local-shell phase and currently sits at a DB-first, l
2828
- Retry/resend UX for failed outgoing media rows in the TUI via `R` and `:retry-message`, keeping the original failed row in chat and queueing a brand-new send attempt when the local attachment file still exists.
2929
- Chat title quality tracking with source precedence, group/contact metadata refresh, and safe placeholders so group JIDs/phone-like IDs are not treated as real names.
3030
- Direct-chat identity hardening for WhatsApp PN/LID aliases: canonicalize mapped 1:1 chats onto a single chat ID, merge already-split alias rows/messages/drafts/history cursors in SQLite, and preserve the active conversation when a live merge remaps the selected chat.
31-
- Large-history TUI guardrails: historical imports avoid refresh storms, implicit database imports show a blocking local-update progress overlay even when WhatsApp omits an offline-sync preview, reconnect catch-up sync batches snapshot refreshes until completion, live refreshes are debounced, stale snapshot reloads do not steal chat focus, message rendering is bounded to the visible window, message cursor scrolling behaves like the chat list viewport, duplicate in-flight history requests are suppressed, and pixel media/avatar/sticker overlays pause into blank reserved space while scrolling before resyncing.
31+
- Large-history TUI guardrails: historical imports avoid refresh storms, implicit database imports show a blocking local-update progress overlay even when WhatsApp omits an offline-sync preview, reconnect catch-up sync batches snapshot refreshes until completion, live refreshes are debounced, chat-list preview text is stored on chats instead of recomputed through per-chat message subqueries during every refresh, stale snapshot reloads do not steal chat focus, message rendering is bounded to the visible window, message cursor scrolling behaves like the chat list viewport, duplicate in-flight history requests are suppressed, and pixel media/avatar/sticker overlays pause into blank reserved space while scrolling before resyncing.
3232
- Terminal/UI polish for real chat data: full/compat/auto emoji rendering, stable emoji fallback for terminals such as `st`, Windows Terminal, and classic Windows console hosts, Windows console output-mode setup with a two-column guarded render width plus a final frame clamp to avoid edge-wrap frame corruption, Windows terminal-size polling plus a lower default renderer FPS for smoother ConPTY behavior, pywal-backed mode indicators with per-mode hex overrides, focused-pane borders that distinguish chat-list vs message-pane input focus, highlighted unread-count badges in the chat list, a structured keymap-driven help screen, non-redundant mode prompts, configurable help/prompt key hints, search match counts in the status bar, configurable cancellation/search-clear bindings, platform-specific terminal focus-reporting options, and native Windows runtime defaults that do not affect Linux behavior.
3333
- Search now folds accents only for accent-free queries, so `/Jose` matches `José` while `/José` stays accent-sensitive, with matching highlight behavior in the TUI and the same rule in store-backed chat/message filtering.
3434
- Demo/dev workflows that exercise the full local UI without a live WhatsApp session.

‎internal/store/chat_merge.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -874,9 +874,11 @@ func refreshMergedChatState(ctx context.Context, tx *sql.Tx, merged chatMergeRow
874874
}
875875
if _, err := tx.ExecContext(ctx, `
876876
UPDATE chats
877-
SET unread_count = ?, pinned = ?, muted = ?, muted_until = ?, last_message_at = ?, updated_at = ?
877+
SET unread_count = ?, pinned = ?, muted = ?, muted_until = ?, last_message_at = ?,
878+
last_preview = `+chatPreviewSQL("?")+`,
879+
updated_at = ?
878880
WHERE id = ?
879-
`, merged.Unread, boolToInt(merged.Pinned), boolToInt(merged.Muted), chatMuteUntilUnix(merged), lastMessageAt, time.Now().Unix(), canonicalID); err != nil {
881+
`, merged.Unread, boolToInt(merged.Pinned), boolToInt(merged.Muted), chatMuteUntilUnix(merged), lastMessageAt, canonicalID, time.Now().Unix(), canonicalID); err != nil {
880882
return fmt.Errorf("refresh merged chat %s: %w", canonicalID, err)
881883
}
882884
return nil

‎internal/store/repository.go‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const (
1818
FROM media_metadata visible_mm
1919
WHERE visible_mm.message_id = m.id
2020
))`
21-
chatLastPreviewSQL = `COALESCE((
21+
chatLastPreviewSQL = `c.last_preview AS last_preview`
22+
)
23+
24+
func chatPreviewSQL(chatIDExpr string) string {
25+
return `COALESCE((
2226
SELECT CASE
2327
WHEN trim(m.body) <> '' THEN m.body
2428
ELSE COALESCE((
@@ -32,15 +36,15 @@ const (
3236
ORDER BY mm.file_name ASC
3337
LIMIT 1
3438
), '')
35-
END
39+
END
3640
FROM messages m
37-
WHERE m.chat_id = c.id
41+
WHERE m.chat_id = ` + chatIDExpr + `
3842
AND m.deleted_at = 0
3943
AND ` + renderableMessageWhereSQL + `
4044
ORDER BY m.timestamp_unix DESC, m.id DESC
4145
LIMIT 1
42-
), '') AS last_preview`
43-
)
46+
), '')`
47+
}
4448

4549
type ChatUpsertOptions struct {
4650
PreserveUnreadOnUpdate bool
@@ -668,6 +672,48 @@ func (s *Store) chatTitleState(ctx context.Context, id string) (Chat, bool, erro
668672
return chat, true, nil
669673
}
670674

675+
type chatPreviewExecer interface {
676+
ExecContext(context.Context, string, ...any) (sql.Result, error)
677+
QueryRowContext(context.Context, string, ...any) *sql.Row
678+
}
679+
680+
func refreshChatPreview(ctx context.Context, execer chatPreviewExecer, chatID string) error {
681+
chatID = strings.TrimSpace(chatID)
682+
if chatID == "" {
683+
return nil
684+
}
685+
if _, err := execer.ExecContext(ctx, `
686+
UPDATE chats
687+
SET last_preview = `+chatPreviewSQL("?")+`,
688+
updated_at = ?
689+
WHERE id = ?
690+
`, chatID, time.Now().Unix(), chatID); err != nil {
691+
return fmt.Errorf("refresh chat preview %s: %w", chatID, err)
692+
}
693+
return nil
694+
}
695+
696+
func refreshChatPreviewForMessage(ctx context.Context, execer chatPreviewExecer, messageID string) error {
697+
messageID = strings.TrimSpace(messageID)
698+
if messageID == "" {
699+
return nil
700+
}
701+
702+
var chatID string
703+
err := execer.QueryRowContext(ctx, `
704+
SELECT chat_id
705+
FROM messages
706+
WHERE id = ?
707+
`, messageID).Scan(&chatID)
708+
if err != nil {
709+
if err == sql.ErrNoRows {
710+
return nil
711+
}
712+
return fmt.Errorf("load chat for preview message %s: %w", messageID, err)
713+
}
714+
return refreshChatPreview(ctx, execer, chatID)
715+
}
716+
671717
func (s *Store) AddMessage(ctx context.Context, message Message) error {
672718
_, err := s.addMessage(ctx, message, false)
673719
return err
@@ -844,6 +890,10 @@ func (s *Store) addMessage(ctx context.Context, message Message, incrementUnread
844890
_ = tx.Rollback()
845891
return false, fmt.Errorf("chat %s does not exist", message.ChatID)
846892
}
893+
if err := refreshChatPreview(ctx, tx, message.ChatID); err != nil {
894+
_ = tx.Rollback()
895+
return false, err
896+
}
847897

848898
if err := tx.Commit(); err != nil {
849899
return false, fmt.Errorf("commit add message: %w", err)
@@ -973,6 +1023,10 @@ func (s *Store) UpdateMessageBody(ctx context.Context, messageID, body string, e
9731023
_ = tx.Rollback()
9741024
return false, fmt.Errorf("touch chat %s after edit: %w", chatID, err)
9751025
}
1026+
if err := refreshChatPreview(ctx, tx, chatID); err != nil {
1027+
_ = tx.Rollback()
1028+
return false, err
1029+
}
9761030
if err := tx.Commit(); err != nil {
9771031
return false, fmt.Errorf("commit edit message: %w", err)
9781032
}
@@ -1163,6 +1217,10 @@ func (s *Store) markMessageDeleted(ctx context.Context, messageID, reason string
11631217
_ = tx.Rollback()
11641218
return false, fmt.Errorf("refresh chat %s after delete: %w", chatID, err)
11651219
}
1220+
if err := refreshChatPreview(ctx, tx, chatID); err != nil {
1221+
_ = tx.Rollback()
1222+
return false, err
1223+
}
11661224

11671225
if err := tx.Commit(); err != nil {
11681226
return false, fmt.Errorf("commit delete message: %w", err)
@@ -1693,12 +1751,27 @@ func replaceMessageMentions(ctx context.Context, execer groupParticipantExecer,
16931751
}
16941752

16951753
func (s *Store) UpsertMediaMetadata(ctx context.Context, media MediaMetadata) error {
1696-
return s.UpsertMediaMetadataWithDownload(ctx, media, nil)
1754+
tx, err := s.db.BeginTx(ctx, nil)
1755+
if err != nil {
1756+
return fmt.Errorf("begin upsert media metadata: %w", err)
1757+
}
1758+
if err := upsertMediaMetadata(ctx, tx, media); err != nil {
1759+
_ = tx.Rollback()
1760+
return err
1761+
}
1762+
if err := refreshChatPreviewForMessage(ctx, tx, media.MessageID); err != nil {
1763+
_ = tx.Rollback()
1764+
return err
1765+
}
1766+
if err := tx.Commit(); err != nil {
1767+
return fmt.Errorf("commit upsert media metadata: %w", err)
1768+
}
1769+
return nil
16971770
}
16981771

16991772
func (s *Store) UpsertMediaMetadataWithDownload(ctx context.Context, media MediaMetadata, descriptor *MediaDownloadDescriptor) error {
17001773
if descriptor == nil {
1701-
return upsertMediaMetadata(ctx, s.db, media)
1774+
return s.UpsertMediaMetadata(ctx, media)
17021775
}
17031776

17041777
tx, err := s.db.BeginTx(ctx, nil)
@@ -1716,6 +1789,10 @@ func (s *Store) UpsertMediaMetadataWithDownload(ctx context.Context, media Media
17161789
_ = tx.Rollback()
17171790
return err
17181791
}
1792+
if err := refreshChatPreviewForMessage(ctx, tx, media.MessageID); err != nil {
1793+
_ = tx.Rollback()
1794+
return err
1795+
}
17191796
if err := tx.Commit(); err != nil {
17201797
return fmt.Errorf("commit upsert media metadata: %w", err)
17211798
}

‎internal/store/store.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,14 @@ var migrations = []migration{
246246
`ALTER TABLE chats ADD COLUMN muted_until INTEGER NOT NULL DEFAULT 0`,
247247
},
248248
},
249+
{
250+
name: "0013_denormalized_chat_preview",
251+
sql: []string{
252+
`ALTER TABLE chats ADD COLUMN last_preview TEXT NOT NULL DEFAULT ''`,
253+
`UPDATE chats
254+
SET last_preview = ` + chatPreviewSQL("chats.id"),
255+
},
256+
},
249257
}
250258

251259
func Open(path string) (*Store, error) {
@@ -287,6 +295,8 @@ func (s *Store) initialize(ctx context.Context) error {
287295
`PRAGMA busy_timeout = 5000`,
288296
`PRAGMA synchronous = NORMAL`,
289297
`PRAGMA temp_store = MEMORY`,
298+
`PRAGMA cache_size = -32000`,
299+
`PRAGMA mmap_size = 134217728`,
290300
`CREATE TABLE IF NOT EXISTS schema_migrations (
291301
id INTEGER PRIMARY KEY,
292302
name TEXT NOT NULL UNIQUE,
@@ -303,6 +313,9 @@ func (s *Store) initialize(ctx context.Context) error {
303313
if err := s.applyMigrations(ctx); err != nil {
304314
return err
305315
}
316+
if _, err := s.db.ExecContext(ctx, `PRAGMA optimize`); err != nil {
317+
return fmt.Errorf("optimize sqlite: %w", err)
318+
}
306319

307320
return nil
308321
}

0 commit comments

Comments
 (0)