Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion lib/pages/chat/chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,12 @@ class ChatController extends State<Chat>

final int _loadHistoryCount = 100;

/// Maximum number of events to keep in memory per timeline.
/// When exceeded after a history request, older events are trimmed
/// to prevent unbounded memory growth during long scroll sessions.
/// 500 events ≈ a generous scroll buffer while keeping memory bounded.
static const int _maxTimelineEvents = 500;

final inputText = ValueNotifier('');

String pendingText = '';
Expand Down Expand Up @@ -523,10 +529,11 @@ class ChatController extends State<Chat>
if (!timeline!.canRequestHistory) return;
Logs().v('Chat::requestHistory(): Requesting history...');
try {
return timeline!.requestHistory(
await timeline!.requestHistory(
historyCount: historyCount ?? _loadHistoryCount,
filter: filter,
);
_trimTimelineIfNeeded();
} catch (err) {
ScaffoldMessenger.of(
context,
Expand All @@ -535,6 +542,26 @@ class ChatController extends State<Chat>
}
}

/// Trims the timeline event list if it exceeds [_maxTimelineEvents].
///
/// Removes the oldest events (at the end of the list, since events
/// are ordered newest-first) to keep memory bounded during long
/// scroll sessions. This is safe because:
/// - The trimmed events are still persisted in the database
/// - They will be re-loaded if the user scrolls back to that point
/// - The SDK's `canRequestHistory` still works correctly
void _trimTimelineIfNeeded() {
final events = timeline?.events;
if (events == null || events.length <= _maxTimelineEvents) return;

final excess = events.length - _maxTimelineEvents;
Logs().d(
'Chat::_trimTimelineIfNeeded(): Trimming $excess old events '
'(${events.length} -> $_maxTimelineEvents)',
);
events.removeRange(_maxTimelineEvents, events.length);
}

void _updateScrollController() async {
if (!mounted) {
return;
Expand Down Expand Up @@ -1267,6 +1294,9 @@ class ChatController extends State<Chat>
void scrollDown() async {
if (timeline == null) return;
if (!timeline!.allowNewEvent) {
// Cancel subscriptions BEFORE nulling the reference to avoid
// leaking the old Timeline's 5 stream subscriptions.
timeline!.cancelSubscriptions();
setState(() {
timeline = null;
loadTimelineFuture = _getTimeline().onError((e, s) {
Expand Down Expand Up @@ -1318,6 +1348,9 @@ class ChatController extends State<Chat>
);
return;
}
// Cancel subscriptions BEFORE nulling the reference to avoid
// leaking the old Timeline's 5 stream subscriptions.
timeline?.cancelSubscriptions();
setState(() {
timeline = null;
loadTimelineFuture = _getTimeline(eventContextId: eventId).onError((
Expand Down Expand Up @@ -1607,6 +1640,9 @@ class ChatController extends State<Chat>
}) async {
Logs().d('$logContext: Reloading timeline centered on $eventId');

// Cancel subscriptions BEFORE nulling the reference to avoid
// leaking the old Timeline's 5 stream subscriptions.
timeline?.cancelSubscriptions();
setState(() {
timeline = null;
loadTimelineFuture = _getTimeline(eventContextId: eventId).onError((
Expand Down Expand Up @@ -3690,6 +3726,13 @@ class ChatController extends State<Chat>
disposeAutoMarkAsReadMixin();
timeline?.cancelSubscriptions();
timeline = null;

// Evict decoded images from Flutter's image cache to free GPU/raster
// memory. This is important because decoded bitmaps can consume
// significantly more memory than their compressed form.
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();

inputFocus.dispose();
searchEmojiFocusNode.dispose();
composerDebouncer.cancel();
Expand Down
Loading