diff --git a/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.down.sql b/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.down.sql new file mode 100644 index 0000000..591e241 --- /dev/null +++ b/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.down.sql @@ -0,0 +1,23 @@ +-- Rollback migration: update sojorn.net back to gosojorn.com + +-- Update profiles +UPDATE profiles +SET avatar_url = REPLACE(avatar_url, 'sojorn.net', 'gosojorn.com') +WHERE avatar_url LIKE '%sojorn.net%' AND avatar_url LIKE '%img.%'; + +UPDATE profiles +SET cover_url = REPLACE(cover_url, 'sojorn.net', 'gosojorn.com') +WHERE cover_url LIKE '%sojorn.net%' AND cover_url LIKE '%img.%'; + +-- Update posts +UPDATE posts +SET image_url = REPLACE(image_url, 'sojorn.net', 'gosojorn.com') +WHERE image_url LIKE '%sojorn.net%' AND image_url LIKE '%img.%'; + +UPDATE posts +SET video_url = REPLACE(video_url, 'sojorn.net', 'gosojorn.com') +WHERE video_url LIKE '%sojorn.net%' AND video_url LIKE '%quips.%'; + +UPDATE posts +SET thumbnail_url = REPLACE(thumbnail_url, 'sojorn.net', 'gosojorn.com') +WHERE thumbnail_url LIKE '%sojorn.net%' AND thumbnail_url LIKE '%img.%'; diff --git a/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.up.sql b/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.up.sql new file mode 100644 index 0000000..e3ffe5e --- /dev/null +++ b/go-backend/internal/database/migrations/20260204000003_migrate_to_sojorn_net.up.sql @@ -0,0 +1,23 @@ +-- Migration to update all legacy gosojorn.com URLs to sojorn.net + +-- Update profiles +UPDATE profiles +SET avatar_url = REPLACE(avatar_url, 'gosojorn.com', 'sojorn.net') +WHERE avatar_url LIKE '%gosojorn.com%'; + +UPDATE profiles +SET cover_url = REPLACE(cover_url, 'gosojorn.com', 'sojorn.net') +WHERE cover_url LIKE '%gosojorn.com%'; + +-- Update posts +UPDATE posts +SET image_url = REPLACE(image_url, 'gosojorn.com', 'sojorn.net') +WHERE image_url LIKE '%gosojorn.com%'; + +UPDATE posts +SET video_url = REPLACE(video_url, 'gosojorn.com', 'sojorn.net') +WHERE video_url LIKE '%gosojorn.com%'; + +UPDATE posts +SET thumbnail_url = REPLACE(thumbnail_url, 'gosojorn.com', 'sojorn.net') +WHERE thumbnail_url LIKE '%gosojorn.com%'; diff --git a/go-backend/internal/handlers/notification_handler.go b/go-backend/internal/handlers/notification_handler.go index 02c7eac..49be996 100644 --- a/go-backend/internal/handlers/notification_handler.go +++ b/go-backend/internal/handlers/notification_handler.go @@ -36,14 +36,15 @@ func (h *NotificationHandler) GetNotifications(c *gin.Context) { limit := utils.GetQueryInt(c, "limit", 20) offset := utils.GetQueryInt(c, "offset", 0) grouped := c.Query("grouped") == "true" + includeArchived := c.Query("include_archived") == "true" var notifications []models.Notification var err error if grouped { - notifications, err = h.notifRepo.GetGroupedNotifications(c.Request.Context(), userIDStr.(string), limit, offset) + notifications, err = h.notifRepo.GetGroupedNotifications(c.Request.Context(), userIDStr.(string), limit, offset, includeArchived) } else { - notifications, err = h.notifRepo.GetNotifications(c.Request.Context(), userIDStr.(string), limit, offset) + notifications, err = h.notifRepo.GetNotifications(c.Request.Context(), userIDStr.(string), limit, offset, includeArchived) } if err != nil { diff --git a/go-backend/internal/repository/notification_repository.go b/go-backend/internal/repository/notification_repository.go index 8e6174d..561f714 100644 --- a/go-backend/internal/repository/notification_repository.go +++ b/go-backend/internal/repository/notification_repository.go @@ -104,7 +104,12 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notif * return err } -func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) { +func (r *NotificationRepository) GetNotifications(ctx context.Context, userID string, limit, offset int, includeArchived bool) ([]models.Notification, error) { + whereClause := "WHERE n.user_id = $1::uuid AND n.archived_at IS NULL" + if includeArchived { + whereClause = "WHERE n.user_id = $1::uuid" + } + query := ` SELECT n.id, n.user_id, n.type, n.actor_id, n.post_id, n.comment_id, n.is_read, n.created_at, n.metadata, @@ -116,7 +121,7 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st FROM public.notifications n JOIN public.profiles pr ON n.actor_id = pr.id LEFT JOIN public.posts po ON n.post_id = po.id - WHERE n.user_id = $1::uuid AND n.archived_at IS NULL + ` + whereClause + ` ORDER BY n.created_at DESC LIMIT $2 OFFSET $3 ` @@ -152,7 +157,12 @@ func (r *NotificationRepository) GetNotifications(ctx context.Context, userID st } // GetGroupedNotifications returns notifications with grouping (e.g., "5 people liked your post") -func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, userID string, limit, offset int) ([]models.Notification, error) { +func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, userID string, limit, offset int, includeArchived bool) ([]models.Notification, error) { + whereClause := "WHERE n.user_id = $1::uuid AND n.archived_at IS NULL" + if includeArchived { + whereClause = "WHERE n.user_id = $1::uuid" + } + query := ` WITH ranked AS ( SELECT @@ -167,7 +177,7 @@ func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, us FROM public.notifications n JOIN public.profiles pr ON n.actor_id = pr.id LEFT JOIN public.posts po ON n.post_id = po.id - WHERE n.user_id = $1::uuid AND n.archived_at IS NULL + ` + whereClause + ` ) SELECT id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata, diff --git a/sojorn_app/lib/config/api_config.dart b/sojorn_app/lib/config/api_config.dart index 33031c2..c035b21 100644 --- a/sojorn_app/lib/config/api_config.dart +++ b/sojorn_app/lib/config/api_config.dart @@ -3,14 +3,18 @@ class ApiConfig { static final String baseUrl = _computeBaseUrl(); static String _computeBaseUrl() { - final raw = const String.fromEnvironment( + String raw = const String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://api.sojorn.net/api/v1', ); + // Failsafe: migrate legacy domain if it slips in via environment or cache + if (raw.contains('gosojorn.com')) { + raw = raw.replaceAll('gosojorn.com', 'sojorn.net'); + } + // Auto-upgrade any lingering http://api.sojorn.net:8080 (or plain http) - // to the public https endpoint behind nginx. This protects old .env files - // or cached web builds that still point at the closed port 8080. + // to the public https endpoint behind nginx. if (raw.startsWith('http://api.sojorn.net:8080')) { return raw.replaceFirst( 'http://api.sojorn.net:8080', @@ -18,11 +22,6 @@ class ApiConfig { ); } - // Belt-and-suspenders: Force migration even if args/env are stale - if (raw.contains('gosojorn.com')) { - return raw.replaceAll('gosojorn.com', 'sojorn.net'); - } - if (raw.startsWith('http://')) { return 'https://${raw.substring('http://'.length)}'; } diff --git a/sojorn_app/lib/providers/notification_provider.dart b/sojorn_app/lib/providers/notification_provider.dart new file mode 100644 index 0000000..aee4b45 --- /dev/null +++ b/sojorn_app/lib/providers/notification_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/notification_service.dart'; + +final notificationServiceProvider = Provider((ref) { + return NotificationService.instance; +}); + +final badgeStreamProvider = StreamProvider((ref) { + final service = ref.watch(notificationServiceProvider); + return service.badgeStream; +}); + +final currentBadgeProvider = Provider((ref) { + final badgeAsync = ref.watch(badgeStreamProvider); + return badgeAsync.when( + data: (badge) => badge, + loading: () => NotificationService.instance.currentBadge, + error: (_, __) => NotificationService.instance.currentBadge, + ); +}); diff --git a/sojorn_app/lib/screens/home/home_shell.dart b/sojorn_app/lib/screens/home/home_shell.dart index f37b3e8..a6191d7 100644 --- a/sojorn_app/lib/screens/home/home_shell.dart +++ b/sojorn_app/lib/screens/home/home_shell.dart @@ -15,20 +15,21 @@ import '../quips/create/quip_creation_flow.dart'; import '../secure_chat/secure_chat_full_screen.dart'; import '../../widgets/radial_menu_overlay.dart'; import '../../providers/quip_upload_provider.dart'; +import '../../providers/notification_provider.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Root shell for the main tabs. The active tab is controlled by GoRouter's /// [StatefulNavigationShell] so navigation state and tab selection stay in sync. -class HomeShell extends StatefulWidget { +class HomeShell extends ConsumerStatefulWidget { final StatefulNavigationShell navigationShell; const HomeShell({super.key, required this.navigationShell}); @override - State createState() => _HomeShellState(); + ConsumerState createState() => _HomeShellState(); } -class _HomeShellState extends State with WidgetsBindingObserver { +class _HomeShellState extends ConsumerState with WidgetsBindingObserver { bool _isRadialMenuVisible = false; final SecureChatService _chatService = SecureChatService(); StreamSubscription? _notifSub; @@ -264,7 +265,17 @@ class _HomeShellState extends State with WidgetsBindingObserver { }, ), IconButton( - icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + icon: Consumer( + builder: (context, ref, child) { + final badge = ref.watch(currentBadgeProvider); + return Badge( + label: Text(badge.messageCount.toString()), + isLabelVisible: badge.messageCount > 0, + backgroundColor: AppTheme.brightNavy, + child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + ); + }, + ), tooltip: 'Messages', onPressed: () { Navigator.of(context, rootNavigator: true).push( @@ -276,7 +287,17 @@ class _HomeShellState extends State with WidgetsBindingObserver { }, ), IconButton( - icon: Icon(Icons.notifications_none, color: AppTheme.navyBlue), + icon: Consumer( + builder: (context, ref, child) { + final badge = ref.watch(currentBadgeProvider); + return Badge( + label: Text(badge.notificationCount.toString()), + isLabelVisible: badge.notificationCount > 0, + backgroundColor: Colors.redAccent, + child: Icon(Icons.notifications_none, color: AppTheme.navyBlue), + ); + }, + ), tooltip: 'Notifications', onPressed: () { showGeneralDialog( diff --git a/sojorn_app/lib/screens/notifications/notifications_screen.dart b/sojorn_app/lib/screens/notifications/notifications_screen.dart index b3f7986..a980e31 100644 --- a/sojorn_app/lib/screens/notifications/notifications_screen.dart +++ b/sojorn_app/lib/screens/notifications/notifications_screen.dart @@ -11,6 +11,7 @@ import '../../widgets/media/signed_media_image.dart'; import '../profile/viewable_profile_screen.dart'; import '../post/post_detail_screen.dart'; import 'package:go_router/go_router.dart'; +import '../../services/notification_service.dart'; /// Notifications screen showing user activity class NotificationsScreen extends ConsumerStatefulWidget { @@ -25,9 +26,10 @@ class _NotificationsScreenState extends ConsumerState { bool _isLoading = false; bool _hasMore = true; String? _error; - bool _showArchived = false; + int _activeTabIndex = 0; // 0 for Active, 1 for Archived final Set _locallyArchivedIds = {}; StreamSubscription? _authSub; + TabController? _tabController; @override void initState() { @@ -48,6 +50,7 @@ class _NotificationsScreenState extends ConsumerState { @override void dispose() { _authSub?.cancel(); + _tabController?.dispose(); super.dispose(); } @@ -63,7 +66,7 @@ class _NotificationsScreenState extends ConsumerState { if (notifications.isNotEmpty && mounted) { final newNotification = notifications.first; - if (!_showArchived && + if (_activeTabIndex != 1 && // If not in Archived tab (newNotification.archivedAt != null || _locallyArchivedIds.contains(newNotification.id))) { return; @@ -97,20 +100,28 @@ class _NotificationsScreenState extends ConsumerState { try { final apiService = ref.read(apiServiceProvider); + final showArchived = _activeTabIndex == 1; final notifications = await apiService.getNotifications( limit: 20, offset: refresh ? 0 : _notifications.length, - includeArchived: _showArchived, + includeArchived: showArchived, ); if (mounted) { - final filtered = _showArchived + // If showing Active, filter out anything archived + final filtered = !showArchived ? notifications - : notifications .where((item) => item.archivedAt == null && !_locallyArchivedIds.contains(item.id)) + .toList() + // If showing Archived, only show archived items + : notifications + .where((item) => + item.archivedAt != null || + _locallyArchivedIds.contains(item.id)) .toList(); + final fetchedCount = notifications.length; setState(() { if (refresh) { @@ -148,14 +159,12 @@ class _NotificationsScreenState extends ConsumerState { final index = _notifications.indexWhere((item) => item.id == notification.id); if (index != -1) { - if (_showArchived) { - _notifications[index] = notification.copyWith(isRead: true); - } else { - _locallyArchivedIds.add(notification.id); - _notifications.removeAt(index); - } + _notifications[index] = notification.copyWith(isRead: true); } }); + + // Clear the badge count on the bell icon immediately + NotificationService.instance.refreshBadge(); } } catch (e) { // Silently fail - not critical @@ -163,6 +172,26 @@ class _NotificationsScreenState extends ConsumerState { } Future _archiveAllNotifications() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Archive All'), + content: const Text('Are you sure you want to move all notifications to your archive?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text('Archive All', style: TextStyle(color: AppTheme.error)), + ), + ], + ), + ); + + if (confirmed != true) return; + try { final apiService = ref.read(apiServiceProvider); await apiService.archiveAllNotifications(); @@ -176,16 +205,19 @@ class _NotificationsScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('All notifications cleared'), + content: Text('Notifications moved to archive'), duration: Duration(seconds: 2), ), ); + + // Clear the badge count on the bell icon immediately + NotificationService.instance.refreshBadge(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to clear notifications: ${e.toString().replaceAll('Exception: ', '')}'), + content: Text('Failed to archive notifications: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); @@ -236,10 +268,9 @@ class _NotificationsScreenState extends ConsumerState { } void _handleNotificationTap(AppNotification notification) async { - final archived = await _archiveNotification(notification); - if (!archived) { - await _markAsRead(notification); - } + // Only mark as read, do NOT archive automatically on tap. + // This allows the user to see the notification again later if they don't archive it. + await _markAsRead(notification); // Navigate based on notification type switch (notification.type) { @@ -309,6 +340,9 @@ class _NotificationsScreenState extends ConsumerState { _locallyArchivedIds.add(notification.id); _notifications.removeWhere((n) => n.id == notification.id); }); + + // Update badge count + NotificationService.instance.refreshBadge(); } return true; } catch (e) { @@ -326,75 +360,124 @@ class _NotificationsScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final canClearNotifications = !_showArchived && _notifications.isNotEmpty; + final canArchiveAll = _activeTabIndex == 0 && _notifications.isNotEmpty; - return AppScaffold( - title: 'Notifications', - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - IconButton( - tooltip: _showArchived ? 'Hide archived' : 'Show archived', - icon: Icon( - _showArchived ? Icons.archive : Icons.archive_outlined, - ), - onPressed: () { - setState(() { - _showArchived = !_showArchived; - }); - _loadNotifications(refresh: true); - }, + return DefaultTabController( + length: 2, + child: AppScaffold( + title: 'Notifications', + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), ), - if (canClearNotifications) - IconButton( - tooltip: 'Clear notifications', - icon: Icon(Icons.clear_all, color: AppTheme.navyBlue), - onPressed: _archiveAllNotifications, - ), - ], - body: _error != null - ? _ErrorState( - message: _error!, - onRetry: () => _loadNotifications(refresh: true), - ) - : _notifications.isEmpty && !_isLoading - ? const _EmptyState() - : RefreshIndicator( - onRefresh: () => _loadNotifications(refresh: true), - child: ListView.builder( - itemCount: _notifications.length + (_hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index == _notifications.length) { - // Load more indicator - if (!_isLoading) { - _loadNotifications(); - } - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - final notification = _notifications[index]; - return _NotificationItem( - notification: notification, - onTap: notification.type == NotificationType.follow_request - ? null - : () => _handleNotificationTap(notification), - onApprove: notification.type == NotificationType.follow_request - ? () => _approveFollowRequest(notification) - : null, - onReject: notification.type == NotificationType.follow_request - ? () => _rejectFollowRequest(notification) - : null, - ); - }, - ), + actions: [ + if (canArchiveAll) + TextButton( + onPressed: _archiveAllNotifications, + child: Text( + 'Archive All', + style: AppTheme.textTheme.labelMedium?.copyWith( + color: AppTheme.egyptianBlue, + fontWeight: FontWeight.w600, ), + ), + ), + ], + bottom: TabBar( + onTap: (index) { + if (index != _activeTabIndex) { + setState(() { + _activeTabIndex = index; + }); + _loadNotifications(refresh: true); + } + }, + indicatorColor: AppTheme.egyptianBlue, + labelColor: AppTheme.egyptianBlue, + unselectedLabelColor: AppTheme.egyptianBlue.withOpacity(0.5), + tabs: const [ + Tab(text: 'Active'), + Tab(text: 'Archived'), + ], + ), + body: _error != null + ? _ErrorState( + message: _error!, + onRetry: () => _loadNotifications(refresh: true), + ) + : _notifications.isEmpty && !_isLoading + ? const _EmptyState() + : RefreshIndicator( + onRefresh: () => _loadNotifications(refresh: true), + child: ListView.builder( + itemCount: _notifications.length + (_hasMore ? 1 : 0), + padding: const EdgeInsets.symmetric(vertical: 8), + itemBuilder: (context, index) { + if (index == _notifications.length) { + // Load more indicator + if (!_isLoading) { + _loadNotifications(); + } + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + final notification = _notifications[index]; + if (_activeTabIndex == 0) { + // Swipe to archive in Active tab + return Dismissible( + key: Key('notif_${notification.id}'), + direction: DismissDirection.endToStart, + onDismissed: (_) => _archiveNotification(notification), + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + color: AppTheme.royalPurple.withOpacity(0.8), + child: const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + 'Archive', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(width: 8), + Icon(Icons.archive, color: Colors.white), + ], + ), + ), + child: _NotificationItem( + notification: notification, + onTap: notification.type == NotificationType.follow_request + ? null + : () => _handleNotificationTap(notification), + onApprove: notification.type == NotificationType.follow_request + ? () => _approveFollowRequest(notification) + : null, + onReject: notification.type == NotificationType.follow_request + ? () => _rejectFollowRequest(notification) + : null, + ), + ); + } else { + // No swipe to archive in Archived tab + return _NotificationItem( + notification: notification, + onTap: notification.type == NotificationType.follow_request + ? null + : () => _handleNotificationTap(notification), + ); + } + }, + ), + ), + ), ); } } diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index ff58532..0d4eb6b 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -15,8 +15,10 @@ import '../../widgets/media/signed_media_image.dart'; import '../compose/compose_screen.dart'; import '../discover/discover_screen.dart'; import '../secure_chat/secure_chat_full_screen.dart'; +import '../../services/notification_service.dart'; import '../../widgets/post/post_body.dart'; import '../../widgets/post/post_view_mode.dart'; +import '../../providers/notification_provider.dart'; import 'package:share_plus/share_plus.dart'; class ThreadedConversationScreen extends ConsumerStatefulWidget { @@ -53,6 +55,7 @@ class _ThreadedConversationScreenState extends ConsumerState Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (_) => SecureChatFullScreen(), + builder: (_) => const SecureChatFullScreen(), fullscreenDialog: true, ), ), - icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + icon: Consumer( + builder: (context, ref, child) { + final badge = ref.watch(currentBadgeProvider); + return Badge( + label: Text(badge.messageCount.toString()), + isLabelVisible: badge.messageCount > 0, + backgroundColor: AppTheme.brightNavy, + child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + ); + }, + ), ), const SizedBox(width: 8), ], diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart index d58be65..958742b 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart @@ -634,26 +634,44 @@ class _ConversationTileState extends State<_ConversationTile> { ), const SizedBox(width: 4), Expanded( - child: Text( - widget.conversation.lastMessageAt != null - ? 'Recent message' - : 'Start a conversation', - style: GoogleFonts.inter( - color: widget.conversation.lastMessageAt != null - ? AppTheme.textSecondary - : AppTheme.textDisabled, - fontSize: 14, + child: Text( + widget.conversation.lastMessageAt != null + ? 'Recent message' + : 'Start a conversation', + style: GoogleFonts.inter( + color: widget.conversation.lastMessageAt != null + ? AppTheme.textSecondary + : AppTheme.textDisabled, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - // Hover delete button for web/desktop + // Unread badge + if (widget.conversation.unreadCount != null && widget.conversation.unreadCount! > 0) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.brightNavy, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + widget.conversation.unreadCount.toString(), + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + // Hover delete button for web/desktop if (_isHovered) Padding( padding: const EdgeInsets.only(left: 8), diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart index b304dab..0d72de9 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_screen.dart @@ -7,6 +7,7 @@ import '../../services/api_service.dart'; import '../../services/secure_chat_service.dart'; import '../../services/local_message_store.dart'; import '../../theme/app_theme.dart'; +import '../../services/notification_service.dart'; import '../../widgets/media/signed_media_image.dart'; import '../../widgets/secure_chat/chat_bubble_widget.dart'; import '../../widgets/secure_chat/composer_widget.dart'; @@ -58,6 +59,7 @@ class _SecureChatScreenState extends State print('[DEBUG] SecureChatScreen initState - UPLOAD BUTTON SHOULD BE VISIBLE'); WidgetsBinding.instance.addObserver(this); _messageStream = _chatService.getMessagesStream(widget.conversation.id); + NotificationService.instance.activeConversationId = widget.conversation.id; _markAsRead(); _hydrateAvatars(); } @@ -65,6 +67,7 @@ class _SecureChatScreenState extends State @override void dispose() { WidgetsBinding.instance.removeObserver(this); + NotificationService.instance.activeConversationId = null; _messageController.dispose(); _scrollController.dispose(); _focusNode.dispose(); diff --git a/sojorn_app/lib/services/image_upload_service.dart b/sojorn_app/lib/services/image_upload_service.dart index 3f11e57..49ff31d 100644 --- a/sojorn_app/lib/services/image_upload_service.dart +++ b/sojorn_app/lib/services/image_upload_service.dart @@ -500,6 +500,10 @@ class ImageUploadService { /// Helper to force custom domains if raw R2 URLs slip through String _fixR2Url(String url) { + if (url.contains('gosojorn.com')) { + return url.replaceAll('gosojorn.com', 'sojorn.net'); + } + if (!url.contains('.r2.cloudflarestorage.com')) return url; // Fix Image URLs diff --git a/sojorn_app/lib/services/notification_service.dart b/sojorn_app/lib/services/notification_service.dart index 7e9a4bd..7fb2d01 100644 --- a/sojorn_app/lib/services/notification_service.dart +++ b/sojorn_app/lib/services/notification_service.dart @@ -10,6 +10,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../config/firebase_web_config.dart'; import '../routes/app_routes.dart'; import '../services/secure_chat_service.dart'; +import '../theme/app_theme.dart'; import 'api_service.dart'; /// NotificationPreferences model @@ -174,6 +175,10 @@ class NotificationService { // Global overlay entry for in-app notification banner OverlayEntry? _currentBannerOverlay; + // Active context tracking to suppress notifications for what the user is already seeing + String? activeConversationId; + String? activePostId; + Future init() async { if (_initialized) return; _initialized = true; @@ -434,6 +439,23 @@ class NotificationService { /// Show an in-app notification banner void showNotificationBanner(BuildContext context, RemoteMessage message) { + final data = message.data; + final type = data['type'] as String?; + + // Suppress if the user is already in this conversation + if (activeConversationId != null && + data['conversation_id']?.toString() == activeConversationId) { + debugPrint('[FCM] Suppressing banner for active conversation: $activeConversationId'); + return; + } + + // Suppress if the user is already in this post/thread + if (activePostId != null && + (data['post_id']?.toString() == activePostId || data['beacon_id']?.toString() == activePostId)) { + debugPrint('[FCM] Suppressing banner for active post: $activePostId'); + return; + } + // Dismiss any existing banner _dismissCurrentBanner(); @@ -683,20 +705,15 @@ class _NotificationBannerState extends State<_NotificationBanner> child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.grey[900]!, - Colors.grey[850]!, - ], - ), - borderRadius: BorderRadius.circular(16), + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(20), border: Border.all( - color: Colors.white.withOpacity(0.1), - width: 1, + color: AppTheme.egyptianBlue.withOpacity(0.1), + width: 1.5, ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.3), + color: AppTheme.brightNavy.withOpacity(0.12), blurRadius: 20, offset: const Offset(0, 8), ), @@ -709,7 +726,7 @@ class _NotificationBannerState extends State<_NotificationBanner> width: 44, height: 44, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), + color: AppTheme.brightNavy.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Center( @@ -728,9 +745,9 @@ class _NotificationBannerState extends State<_NotificationBanner> children: [ Text( notification?.title ?? 'Sojorn', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, + style: TextStyle( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w700, fontSize: 15, ), maxLines: 1, @@ -741,8 +758,9 @@ class _NotificationBannerState extends State<_NotificationBanner> Text( notification!.body!, style: TextStyle( - color: Colors.white.withOpacity(0.7), + color: AppTheme.textSecondary.withOpacity(0.8), fontSize: 13, + height: 1.3, ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -758,7 +776,7 @@ class _NotificationBannerState extends State<_NotificationBanner> padding: const EdgeInsets.all(8), child: Icon( Icons.close, - color: Colors.white.withOpacity(0.5), + color: AppTheme.textSecondary.withOpacity(0.3), size: 18, ), ), diff --git a/sojorn_app/lib/widgets/app_scaffold.dart b/sojorn_app/lib/widgets/app_scaffold.dart index 3afb0d4..19e849b 100644 --- a/sojorn_app/lib/widgets/app_scaffold.dart +++ b/sojorn_app/lib/widgets/app_scaffold.dart @@ -13,6 +13,7 @@ class AppScaffold extends StatelessWidget { final bool resizeToAvoidBottomInset; final PreferredSizeWidget? customAppBar; final bool showAppBar; + final PreferredSizeWidget? bottom; const AppScaffold({ super.key, @@ -26,6 +27,7 @@ class AppScaffold extends StatelessWidget { this.resizeToAvoidBottomInset = true, this.customAppBar, this.showAppBar = true, + this.bottom, }); // Responsive breakpoints and margins (moved from AppTheme) @@ -89,8 +91,9 @@ class AppScaffold extends StatelessWidget { elevation: 0, backgroundColor: AppTheme.queenPinkLight, iconTheme: IconThemeData(color: AppTheme.navyBlue), + bottom: bottom, ); - } +} Widget? _buildBackButton(BuildContext context) { return Navigator.canPop(context) diff --git a/sojorn_app/lib/widgets/media/signed_media_image.dart b/sojorn_app/lib/widgets/media/signed_media_image.dart index b66ed15..c63cf82 100644 --- a/sojorn_app/lib/widgets/media/signed_media_image.dart +++ b/sojorn_app/lib/widgets/media/signed_media_image.dart @@ -70,7 +70,8 @@ class _SignedMediaImageState extends ConsumerState { final host = uri.host.toLowerCase(); // Custom domain URLs are public and directly accessible - no signing needed - if (host == 'img.sojorn.net' || host == 'quips.sojorn.net') { + if (host == 'img.sojorn.net' || host == 'quips.sojorn.net' || + host == 'img.gosojorn.com' || host == 'quips.gosojorn.com') { return false; } diff --git a/sojorn_app/lib/widgets/traditional_quips_sheet.dart b/sojorn_app/lib/widgets/traditional_quips_sheet.dart index f6adb84..4162e75 100644 --- a/sojorn_app/lib/widgets/traditional_quips_sheet.dart +++ b/sojorn_app/lib/widgets/traditional_quips_sheet.dart @@ -17,6 +17,7 @@ import '../widgets/reactions/reactions_display.dart'; import '../widgets/reactions/reaction_picker.dart'; import '../widgets/modals/sanctuary_sheet.dart'; import '../widgets/sojorn_snackbar.dart'; +import '../providers/notification_provider.dart'; import 'post/post_body.dart'; import 'post/post_view_mode.dart'; @@ -344,7 +345,17 @@ class _TraditionalQuipsSheetState extends ConsumerState { ), IconButton( onPressed: () => context.go(AppRoutes.secureChat), - icon: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + icon: Consumer( + builder: (context, ref, child) { + final badge = ref.watch(currentBadgeProvider); + return Badge( + label: Text(badge.messageCount.toString()), + isLabelVisible: badge.messageCount > 0, + backgroundColor: AppTheme.brightNavy, + child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), + ); + }, + ), ), ] else ...[ IconButton(