feat: Add relay ping and lock message destination

This commit is contained in:
Janez T
2026-03-31 17:52:44 +02:00
parent 7df750c8d5
commit 3392e5f9c1
9 changed files with 955 additions and 123 deletions

View File

@@ -148,11 +148,13 @@ class _MessagesTabState extends State<MessagesTab> {
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
ContactsProvider? _contactsProvider;
// Message destination state
String _destinationType =
MessageDestinationPreferences.destinationTypeChannel;
Contact? _selectedRecipient;
bool _isDestinationLocked = false;
// Region scope state
String? _channelRegionScopeName;
@@ -183,13 +185,9 @@ class _MessagesTabState extends State<MessagesTab> {
super.initState();
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
_loadAllChannelRegionScopes();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest();
});
_scheduleDestinationSync();
}
Future<void> _loadVoiceSettings() async {
@@ -203,11 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Reload saved destination and check for navigation request whenever dependencies change
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadSavedDestination();
_checkForNavigationRequest();
});
final contactsProvider = context.read<ContactsProvider>();
if (!identical(_contactsProvider, contactsProvider)) {
_contactsProvider?.removeListener(_handleContactsChanged);
_contactsProvider = contactsProvider;
_contactsProvider?.addListener(_handleContactsChanged);
}
_scheduleDestinationSync();
}
@override
@@ -216,6 +216,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_contactsProvider?.removeListener(_handleContactsChanged);
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
@@ -228,13 +229,37 @@ class _MessagesTabState extends State<MessagesTab> {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
if (widget.isActive) {
_scheduleDestinationSync();
}
}
}
void _checkForNavigationRequest() {
void _scheduleDestinationSync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
_synchronizeDestinationState();
});
}
void _handleContactsChanged() {
if (!mounted) return;
_scheduleDestinationSync();
}
Future<void> _synchronizeDestinationState() async {
if (!mounted) return;
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
final targetRecipientPublicKeyHex =
messagesProvider.targetRecipientPublicKeyHex;
await _restoreDestinationState(
overrideType: targetDestinationType,
overrideRecipientPublicKeyHex: targetRecipientPublicKeyHex,
);
if (!mounted) return;
if (targetMessageId != null) {
_scrollToMessage(targetMessageId);
@@ -242,11 +267,8 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
_focusNode.requestFocus();
}
}
@@ -447,56 +469,91 @@ class _MessagesTabState extends State<MessagesTab> {
_updateCharacterCount();
}
/// Load saved message destination from preferences
Future<void> _loadSavedDestination() async {
Future<void> _restoreDestinationState({
String? overrideType,
String? overrideRecipientPublicKeyHex,
}) async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
final savedDestination =
await MessageDestinationPreferences.getDestination();
final effectiveType =
overrideType ??
lockedDestination?['type'] ??
savedDestination?['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
final effectivePublicKeyHex =
overrideRecipientPublicKeyHex ??
lockedDestination?['publicKey'] ??
savedDestination?['publicKey'];
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
final recipient = _resolveDestinationRecipient(
contactsProvider,
effectiveType,
effectivePublicKeyHex,
);
final allowsEmptyRecipient =
effectiveType == MessageDestinationPreferences.destinationTypeAll ||
(effectiveType == MessageDestinationPreferences.destinationTypeChannel &&
effectivePublicKeyHex == null);
final shouldFallbackToPublicChannel =
recipient == null && !allowsEmptyRecipient;
final destinationType = shouldFallbackToPublicChannel
? MessageDestinationPreferences.destinationTypeChannel
: effectiveType;
final selectedRecipient = shouldFallbackToPublicChannel ? null : recipient;
final shouldClearSavedDestination =
lockedDestination == null &&
overrideType == null &&
shouldFallbackToPublicChannel &&
savedDestination != null;
if (savedDestination == null || !mounted) {
// Default to public channel
return;
}
final type = savedDestination['type']!;
final publicKey = savedDestination['publicKey'];
if (!mounted) return;
setState(() {
_destinationType = type;
_isDestinationLocked = lockedDestination != null;
_destinationType = destinationType;
_selectedRecipient = selectedRecipient;
});
// If it's a contact or room, try to find it in the contacts list
if (publicKey != null && mounted) {
final contactsProvider = context.read<ContactsProvider>();
final contact = contactsProvider.contacts.where((c) {
return c.publicKeyHex == publicKey;
}).firstOrNull;
if (contact != null) {
setState(() {
_selectedRecipient = contact;
});
} else {
// Contact/room not found, fallback to public channel
debugPrint(
'⚠️ [MessagesTab] Saved recipient not found, falling back to public channel',
);
setState(() {
_destinationType =
MessageDestinationPreferences.destinationTypeChannel;
_selectedRecipient = null;
});
await MessageDestinationPreferences.clearDestination();
}
if (shouldClearSavedDestination) {
await MessageDestinationPreferences.clearDestination();
}
_enforceMessageByteLimit();
// Load region scope for channel destinations
await _loadRegionScope();
}
Contact? _resolveDestinationRecipient(
ContactsProvider contactsProvider,
String type,
String? publicKeyHex,
) {
if (publicKeyHex == null) {
return null;
}
final candidates = switch (type) {
MessageDestinationPreferences.destinationTypeChannel =>
contactsProvider.channels,
MessageDestinationPreferences.destinationTypeRoom => contactsProvider.rooms,
MessageDestinationPreferences.destinationTypeContact =>
contactsProvider.chatContacts,
_ => contactsProvider.contacts,
};
return candidates.where((contact) {
return contact.publicKeyHex == publicKeyHex;
}).firstOrNull;
}
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
if (_isDestinationLocked) {
return;
}
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
@@ -552,7 +609,11 @@ class _MessagesTabState extends State<MessagesTab> {
}
/// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
Future<void> _onRecipientSelected(
String type,
Contact? recipient, {
bool persistSelection = true,
}) async {
setState(() {
_destinationType = type;
_selectedRecipient = recipient;
@@ -565,11 +626,12 @@ class _MessagesTabState extends State<MessagesTab> {
// Load region scope for channel destinations
await _loadRegionScope();
// Save to preferences
await MessageDestinationPreferences.setDestination(
type,
recipientPublicKey: recipient?.publicKeyHex,
);
if (persistSelection) {
await MessageDestinationPreferences.setDestination(
type,
recipientPublicKey: recipient?.publicKeyHex,
);
}
// Show confirmation toast
if (!mounted) return;
@@ -620,23 +682,6 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
@@ -746,7 +791,11 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
await _onRecipientSelected(destinationType, recipient);
await _onRecipientSelected(
destinationType,
recipient,
persistSelection: !_isDestinationLocked,
);
if (!mounted) return;
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
@@ -2442,6 +2491,7 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
destinationLocked: _isDestinationLocked,
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,

View File

@@ -25,6 +25,7 @@ import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/message_destination_preferences.dart';
import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
@@ -60,6 +61,8 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
static const String _publicChannelPublicKeyHex =
'0000000000000000000000000000000000000000000000000000000000000000';
late AppThemeMode _selectedTheme;
late Locale? _selectedLocale;
PackageInfo? _packageInfo;
@@ -91,6 +94,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
bool _profilesEnabled = false;
bool _messageDestinationLockEnabled = false;
String _messageDestinationLockType =
MessageDestinationPreferences.destinationTypeChannel;
String? _messageDestinationLockPublicKey = _publicChannelPublicKeyHex;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
@@ -114,6 +121,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
_loadMessageDestinationLock();
}
@override
@@ -184,6 +192,94 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _loadMessageDestinationLock() async {
final lockedDestination =
await MessageDestinationPreferences.getLockedDestination();
if (!mounted) return;
setState(() {
_messageDestinationLockEnabled = lockedDestination != null;
_messageDestinationLockType =
lockedDestination?['publicKey'] == null
? MessageDestinationPreferences.destinationTypeChannel
: lockedDestination?['type'] ??
MessageDestinationPreferences.destinationTypeChannel;
_messageDestinationLockPublicKey =
lockedDestination?['publicKey'] ?? _publicChannelPublicKeyHex;
});
}
List<Contact> _messageDestinationLockOptions(
ContactsProvider contactsProvider,
) {
final channels = List<Contact>.from(contactsProvider.channels)
..sort((a, b) {
if (a.isPublicChannel != b.isPublicChannel) {
return a.isPublicChannel ? -1 : 1;
}
return a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
);
});
final rooms = List<Contact>.from(contactsProvider.rooms)
..sort(
(a, b) => a.displayName.toLowerCase().compareTo(
b.displayName.toLowerCase(),
),
);
return [...channels, ...rooms];
}
String _messageDestinationLockLabel(BuildContext context, Contact contact) {
final name = contact.isChannel
? contact.getLocalizedDisplayName(context)
: contact.displayName;
return contact.isRoom ? 'Room: $name' : 'Channel: $name';
}
String _messageDestinationLockTypeForContact(Contact contact) {
return contact.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeChannel;
}
String? _selectedMessageDestinationLockValue(List<Contact> destinations) {
final currentValue = _messageDestinationLockPublicKey;
if (currentValue != null &&
destinations.any((contact) => contact.publicKeyHex == currentValue)) {
return currentValue;
}
return destinations.isEmpty ? null : destinations.first.publicKeyHex;
}
Future<void> _setMessageDestinationLock({
required bool enabled,
String? type,
String? recipientPublicKey,
}) async {
final nextType = type ?? _messageDestinationLockType;
final nextRecipientPublicKey =
recipientPublicKey ??
_messageDestinationLockPublicKey ??
_publicChannelPublicKeyHex;
await MessageDestinationPreferences.setLockedDestination(
enabled: enabled,
type: nextType,
recipientPublicKey: enabled ? nextRecipientPublicKey : null,
);
if (!mounted) return;
setState(() {
_messageDestinationLockEnabled = enabled;
_messageDestinationLockType = nextType;
_messageDestinationLockPublicKey = nextRecipientPublicKey;
});
}
Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
@@ -1430,6 +1526,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
SwitchListTile(
secondary: const Icon(Icons.lock_outline),
title: const Text('Lock messages to one channel or room'),
subtitle: const Text(
'Keep the Messages tab and composer fixed on one destination. Direct messages from Contacts still open as usual.',
),
value: _messageDestinationLockEnabled,
onChanged: (value) async {
final contactsProvider = context.read<ContactsProvider>();
final options = _messageDestinationLockOptions(
contactsProvider,
);
final selectedPublicKey =
_selectedMessageDestinationLockValue(options) ??
_publicChannelPublicKeyHex;
final selectedContact = options.where((contact) {
return contact.publicKeyHex == selectedPublicKey;
}).firstOrNull;
await _setMessageDestinationLock(
enabled: value,
type: selectedContact == null
? MessageDestinationPreferences.destinationTypeChannel
: _messageDestinationLockTypeForContact(selectedContact),
recipientPublicKey: selectedPublicKey,
);
},
),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final options = _messageDestinationLockOptions(
contactsProvider,
);
final selectedValue =
_selectedMessageDestinationLockValue(options);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: DropdownButtonFormField<String>(
key: ValueKey(selectedValue),
initialValue: selectedValue,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Locked channel or room',
prefixIcon: Icon(Icons.forum_outlined),
border: OutlineInputBorder(),
),
items: [
for (final contact in options)
DropdownMenuItem<String>(
value: contact.publicKeyHex,
child: Text(
_messageDestinationLockLabel(context, contact),
overflow: TextOverflow.ellipsis,
),
),
],
onChanged:
_messageDestinationLockEnabled && options.isNotEmpty
? (value) async {
if (value == null) {
return;
}
final selectedContact = options.where((contact) {
return contact.publicKeyHex == value;
}).firstOrNull;
if (selectedContact == null) {
return;
}
await _setMessageDestinationLock(
enabled: true,
type: _messageDestinationLockTypeForContact(
selectedContact,
),
recipientPublicKey: value,
);
}
: null,
),
);
},
),
ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(