Skip to content

Commit ffc9ce8

Browse files
devohmycodeclaude
andcommitted
feat: replace Firebase periodic polling with real-time SSE listener
Switch from 45-second DispatcherTimer polling to Server-Sent Events streaming for instant sync. Open note windows now refresh automatically when remote changes arrive. Fix timezone-aware date comparisons using ToUniversalTime() and prevent sync loops with localOnly save flag. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 628cc31 commit ffc9ce8

4 files changed

Lines changed: 150 additions & 32 deletions

File tree

‎FirebaseSync.cs‎

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public class FirebaseSync : IDisposable
2929
private string? _refreshToken;
3030
private Timer? _refreshTimer;
3131

32+
private CancellationTokenSource? _listenerCts;
33+
private int _sseSkips;
34+
3235
public bool IsConfigured => !string.IsNullOrEmpty(_databaseUrl) && !string.IsNullOrEmpty(_apiKey);
3336
public bool IsConnected => _idToken != null;
3437
public string? Email { get; private set; }
@@ -377,7 +380,7 @@ public static List<NoteEntry> MergePullWithLocal(
377380

378381
private static NoteEntry MergeRemoteIntoLocal(NoteEntry local, NoteEntry remote)
379382
{
380-
if (remote.UpdatedAt <= local.UpdatedAt)
383+
if (remote.UpdatedAt.ToUniversalTime() <= local.UpdatedAt.ToUniversalTime())
381384
return local;
382385
return new NoteEntry
383386
{
@@ -465,7 +468,7 @@ private static Dictionary<string, NoteEntry> MergeNotesForPush(
465468
merged[id] = l;
466469
continue;
467470
}
468-
merged[id] = l.UpdatedAt >= r.UpdatedAt ? l : r;
471+
merged[id] = l.UpdatedAt.ToUniversalTime() >= r.UpdatedAt.ToUniversalTime() ? l : r;
469472
}
470473

471474
foreach (var (id, r) in remote)
@@ -589,7 +592,12 @@ public async Task<bool> PushNotesAsync(IReadOnlyList<NoteEntry> notes)
589592
};
590593
var patchJson = JsonSerializer.Serialize(patchPayload);
591594
using var patchResponse = await _http.PatchAsync(userUrl, new StringContent(patchJson, Encoding.UTF8, "application/json"));
592-
return patchResponse.IsSuccessStatusCode;
595+
if (patchResponse.IsSuccessStatusCode)
596+
{
597+
Interlocked.Increment(ref _sseSkips);
598+
return true;
599+
}
600+
return false;
593601
}
594602
catch { return false; }
595603
}
@@ -626,8 +634,90 @@ public async Task<bool> PushSettingsAsync(Dictionary<string, object> settings)
626634
catch { return null; }
627635
}
628636

637+
// ── SSE Real-time Listener ──────────────────────────────────
638+
639+
public void StartListening(Action onDataChanged)
640+
{
641+
StopListening();
642+
_listenerCts = new CancellationTokenSource();
643+
_ = ListenLoopAsync(onDataChanged, _listenerCts.Token);
644+
}
645+
646+
public void StopListening()
647+
{
648+
_listenerCts?.Cancel();
649+
_listenerCts?.Dispose();
650+
_listenerCts = null;
651+
}
652+
653+
private async Task ListenLoopAsync(Action onDataChanged, CancellationToken ct)
654+
{
655+
while (!ct.IsCancellationRequested)
656+
{
657+
try
658+
{
659+
await ListenOnceAsync(onDataChanged, ct);
660+
}
661+
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
662+
catch
663+
{
664+
try { await Task.Delay(5000, ct); } catch { break; }
665+
}
666+
}
667+
}
668+
669+
private async Task ListenOnceAsync(Action onDataChanged, CancellationToken ct)
670+
{
671+
if (!IsConnected || _localId == null)
672+
{
673+
await Task.Delay(5000, ct);
674+
return;
675+
}
676+
677+
var url = $"{_databaseUrl}/users/{_localId}/notes.json?auth={_idToken}";
678+
using var request = new HttpRequestMessage(HttpMethod.Get, url);
679+
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
680+
681+
using var sseClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
682+
using var response = await sseClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
683+
response.EnsureSuccessStatusCode();
684+
685+
await using var stream = await response.Content.ReadAsStreamAsync(ct);
686+
using var reader = new StreamReader(stream);
687+
688+
var isFirstPut = true;
689+
690+
while (!ct.IsCancellationRequested)
691+
{
692+
var line = await reader.ReadLineAsync(ct);
693+
if (line == null) break;
694+
695+
if (!line.StartsWith("event:", StringComparison.Ordinal)) continue;
696+
var eventType = line["event:".Length..].Trim();
697+
698+
if (eventType is not "put" and not "patch") continue;
699+
700+
// Skip the initial full snapshot (we already have the data)
701+
if (isFirstPut)
702+
{
703+
isFirstPut = false;
704+
continue;
705+
}
706+
707+
// Skip echoes of our own writes
708+
if (Interlocked.CompareExchange(ref _sseSkips, 0, 0) > 0)
709+
{
710+
Interlocked.Decrement(ref _sseSkips);
711+
continue;
712+
}
713+
714+
onDataChanged();
715+
}
716+
}
717+
629718
public void Dispose()
630719
{
720+
StopListening();
631721
_refreshTimer?.Dispose();
632722
_http.Dispose();
633723
}

‎MainWindow.xaml.cs‎

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ private enum ViewMode { Notes, Favorites, Tags, TagFilter, Archive }
4646
private const int DefaultFullHeight = 650;
4747
private const int CompactHeight = 40;
4848
private Microsoft.UI.Xaml.DispatcherTimer? _animTimer;
49-
private Microsoft.UI.Xaml.DispatcherTimer? _firebasePeriodicTimer;
5049
private int _preCompactWidth = 380;
5150
private int _preCompactHeight = DefaultFullHeight;
5251
private int _targetHeight;
@@ -125,7 +124,7 @@ public MainWindow()
125124
this.Closed += (_, _) =>
126125
{
127126
SaveMainWindowPosition();
128-
StopFirebasePeriodicPull();
127+
StopFirebaseListener();
129128
_attachmentService?.Dispose();
130129
foreach (var w in _attachedNoteWindows.Values.ToList())
131130
{
@@ -182,42 +181,50 @@ private async Task InitCloudSync()
182181
await _notes.InitWebDavFromSettings();
183182
UpdateSyncButtonVisibility();
184183
RefreshCurrentView();
185-
StartFirebasePeriodicPull();
184+
StartFirebaseListener();
186185
}
187186

188-
/// <summary>Background pull from RTDB (same idea as web <c>onValue</c>), so web deletes propagate without pressing Sync.</summary>
189-
private void StartFirebasePeriodicPull()
187+
/// <summary>Real-time SSE listener on RTDB notes, replaces periodic polling.</summary>
188+
private void StartFirebaseListener()
190189
{
191-
StopFirebasePeriodicPull();
192-
if (_notes.Firebase is not { IsConnected: true }) return;
193-
_firebasePeriodicTimer = new Microsoft.UI.Xaml.DispatcherTimer
194-
{
195-
Interval = TimeSpan.FromSeconds(45),
196-
};
197-
_firebasePeriodicTimer.Tick += (_, _) =>
190+
StopFirebaseListener();
191+
if (_notes.Firebase is not { IsConnected: true } fb) return;
192+
fb.StartListening(() =>
198193
{
199194
DispatcherQueue.TryEnqueue(() => _ = PullFirebaseInBackgroundAsync());
200-
};
201-
_firebasePeriodicTimer.Start();
195+
});
202196
}
203197

204-
private void StopFirebasePeriodicPull()
198+
private void StopFirebaseListener()
205199
{
206-
if (_firebasePeriodicTimer != null)
207-
{
208-
_firebasePeriodicTimer.Stop();
209-
_firebasePeriodicTimer = null;
210-
}
200+
_notes.Firebase?.StopListening();
211201
}
212202

213203
private async Task PullFirebaseInBackgroundAsync()
214204
{
215205
if (_notes.IsSyncing) return;
216206
if (_notes.Firebase is not { IsConnected: true }) return;
217207
await _notes.SyncFromFirebase();
208+
RefreshOpenNoteWindows();
218209
RefreshCurrentView();
219210
}
220211

212+
private void RefreshOpenNoteWindows()
213+
{
214+
foreach (var w in _openNoteWindows)
215+
{
216+
var updated = _notes.Notes.FirstOrDefault(n => n.Id == w.NoteId);
217+
if (updated != null)
218+
w.ReloadAfterSync(updated);
219+
}
220+
foreach (var (id, w) in _attachedNoteWindows)
221+
{
222+
var updated = _notes.Notes.FirstOrDefault(n => n.Id == id);
223+
if (updated != null)
224+
w.ReloadAfterSync(updated);
225+
}
226+
}
227+
221228
private void UpdateSyncButtonVisibility()
222229
{
223230
SyncButton.Visibility = _notes.Firebase is { IsConnected: true }
@@ -232,6 +239,7 @@ private async void SyncButton_Click(object sender, RoutedEventArgs e)
232239
{
233240
await _notes.SyncSettingsFromFirebase();
234241
await _notes.SyncFromFirebase();
242+
RefreshOpenNoteWindows();
235243
RefreshCurrentView();
236244
}
237245
finally
@@ -1119,13 +1127,14 @@ private void Settings_Click(object sender, RoutedEventArgs e)
11191127
onConfigureFirebase: async () => await ShowFirebaseConfigDialog(),
11201128
onDisconnectFirebase: () =>
11211129
{
1122-
StopFirebasePeriodicPull();
1130+
StopFirebaseListener();
11231131
_notes.DisconnectFirebase();
11241132
UpdateSyncButtonVisibility();
11251133
},
11261134
onSyncFirebase: async () =>
11271135
{
11281136
await _notes.SyncFromFirebase();
1137+
RefreshOpenNoteWindows();
11291138
RefreshCurrentView();
11301139
},
11311140
onConfigureWebDav: async () => await ShowWebDavConfigDialog(),
@@ -1560,9 +1569,10 @@ private async Task ShowFirebaseConfigDialog()
15601569
if (success)
15611570
{
15621571
await _notes.SyncFromFirebase();
1572+
RefreshOpenNoteWindows();
15631573
UpdateSyncButtonVisibility();
15641574
RefreshCurrentView();
1565-
StartFirebasePeriodicPull();
1575+
StartFirebaseListener();
15661576
googleSignInDone = true;
15671577
}
15681578
else
@@ -1596,9 +1606,10 @@ private async Task ShowFirebaseConfigDialog()
15961606
if (success)
15971607
{
15981608
await _notes.SyncFromFirebase();
1609+
RefreshOpenNoteWindows();
15991610
UpdateSyncButtonVisibility();
16001611
RefreshCurrentView();
1601-
StartFirebasePeriodicPull();
1612+
StartFirebaseListener();
16021613
break;
16031614
}
16041615

‎NoteWindow.xaml.cs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace NoteUI;
1313
public sealed partial class NoteWindow : Window
1414
{
1515
private readonly NotesManager _notesManager;
16-
private readonly NoteEntry _note;
16+
private NoteEntry _note;
1717
private SnippetManager? _snippetManager;
1818
private bool _isPinnedOnTop;
1919
private bool _suppressTextChanged;
@@ -457,6 +457,21 @@ private void SaveCurrentNote()
457457
NoteChanged?.Invoke();
458458
}
459459

460+
public void ReloadAfterSync(NoteEntry updatedNote)
461+
{
462+
var changed = _note.Content != updatedNote.Content
463+
|| _note.Title != updatedNote.Title
464+
|| _note.Color != updatedNote.Color;
465+
_note = updatedNote;
466+
if (!changed) return;
467+
TitleText.Text = _note.Title;
468+
if (_note.NoteType == "tasklist")
469+
LoadTaskNoteContent();
470+
else
471+
LoadNote();
472+
ApplyNoteColor(_note.Color);
473+
}
474+
460475
// ── Task note (free-form text in tasklist mode) ─────────────
461476

462477
private void LoadTaskNoteContent()

‎NotesManager.cs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public void ChangeFolder(string newFolder)
4646
Load();
4747
}
4848

49-
public void Save()
49+
public void Save(bool localOnly = false)
5050
{
5151
try
5252
{
@@ -56,6 +56,8 @@ public void Save()
5656
}
5757
catch { }
5858

59+
if (localOnly) return;
60+
5961
// Fire-and-forget push to cloud
6062
if (Firebase is { IsConnected: true })
6163
_ = Firebase.PushNotesAsync(_notes);
@@ -137,7 +139,7 @@ public async Task<bool> SyncFromFirebase()
137139

138140
_notes.Clear();
139141
_notes.AddRange(merged);
140-
Save();
142+
Save(localOnly: true);
141143
return true;
142144
}
143145
catch { return false; }
@@ -264,13 +266,13 @@ public async Task<bool> SyncFromWebDav()
264266
foreach (var n in remote)
265267
{
266268
// Prefer remote when newer or same instant (avoids losing pin-only web updates if clocks align to the same tick).
267-
if (!merged.TryGetValue(n.Id, out var local) || n.UpdatedAt >= local.UpdatedAt)
269+
if (!merged.TryGetValue(n.Id, out var local) || n.UpdatedAt.ToUniversalTime() >= local.UpdatedAt.ToUniversalTime())
268270
merged[n.Id] = n;
269271
}
270272

271273
_notes.Clear();
272274
_notes.AddRange(merged.Values);
273-
Save();
275+
Save(localOnly: true);
274276
return true;
275277
}
276278
catch { return false; }
@@ -631,7 +633,7 @@ private static string StripRtf(string rtf)
631633
}
632634
continue;
633635
}
634-
if (depth <= 1 && (c >= ' ' || c == '\n' || c == '\t'))
636+
if (depth <= 1 && c >= ' ')
635637
result.Append(c);
636638
i++;
637639
}

0 commit comments

Comments
 (0)