Skip to content

Commit b87eab8

Browse files
authored
Fix chat UI bugs and message draft logic (#773)
* Fix chat bubble hugging * Improve and fix message drafts * Fix chat init-scroll * Just format * Push general settings screen changes * Listen active account changes for drafts * Handle account switch in chat input provider * Use logger for draft debug prints * _hasScheduledInitialScroll * Prevent duplicate frame callback * Add post-await mounted check
1 parent af534d6 commit b87eab8

File tree

9 files changed

+533
-79
lines changed

9 files changed

+533
-79
lines changed

ios/Podfile.lock

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -104,25 +104,25 @@ EXTERNAL SOURCES:
104104
:path: ".symlinks/plugins/workmanager_apple/ios"
105105

106106
SPEC CHECKSUMS:
107-
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
108-
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
107+
audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe
108+
emoji_picker_flutter: 8e50ec5caac456a23a78637e02c6293ea0ac8771
109109
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
110-
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
111-
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
112-
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
113-
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
114-
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
115-
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
116-
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
117-
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
118-
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
119-
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
120-
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
121-
rust_lib_whitenoise: 22de658398f8e36a1a396d35b6b6547a0732e6bb
122-
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
123-
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
124-
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
125-
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
110+
flutter_foreground_task: 21ef182ab0a29a3005cc72cd70e5f45cb7f7f817
111+
flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9
112+
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
113+
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
114+
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
115+
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
116+
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
117+
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
118+
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
119+
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
120+
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
121+
rust_lib_whitenoise: 69ef24b69b2aba78a7ebabc09a504b5a39177d21
122+
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
123+
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
124+
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
125+
workmanager_apple: 7bac258335c310689a641e2d66e88d4845d372e9
126126

127127
PODFILE CHECKSUM: e30f02f9d1c72c47bb6344a0a748c9d268180865
128128

lib/config/providers/chat_input_provider.dart

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ChatInputNotifier extends FamilyNotifier<ChatInputState, String> {
3030

3131
static final _logger = Logger('ChatInputNotifier');
3232
late final String _groupId;
33+
late String _accountHexPubkey;
3334
final ImagePickerService _imagePickerService;
3435
final DraftMessageService _draftMessageService;
3536
final Duration _draftSaveDelay;
@@ -44,16 +45,54 @@ class ChatInputNotifier extends FamilyNotifier<ChatInputState, String> {
4445
@override
4546
ChatInputState build(String groupId) {
4647
_groupId = groupId;
48+
final accountPubkey = ref.read(activePubkeyProvider);
49+
_accountHexPubkey = PubkeyFormatter(pubkey: accountPubkey).toHex() ?? '';
50+
51+
ref.listen<String?>(activePubkeyProvider, (previous, next) {
52+
if (previous != next) {
53+
final newAccountHexPubkey = PubkeyFormatter(pubkey: next).toHex() ?? '';
54+
if (_accountHexPubkey != newAccountHexPubkey) {
55+
_draftSaveTimer?.cancel();
56+
_accountHexPubkey = newAccountHexPubkey;
57+
}
58+
}
59+
});
60+
4761
ref.onDispose(() {
4862
_draftSaveTimer?.cancel();
4963
});
5064
return const ChatInputState();
5165
}
5266

67+
Future<void> handleAccountSwitch({
68+
required String? oldPubkey,
69+
required String currentText,
70+
}) async {
71+
if (oldPubkey == null || currentText.isEmpty) return;
72+
73+
final oldAccountHexPubkey = PubkeyFormatter(pubkey: oldPubkey).toHex();
74+
if (oldAccountHexPubkey != null && oldAccountHexPubkey.isNotEmpty) {
75+
await _draftMessageService.saveDraft(
76+
accountId: oldAccountHexPubkey,
77+
chatId: _groupId,
78+
message: currentText,
79+
);
80+
}
81+
}
82+
5383
Future<String?> loadDraft() async {
5484
state = state.copyWith(isLoadingDraft: true);
5585
try {
56-
final draft = await _draftMessageService.loadDraft(chatId: _groupId);
86+
final accountPubkey = ref.read(activePubkeyProvider);
87+
final accountHexPubkey = PubkeyFormatter(pubkey: accountPubkey).toHex() ?? '';
88+
if (accountHexPubkey.isEmpty) return null;
89+
90+
_accountHexPubkey = accountHexPubkey;
91+
92+
final draft = await _draftMessageService.loadDraft(
93+
accountId: accountHexPubkey,
94+
chatId: _groupId,
95+
);
5796
return draft;
5897
} finally {
5998
state = state.copyWith(isLoadingDraft: false);
@@ -74,7 +113,17 @@ class ChatInputNotifier extends FamilyNotifier<ChatInputState, String> {
74113
}
75114

76115
Future<void> _saveDraft(String text) async {
77-
await _draftMessageService.saveDraft(chatId: _groupId, message: text);
116+
final accountPubkey = ref.read(activePubkeyProvider);
117+
final accountHexPubkey = PubkeyFormatter(pubkey: accountPubkey).toHex() ?? '';
118+
if (accountHexPubkey.isEmpty) return;
119+
120+
_accountHexPubkey = accountHexPubkey;
121+
122+
await _draftMessageService.saveDraft(
123+
accountId: accountHexPubkey,
124+
chatId: _groupId,
125+
message: text,
126+
);
78127
}
79128

80129
void hideMediaSelector() {
@@ -218,7 +267,17 @@ class ChatInputNotifier extends FamilyNotifier<ChatInputState, String> {
218267

219268
Future<void> clear() async {
220269
_draftSaveTimer?.cancel();
221-
await _draftMessageService.clearDraft(chatId: _groupId);
270+
271+
final accountPubkey = ref.read(activePubkeyProvider);
272+
final accountHexPubkey = PubkeyFormatter(pubkey: accountPubkey).toHex() ?? '';
273+
if (accountHexPubkey.isNotEmpty) {
274+
_accountHexPubkey = accountHexPubkey;
275+
276+
await _draftMessageService.clearDraft(
277+
accountId: accountHexPubkey,
278+
chatId: _groupId,
279+
);
280+
}
222281
state = state.copyWith(
223282
showMediaSelector: false,
224283
isLoadingDraft: false,

lib/domain/services/draft_message_service.dart

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,80 @@
11
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
2+
import 'package:logging/logging.dart';
23

34
class DraftMessageService {
5+
static final Logger _logger = Logger('DraftMessageService');
46
static const FlutterSecureStorage _defaultStorage = FlutterSecureStorage();
57
static const String _draftPrefix = 'draft_message_';
68

79
Future<void> saveDraft({
10+
required String accountId,
811
required String chatId,
912
required String message,
1013
FlutterSecureStorage? storage,
1114
}) async {
1215
final secureStorage = storage ?? _defaultStorage;
1316
try {
14-
final key = '$_draftPrefix$chatId';
17+
final key = '$_draftPrefix${accountId}_$chatId';
1518
if (message.trim().isEmpty) {
1619
await secureStorage.delete(key: key);
20+
_logger.fine('Draft cleared (accountId=$accountId, chatId=$chatId)');
1721
} else {
1822
await secureStorage.write(key: key, value: message);
23+
_logger.fine(
24+
'Draft saved (accountId=$accountId, chatId=$chatId, length=${message.length})',
25+
);
1926
}
2027
} catch (e) {
2128
return;
2229
}
2330
}
2431

2532
Future<String?> loadDraft({
33+
required String accountId,
2634
required String chatId,
2735
FlutterSecureStorage? storage,
2836
}) async {
2937
final secureStorage = storage ?? _defaultStorage;
3038
try {
31-
final key = '$_draftPrefix$chatId';
39+
final key = '$_draftPrefix${accountId}_$chatId';
3240
return await secureStorage.read(key: key);
3341
} catch (e) {
3442
return null;
3543
}
3644
}
3745

3846
Future<void> clearDraft({
47+
required String accountId,
3948
required String chatId,
4049
FlutterSecureStorage? storage,
4150
}) async {
4251
final secureStorage = storage ?? _defaultStorage;
4352
try {
44-
final key = '$_draftPrefix$chatId';
53+
final key = '$_draftPrefix${accountId}_$chatId';
4554
await secureStorage.delete(key: key);
4655
} catch (e) {
4756
return;
4857
}
4958
}
5059

60+
Future<void> clearDraftsForAccount({
61+
required String accountId,
62+
FlutterSecureStorage? storage,
63+
}) async {
64+
final secureStorage = storage ?? _defaultStorage;
65+
try {
66+
final allKeys = await secureStorage.readAll();
67+
final prefix = '$_draftPrefix${accountId}_';
68+
final draftKeys = allKeys.keys.where((key) => key.startsWith(prefix));
69+
for (final key in draftKeys) {
70+
await secureStorage.delete(key: key);
71+
}
72+
_logger.fine('Cleared drafts for accountId=$accountId');
73+
} catch (e) {
74+
return;
75+
}
76+
}
77+
5178
Future<void> clearAllDrafts({FlutterSecureStorage? storage}) async {
5279
final secureStorage = storage ?? _defaultStorage;
5380
try {

lib/ui/chat/chat_screen.dart

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
4646
ProviderSubscription<ChatState>? _chatSubscription;
4747
bool _hasInitialScrollCompleted = false;
4848
bool _isKeyboardOpen = false;
49+
bool _hasScheduledInitialScroll = false;
4950

5051
static const double _scrollBottomThreshold = 50.0;
5152

@@ -75,6 +76,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
7576
super.didUpdateWidget(oldWidget);
7677
if (oldWidget.groupId != widget.groupId) {
7778
_hasInitialScrollCompleted = false; // Reset for new chat
79+
_hasScheduledInitialScroll = false; // Reset scheduling flag
7880
}
7981
}
8082

@@ -151,9 +153,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
151153
final isLoading = next.isGroupLoading(widget.groupId);
152154
final isLoadingCompleted = wasLoading && !isLoading;
153155

154-
// Auto-scroll when chat first loads
156+
// Auto-scroll when chat first loads (loading transition detected)
155157
if (isLoadingCompleted && currentMessages.isNotEmpty && !_hasInitialScrollCompleted) {
156158
_hasInitialScrollCompleted = true;
159+
_hasScheduledInitialScroll = true;
157160
_scrollToBottom(animated: false);
158161
// Save last read only if user is effectively at bottom
159162
if (_isAtBottom()) {
@@ -162,6 +165,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
162165
return;
163166
}
164167

168+
// Handle case where messages are already loaded when widget mounts
169+
if (!_hasInitialScrollCompleted && currentMessages.isNotEmpty && !isLoading) {
170+
_hasInitialScrollCompleted = true;
171+
_hasScheduledInitialScroll = true;
172+
_scrollToBottom(animated: false);
173+
if (_isAtBottom()) {
174+
_saveLastReadForCurrentMessages();
175+
}
176+
return;
177+
}
178+
165179
// Auto-scroll when new messages arrive (after initial load)
166180
if (_hasInitialScrollCompleted &&
167181
previousMessages.isNotEmpty &&
@@ -264,6 +278,26 @@ class _ChatScreenState extends ConsumerState<ChatScreen> with WidgetsBindingObse
264278
chatProvider.select((state) => state.groupMessages[widget.groupId] ?? []),
265279
);
266280

281+
final isLoading = ref.watch(
282+
chatProvider.select((state) => state.isGroupLoading(widget.groupId)),
283+
);
284+
if (!_hasInitialScrollCompleted &&
285+
!_hasScheduledInitialScroll &&
286+
messages.isNotEmpty &&
287+
!isLoading) {
288+
_hasScheduledInitialScroll = true;
289+
WidgetsBinding.instance.addPostFrameCallback((_) {
290+
if (mounted && !_hasInitialScrollCompleted && _scrollController.hasClients) {
291+
_hasInitialScrollCompleted = true;
292+
_hasScheduledInitialScroll = false;
293+
_scrollToBottom(animated: false);
294+
} else {
295+
// Reset flag if callback can't execute
296+
_hasScheduledInitialScroll = false;
297+
}
298+
});
299+
}
300+
267301
// Move ref.listen calls to the main build method
268302
ref.listen(chatSearchProvider(widget.groupId), (previous, next) {
269303
if (next.query.isNotEmpty && next.query != previous?.query) {

lib/ui/chat/widgets/chat_input.dart

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
44
import 'package:flutter_animate/flutter_animate.dart';
55
import 'package:flutter_riverpod/flutter_riverpod.dart';
66
import 'package:flutter_screenutil/flutter_screenutil.dart';
7+
import 'package:whitenoise/config/providers/active_pubkey_provider.dart';
78
import 'package:whitenoise/config/providers/chat_input_provider.dart';
89
import 'package:whitenoise/config/providers/chat_provider.dart';
910
import 'package:whitenoise/ui/chat/widgets/chat_input_media_selector.dart';
@@ -40,10 +41,12 @@ class _ChatInputState extends ConsumerState<ChatInput> with WidgetsBindingObserv
4041
void initState() {
4142
super.initState();
4243
WidgetsBinding.instance.addObserver(this);
43-
_loadDraftMessage();
4444
_focusNode.addListener(_handleFocusChange);
4545
_textController.addListener(_onTextChanged);
4646
WidgetsBinding.instance.addPostFrameCallback((_) {
47+
if (mounted) {
48+
_loadDraftMessage();
49+
}
4750
_measureSingleLineHeight();
4851
});
4952
}
@@ -57,8 +60,11 @@ class _ChatInputState extends ConsumerState<ChatInput> with WidgetsBindingObserv
5760

5861
if (editingMessage != null &&
5962
editingMessage.content != chatInputState.previousEditingMessageContent) {
60-
chatInputNotifier.setPreviousEditingMessageContent(editingMessage.content);
61-
_textController.text = editingMessage.content ?? '';
63+
WidgetsBinding.instance.addPostFrameCallback((_) {
64+
if (!mounted) return;
65+
chatInputNotifier.setPreviousEditingMessageContent(editingMessage.content);
66+
_textController.text = editingMessage.content ?? '';
67+
});
6268
}
6369
}
6470

@@ -190,6 +196,30 @@ class _ChatInputState extends ConsumerState<ChatInput> with WidgetsBindingObserv
190196
final chatNotifier = ref.watch(chatProvider.notifier);
191197
final chatInputState = ref.watch(chatInputProvider(widget.groupId));
192198

199+
ref.listen<String?>(activePubkeyProvider, (previous, next) {
200+
if (previous != null && next != null && previous != next) {
201+
WidgetsBinding.instance.addPostFrameCallback((_) async {
202+
if (!mounted) return;
203+
final chatState = ref.read(chatProvider);
204+
final isEditing = chatState.editingMessage[widget.groupId] != null;
205+
if (!isEditing) {
206+
final chatInputNotifier = ref.read(chatInputProvider(widget.groupId).notifier);
207+
final currentText = _textController.text;
208+
209+
await chatInputNotifier.handleAccountSwitch(
210+
oldPubkey: previous,
211+
currentText: currentText,
212+
);
213+
214+
if (!mounted) return;
215+
216+
_textController.clear();
217+
await _loadDraftMessage();
218+
}
219+
});
220+
}
221+
});
222+
193223
return SafeArea(
194224
top: false,
195225
child: Column(

0 commit comments

Comments
 (0)