Skip to content

Commit ea67860

Browse files
committed
implemented the startup sync guard
1 parent b7fa650 commit ea67860

5 files changed

Lines changed: 253 additions & 3 deletions

File tree

‎PLAN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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, 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.
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, immediate startup/catch-up sync overlay blocking, 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

@@ -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, 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.
31+
- Large-history TUI guardrails: paired live startup immediately shows a blocking sync/check overlay until catch-up is known to be complete or absent, 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/app/app.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ func runTUI(env Environment, stderr io.Writer) int {
177177
ConnectionState: initialConnection,
178178
LiveUpdates: liveUpdateSource,
179179
RequireOnlineForSend: liveEnabled,
180+
BlockLiveStartup: liveEnabled && initialConnection == ui.ConnectionPaired,
180181
PersistMessage: func(outgoing ui.OutgoingMessage) (store.Message, error) {
181182
if liveEnabled {
182183
if len(outgoing.Attachments) > 0 {
@@ -503,6 +504,7 @@ var (
503504
offlineSyncInactivity = 15 * time.Second
504505
offlineSyncMaxDuration = 60 * time.Second
505506
databaseImportInactivity = 750 * time.Millisecond
507+
liveStartupSyncSettle = 300 * time.Millisecond
506508
)
507509

508510
type textSendRequest struct {
@@ -816,6 +818,7 @@ func runLiveWhatsApp(
816818
sendLiveUpdate(ctx, updates, ui.LiveUpdate{
817819
ConnectionState: ui.ConnectionOffline,
818820
Status: fmt.Sprintf("whatsapp open failed: %s", shortStatusError(err)),
821+
Sync: &ui.SyncProgressUpdate{},
819822
})
820823
return
821824
}
@@ -828,6 +831,7 @@ func runLiveWhatsApp(
828831
sendLiveUpdate(ctx, updates, ui.LiveUpdate{
829832
ConnectionState: ui.ConnectionOffline,
830833
Status: "whatsapp live session unavailable",
834+
Sync: &ui.SyncProgressUpdate{},
831835
})
832836
return
833837
}
@@ -837,6 +841,7 @@ func runLiveWhatsApp(
837841
sendLiveUpdate(ctx, updates, ui.LiveUpdate{
838842
ConnectionState: ui.ConnectionOffline,
839843
Status: fmt.Sprintf("whatsapp subscribe failed: %s", shortStatusError(err)),
844+
Sync: &ui.SyncProgressUpdate{},
840845
})
841846
return
842847
}
@@ -852,6 +857,7 @@ func runLiveWhatsApp(
852857
sendLiveUpdate(ctx, updates, ui.LiveUpdate{
853858
ConnectionState: state,
854859
Status: fmt.Sprintf("whatsapp connect failed: %s", shortStatusError(err)),
860+
Sync: &ui.SyncProgressUpdate{},
855861
})
856862
return
857863
}
@@ -898,6 +904,53 @@ func runLiveWhatsApp(
898904
viewState := notificationContext{}
899905
online := true
900906
pendingPreferredChatID := ""
907+
startupSyncPending := true
908+
var startupSyncTimer *time.Timer
909+
var startupSyncTimerC <-chan time.Time
910+
stopStartupSyncTimer := func() {
911+
if startupSyncTimer != nil {
912+
if !startupSyncTimer.Stop() {
913+
select {
914+
case <-startupSyncTimer.C:
915+
default:
916+
}
917+
}
918+
}
919+
startupSyncTimerC = nil
920+
}
921+
startStartupSyncTimer := func() {
922+
duration := liveStartupSyncSettle
923+
if duration <= 0 {
924+
duration = 1 * time.Millisecond
925+
}
926+
if startupSyncTimer == nil {
927+
startupSyncTimer = time.NewTimer(duration)
928+
} else {
929+
if !startupSyncTimer.Stop() {
930+
select {
931+
case <-startupSyncTimer.C:
932+
default:
933+
}
934+
}
935+
startupSyncTimer.Reset(duration)
936+
}
937+
startupSyncTimerC = startupSyncTimer.C
938+
}
939+
resolveStartupSyncOverlay := func() {
940+
if !startupSyncPending {
941+
return
942+
}
943+
startupSyncPending = false
944+
stopStartupSyncTimer()
945+
}
946+
clearStartupSyncOverlay := func() {
947+
if !startupSyncPending {
948+
return
949+
}
950+
resolveStartupSyncOverlay()
951+
sendLiveUpdate(ctx, updates, ui.LiveUpdate{Sync: &ui.SyncProgressUpdate{}})
952+
}
953+
startStartupSyncTimer()
901954
offlineSync := offlineSyncState{}
902955
startStickerSync(ctx, env.Store, live, env.Paths, updates, nil, online)
903956
var offlineSyncIdleTimer *time.Timer
@@ -971,10 +1024,12 @@ func runLiveWhatsApp(
9711024
pendingPreferredChatID = ""
9721025
}
9731026
defer clearOfflineSyncTimers()
1027+
defer stopStartupSyncTimer()
9741028
for {
9751029
select {
9761030
case event, ok := <-events:
9771031
if !ok {
1032+
clearStartupSyncOverlay()
9781033
sendLiveUpdate(ctx, updates, ui.LiveUpdate{ConnectionState: ui.ConnectionOffline})
9791034
return
9801035
}
@@ -983,13 +1038,16 @@ func runLiveWhatsApp(
9831038
online = event.Connection.State == whatsapp.ConnectionOnline
9841039
if online {
9851040
markLivePresenceAvailable(ctx, live, updates)
1041+
} else {
1042+
clearStartupSyncOverlay()
9861043
}
9871044
sendLiveUpdate(ctx, updates, liveUpdateForConnectionEvent(event.Connection))
9881045
continue
9891046
}
9901047
if event.Kind == whatsapp.EventOfflineSync {
9911048
now := time.Now()
9921049
if event.Offline.Active {
1050+
resolveStartupSyncOverlay()
9931051
syncUpdate := offlineSync.start(event.Offline, now)
9941052
resetOfflineSyncIdleTimer(offlineSync.idleDuration())
9951053
startOfflineSyncMaxTimer()
@@ -1006,6 +1064,7 @@ func runLiveWhatsApp(
10061064
}
10071065
manualHistoryImport := isManualHistoryImportEvent(event, historyInflight)
10081066
if isImplicitDatabaseImportEvent(event, manualHistoryImport) && !offlineSync.active {
1067+
resolveStartupSyncOverlay()
10091068
syncUpdate := offlineSync.startImplicit(time.Now())
10101069
resetOfflineSyncIdleTimer(offlineSync.idleDuration())
10111070
startOfflineSyncMaxTimer()
@@ -1254,6 +1313,12 @@ func runLiveWhatsApp(
12541313
Total: offlineSync.total,
12551314
Processed: offlineSync.processed,
12561315
})
1316+
case <-startupSyncTimerC:
1317+
if offlineSync.active {
1318+
resolveStartupSyncOverlay()
1319+
continue
1320+
}
1321+
clearStartupSyncOverlay()
12571322
case <-ctx.Done():
12581323
return
12591324
}

‎internal/app/whatsapp_cli_test.go‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,125 @@ func TestRunLiveWhatsAppSyncsStickersAfterConnect(t *testing.T) {
604604
}
605605
}
606606

607+
func TestRunLiveWhatsAppClearsStartupSyncOverlayWhenNoImportArrives(t *testing.T) {
608+
prevSettle := liveStartupSyncSettle
609+
liveStartupSyncSettle = 20 * time.Millisecond
610+
t.Cleanup(func() {
611+
liveStartupSyncSettle = prevSettle
612+
})
613+
614+
ctx, cancel := context.WithCancel(context.Background())
615+
defer cancel()
616+
617+
db, err := store.Open(filepath.Join(t.TempDir(), "state.sqlite3"))
618+
if err != nil {
619+
t.Fatalf("store.Open() error = %v", err)
620+
}
621+
t.Cleanup(func() {
622+
_ = db.Close()
623+
})
624+
625+
session := &fakeLiveWhatsAppSession{
626+
events: make(chan whatsapp.Event, 8),
627+
}
628+
env := Environment{
629+
Paths: config.Paths{SessionFile: "/tmp/vimwhat-session.sqlite3"},
630+
Store: db,
631+
OpenWhatsAppSession: func(context.Context, string) (WhatsAppSession, error) {
632+
return session, nil
633+
},
634+
}
635+
636+
updates := make(chan ui.LiveUpdate, 16)
637+
done := make(chan struct{})
638+
go func() {
639+
defer close(done)
640+
runLiveWhatsApp(ctx, env, updates, make(chan string, 16), make(chan textSendRequest, 16), make(chan mediaSendRequest, 16), make(chan readReceiptRequest, 16), make(chan reactionRequest, 16), make(chan deleteEveryoneRequest, 16), make(chan editMessageRequest, 16), make(chan forwardMessagesRequest, 16), make(chan presenceRequest, 16), make(chan presenceSubscribeRequest, 16), make(chan mediaDownloadRequest, 16), make(chan stickerSyncRequest, 16), make(chan string, 16), make(chan bool, 16), make(chan []string, 16))
641+
}()
642+
643+
waitForLiveUpdate(t, updates, func(update ui.LiveUpdate) bool {
644+
return update.ConnectionState == ui.ConnectionOnline
645+
})
646+
clear := waitForLiveUpdate(t, updates, func(update ui.LiveUpdate) bool {
647+
return update.Sync != nil && !update.Sync.Active && !update.Sync.Completed
648+
})
649+
if clear.Refresh || clear.Status != "" {
650+
t.Fatalf("startup sync clear update = %+v, want only sync clear", clear)
651+
}
652+
653+
cancel()
654+
select {
655+
case <-done:
656+
case <-time.After(time.Second):
657+
t.Fatal("runLiveWhatsApp did not stop after cancellation")
658+
}
659+
}
660+
661+
func TestRunLiveWhatsAppStartupSyncOverlayHandsOffToOfflineSync(t *testing.T) {
662+
prevSettle := liveStartupSyncSettle
663+
liveStartupSyncSettle = 80 * time.Millisecond
664+
t.Cleanup(func() {
665+
liveStartupSyncSettle = prevSettle
666+
})
667+
668+
ctx, cancel := context.WithCancel(context.Background())
669+
defer cancel()
670+
671+
db, err := store.Open(filepath.Join(t.TempDir(), "state.sqlite3"))
672+
if err != nil {
673+
t.Fatalf("store.Open() error = %v", err)
674+
}
675+
t.Cleanup(func() {
676+
_ = db.Close()
677+
})
678+
679+
session := &fakeLiveWhatsAppSession{
680+
events: make(chan whatsapp.Event, 8),
681+
}
682+
env := Environment{
683+
Paths: config.Paths{SessionFile: "/tmp/vimwhat-session.sqlite3"},
684+
Store: db,
685+
OpenWhatsAppSession: func(context.Context, string) (WhatsAppSession, error) {
686+
return session, nil
687+
},
688+
}
689+
690+
updates := make(chan ui.LiveUpdate, 16)
691+
done := make(chan struct{})
692+
go func() {
693+
defer close(done)
694+
runLiveWhatsApp(ctx, env, updates, make(chan string, 16), make(chan textSendRequest, 16), make(chan mediaSendRequest, 16), make(chan readReceiptRequest, 16), make(chan reactionRequest, 16), make(chan deleteEveryoneRequest, 16), make(chan editMessageRequest, 16), make(chan forwardMessagesRequest, 16), make(chan presenceRequest, 16), make(chan presenceSubscribeRequest, 16), make(chan mediaDownloadRequest, 16), make(chan stickerSyncRequest, 16), make(chan string, 16), make(chan bool, 16), make(chan []string, 16))
695+
}()
696+
697+
waitForLiveUpdate(t, updates, func(update ui.LiveUpdate) bool {
698+
return update.ConnectionState == ui.ConnectionOnline
699+
})
700+
session.events <- whatsapp.Event{
701+
Kind: whatsapp.EventOfflineSync,
702+
Offline: whatsapp.OfflineSyncEvent{
703+
Active: true,
704+
Total: 4,
705+
Messages: 4,
706+
},
707+
}
708+
start := waitForLiveUpdate(t, updates, func(update ui.LiveUpdate) bool {
709+
return update.Sync != nil && update.Sync.Active
710+
})
711+
if start.Sync.Total != 4 || start.Sync.Messages != 4 {
712+
t.Fatalf("offline sync start update = %+v", start)
713+
}
714+
assertNoLiveUpdate(t, updates, func(update ui.LiveUpdate) bool {
715+
return update.Sync != nil && !update.Sync.Active && !update.Sync.Completed
716+
})
717+
718+
cancel()
719+
select {
720+
case <-done:
721+
case <-time.After(time.Second):
722+
t.Fatal("runLiveWhatsApp did not stop after cancellation")
723+
}
724+
}
725+
607726
func TestRunLiveWhatsAppBatchesOfflineSyncRefreshAndNotifications(t *testing.T) {
608727
ctx, cancel := context.WithCancel(context.Background())
609728
defer cancel()

‎internal/ui/model.go‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ type Options struct {
261261
BlockSending bool
262262
BlockAttachments bool
263263
RequireOnlineForSend bool
264+
BlockLiveStartup bool
264265
PersistMessage func(OutgoingMessage) (store.Message, error)
265266
SendSticker func(chatID string, sticker store.RecentSticker) (store.Message, error)
266267
RetryMessage func(message store.Message) (store.Message, error)
@@ -465,6 +466,18 @@ type syncOverlayState struct {
465466
Receipts int
466467
}
467468

469+
func initialSyncOverlay(blockLiveStartup bool) syncOverlayState {
470+
if !blockLiveStartup {
471+
return syncOverlayState{}
472+
}
473+
return syncOverlayState{
474+
Visible: true,
475+
Active: true,
476+
Title: "Connecting to WhatsApp",
477+
Subtitle: "Checking for new chats and messages before the app becomes interactive.",
478+
}
479+
}
480+
468481
func Run(opts Options) (err error) {
469482
report, restore := prepareTerminalOutput()
470483
defer func() {
@@ -564,6 +577,7 @@ func NewModel(opts Options) Model {
564577
presenceSubscribed: map[string]bool{},
565578
messageLimitsByChat: map[string]int{},
566579
historyRequestedByChat: map[string]bool{},
580+
syncOverlay: initialSyncOverlay(opts.BlockLiveStartup),
567581
pollTerminalSize: opts.PollTerminalSize,
568582
}
569583
if model.pasteAttachmentFromClipboard == nil {
@@ -769,7 +783,7 @@ func (m Model) handleSyncProgress(update SyncProgressUpdate) (Model, tea.Cmd) {
769783
if update.Active {
770784
m.leaderPending = false
771785
m.leaderSequence = ""
772-
if strings.TrimSpace(m.status) == "" || m.status == "ready" {
786+
if syncProgressShouldReplaceStatus(m.status) {
773787
m.status = syncProgressStatus(update, "syncing WhatsApp updates")
774788
}
775789
return m, nil
@@ -792,6 +806,15 @@ func syncProgressStatus(update SyncProgressUpdate, fallback string) string {
792806
return fallback
793807
}
794808

809+
func syncProgressShouldReplaceStatus(status string) bool {
810+
status = strings.ToLower(strings.TrimSpace(status))
811+
return status == "" ||
812+
status == "ready" ||
813+
strings.Contains(status, "syncing") ||
814+
strings.Contains(status, "updating local database") ||
815+
strings.Contains(status, "connecting to whatsapp")
816+
}
817+
795818
func syncOverlayDoneCmd(generation int) tea.Cmd {
796819
return tea.Tick(syncOverlayCompleteDelay, func(time.Time) tea.Msg {
797820
return syncOverlayDoneMsg{Generation: generation}

0 commit comments

Comments
 (0)