Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Global No relay indicator

### Changed

Expand Down
Binary file modified assets/pngs/ic_add_chat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/pngs/ic_off_chat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions lib/config/providers/relay_status_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:whitenoise/config/providers/auth_provider.dart';
import 'package:whitenoise/models/relay_status.dart';
import 'package:whitenoise/src/rust/api/relays.dart';
import 'package:whitenoise/src/rust/api/utils.dart';
import 'package:whitenoise/src/rust/lib.dart';

// State for relay status management
class RelayStatusState {
Expand Down Expand Up @@ -118,9 +119,61 @@ class RelayStatusNotifier extends Notifier<RelayStatusState> {
final status = getRelayStatus(url);
return status.isConnected;
}

Future<bool> areAllRelayTypesConnected() async {
try {
final authState = ref.read(authProvider);
if (!authState.isAuthenticated) {
return false;
}

// Use cached account data instead of calling loadAccount() every time
final activeAccountData =
await ref.read(activeAccountProvider.notifier).getActiveAccountData();
if (activeAccountData == null) {
return false;
}

// Check each relay type separately using the cached account data
final hasConnectedNostr = await _hasConnectedRelayOfType(activeAccountData.nip65Relays);
final hasConnectedInbox = await _hasConnectedRelayOfType(activeAccountData.inboxRelays);
final hasConnectedKeyPackage = await _hasConnectedRelayOfType(
activeAccountData.keyPackageRelays,
);

return hasConnectedNostr && hasConnectedInbox && hasConnectedKeyPackage;
} catch (e) {
_logger.warning('Error checking relay type connections: $e');
return false;
}
}

Future<bool> _hasConnectedRelayOfType(List<RelayUrl> relayUrls) async {
if (relayUrls.isEmpty) {
return false;
}

for (final relayUrl in relayUrls) {
final url = await stringFromRelayUrl(relayUrl: relayUrl);
if (isRelayConnected(url)) {
return true;
}
}

return false;
}
}

// Provider
final relayStatusProvider = NotifierProvider<RelayStatusNotifier, RelayStatusState>(
RelayStatusNotifier.new,
);

// Provider for checking if all relay types have at least one connected relay
final allRelayTypesConnectionProvider = FutureProvider<bool>((ref) async {
// Watch the relay status provider to trigger rebuilds when statuses change
ref.watch(relayStatusProvider);

final notifier = ref.read(relayStatusProvider.notifier);
return await notifier.areAllRelayTypesConnected();
});
69 changes: 59 additions & 10 deletions lib/ui/contact_list/chat_list_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:whitenoise/config/providers/group_provider.dart';
import 'package:whitenoise/config/providers/polling_provider.dart';
import 'package:whitenoise/config/providers/profile_provider.dart';
import 'package:whitenoise/config/providers/profile_ready_card_visibility_provider.dart';
import 'package:whitenoise/config/providers/relay_status_provider.dart';
import 'package:whitenoise/config/providers/welcomes_provider.dart';
import 'package:whitenoise/domain/models/chat_list_item.dart';
import 'package:whitenoise/routing/routes.dart';
Expand All @@ -24,6 +25,7 @@ import 'package:whitenoise/ui/core/themes/assets.dart';
import 'package:whitenoise/ui/core/themes/src/extensions.dart';
import 'package:whitenoise/ui/core/ui/wn_app_bar.dart';
import 'package:whitenoise/ui/core/ui/wn_bottom_fade.dart';
import 'package:whitenoise/ui/core/ui/wn_heads_up.dart';
import 'package:whitenoise/ui/core/ui/wn_text_form_field.dart';

class ChatListScreen extends ConsumerStatefulWidget {
Expand Down Expand Up @@ -85,7 +87,7 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
}

Future<void> _loadData() async {
// Load initial data for groups, welcomes, and profile
// Load initial data for groups, welcomes, profile, and relay status
if (_isLoadingData) return;
setState(() {
_isSearchVisible = false;
Expand All @@ -95,6 +97,7 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
ref.read(welcomesProvider.notifier).loadWelcomes(),
ref.read(groupsProvider.notifier).loadGroups(),
ref.read(profileProvider.notifier).fetchProfileData(),
ref.read(relayStatusProvider.notifier).refreshStatuses(),
]);

setState(() {
Expand Down Expand Up @@ -219,6 +222,13 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
(item.lastMessage?.content?.toLowerCase().contains(searchLower) ?? false);
}).toList();

final allRelayTypesConnectionAsync = ref.watch(allRelayTypesConnectionProvider);
final notAllRelayTypesConnected = allRelayTypesConnectionAsync.when(
data: (allConnected) => !allConnected,
loading: () => true,
error: (_, _) => true,
);

return GestureDetector(
onTap: () {
if (_searchFocusNode.hasFocus) {
Expand Down Expand Up @@ -251,22 +261,36 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
),
actions: [
IconButton(
onPressed: () {
if (_searchFocusNode.hasFocus) {
_searchFocusNode.unfocus();
}
NewChatBottomSheet.show(context);
},
onPressed:
notAllRelayTypesConnected
? null
: () {
if (_searchFocusNode.hasFocus) {
_searchFocusNode.unfocus();
}
NewChatBottomSheet.show(context);
},
icon: Image.asset(
AssetsPaths.icAddNewChat,
width: 32.w,
height: 32.w,
notAllRelayTypesConnected
? AssetsPaths.icOffChat
: AssetsPaths.icAddNewChat,
width: 21.w,
height: 21.w,
color: context.colors.solidNeutralWhite.withValues(
alpha: notAllRelayTypesConnected ? 0.5 : 1.0,
),
),
),
Gap(8.w),
],
pinned: true,
),

if (notAllRelayTypesConnected)
SliverToBoxAdapter(
child: SizedBox(height: 100.h),
),

if (chatItems.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
Expand Down Expand Up @@ -355,6 +379,7 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
).animate().fade(),
),
),

SliverPadding(
padding: EdgeInsets.only(top: 8.h, bottom: 32.h),
sliver: SliverList.separated(
Expand All @@ -377,6 +402,30 @@ class _ChatListScreenState extends ConsumerState<ChatListScreen> with TickerProv
],
),

if (notAllRelayTypesConnected)
Positioned(
top: 64.h + kToolbarHeight,
left: 0,
right: 0,
child:
WnStickyHeadsUp(
title: 'No Relays Connected',
subtitle: 'The app won\'t work until you add at least one.',
action: InkWell(
child: Text(
'Connect Relays',
style: TextStyle(
fontSize: 14.sp,
color: context.colors.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
onTap: () => context.push(Routes.settingsNetwork),
),
).animate().fadeIn(),
),

if (chatItems.isNotEmpty)
Positioned(bottom: 0, left: 0, right: 0, height: 54.h, child: const WnBottomFade()),
],
Expand Down
1 change: 1 addition & 0 deletions lib/ui/core/themes/assets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class AssetsPaths {
static const String greenBird = '$_pngsDir/green_bird.png';
static const String hands = '$_pngsDir/hands.png';
static const String icAddNewChat = '$_pngsDir/ic_add_chat.png';
static const String icOffChat = '$_pngsDir/ic_off_chat.png';
static const String icNotificationMuted = '$_pngsDir/ic_notification_muted.png';
static const String icCheckmarkSolid = '$_pngsDir/ic_checkmark_solid.png';
static const String icCheckmarkDashed = '$_pngsDir/ic_checkmark_dashed.png';
Expand Down
103 changes: 103 additions & 0 deletions lib/ui/core/ui/wn_heads_up.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:supa_carbon_icons/supa_carbon_icons.dart';
import 'package:whitenoise/ui/core/themes/src/app_theme.dart';

class WnStickyHeadsUp extends StatelessWidget {
const WnStickyHeadsUp({
super.key,
required this.title,
required this.subtitle,
this.type = WnHeadingType.error,
this.icon,
this.action,
});
final String title;
final String subtitle;
final IconData? icon;
final Widget? action;
final WnHeadingType type;

@override
Widget build(BuildContext context) {
final color = type.color(context);
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: context.colors.surface,
border: Border(
bottom: BorderSide(
color: color,
width: 1.w,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? type.icon,
size: 24.w,
color: color,
),
Gap(8.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: context.colors.primary,
),
),
Gap(4.h),
Text(
subtitle,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: context.colors.mutedForeground,
),
),
Gap(4.h),
if (action != null) action!,
],
),
),
],
),
);
}
}

enum WnHeadingType {
error,
warning,
info;

Color color(BuildContext context) {
switch (this) {
case WnHeadingType.error:
return context.colors.destructive;
case WnHeadingType.warning:
return context.colors.warning;
case WnHeadingType.info:
return context.colors.info;
}
}

IconData get icon {
switch (this) {
case WnHeadingType.error:
return CarbonIcons.error_filled;
case WnHeadingType.warning:
return CarbonIcons.warning_filled;
case WnHeadingType.info:
return CarbonIcons.information;
}
}
}