feat: notify archive instead of delete, fix api domain failsafe

This commit is contained in:
Patrick Britton 2026-02-04 19:38:02 -06:00
parent 0f6a91e319
commit 33ea9b1d56
16 changed files with 392 additions and 140 deletions

View file

@ -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.%';

View file

@ -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%';

View file

@ -36,14 +36,15 @@ func (h *NotificationHandler) GetNotifications(c *gin.Context) {
limit := utils.GetQueryInt(c, "limit", 20) limit := utils.GetQueryInt(c, "limit", 20)
offset := utils.GetQueryInt(c, "offset", 0) offset := utils.GetQueryInt(c, "offset", 0)
grouped := c.Query("grouped") == "true" grouped := c.Query("grouped") == "true"
includeArchived := c.Query("include_archived") == "true"
var notifications []models.Notification var notifications []models.Notification
var err error var err error
if grouped { 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 { } 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 { if err != nil {

View file

@ -104,7 +104,12 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notif *
return err 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 := ` query := `
SELECT 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, 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 FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.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 ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3 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") // 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 := ` query := `
WITH ranked AS ( WITH ranked AS (
SELECT SELECT
@ -167,7 +177,7 @@ func (r *NotificationRepository) GetGroupedNotifications(ctx context.Context, us
FROM public.notifications n FROM public.notifications n
JOIN public.profiles pr ON n.actor_id = pr.id JOIN public.profiles pr ON n.actor_id = pr.id
LEFT JOIN public.posts po ON n.post_id = po.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 SELECT
id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata, id, user_id, type, actor_id, post_id, comment_id, is_read, created_at, metadata,

View file

@ -3,14 +3,18 @@ class ApiConfig {
static final String baseUrl = _computeBaseUrl(); static final String baseUrl = _computeBaseUrl();
static String _computeBaseUrl() { static String _computeBaseUrl() {
final raw = const String.fromEnvironment( String raw = const String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://api.sojorn.net/api/v1', 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) // Auto-upgrade any lingering http://api.sojorn.net:8080 (or plain http)
// to the public https endpoint behind nginx. This protects old .env files // to the public https endpoint behind nginx.
// or cached web builds that still point at the closed port 8080.
if (raw.startsWith('http://api.sojorn.net:8080')) { if (raw.startsWith('http://api.sojorn.net:8080')) {
return raw.replaceFirst( return raw.replaceFirst(
'http://api.sojorn.net:8080', '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://')) { if (raw.startsWith('http://')) {
return 'https://${raw.substring('http://'.length)}'; return 'https://${raw.substring('http://'.length)}';
} }

View file

@ -0,0 +1,20 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/notification_service.dart';
final notificationServiceProvider = Provider<NotificationService>((ref) {
return NotificationService.instance;
});
final badgeStreamProvider = StreamProvider<UnreadBadge>((ref) {
final service = ref.watch(notificationServiceProvider);
return service.badgeStream;
});
final currentBadgeProvider = Provider<UnreadBadge>((ref) {
final badgeAsync = ref.watch(badgeStreamProvider);
return badgeAsync.when(
data: (badge) => badge,
loading: () => NotificationService.instance.currentBadge,
error: (_, __) => NotificationService.instance.currentBadge,
);
});

View file

@ -15,20 +15,21 @@ import '../quips/create/quip_creation_flow.dart';
import '../secure_chat/secure_chat_full_screen.dart'; import '../secure_chat/secure_chat_full_screen.dart';
import '../../widgets/radial_menu_overlay.dart'; import '../../widgets/radial_menu_overlay.dart';
import '../../providers/quip_upload_provider.dart'; import '../../providers/quip_upload_provider.dart';
import '../../providers/notification_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Root shell for the main tabs. The active tab is controlled by GoRouter's /// Root shell for the main tabs. The active tab is controlled by GoRouter's
/// [StatefulNavigationShell] so navigation state and tab selection stay in sync. /// [StatefulNavigationShell] so navigation state and tab selection stay in sync.
class HomeShell extends StatefulWidget { class HomeShell extends ConsumerStatefulWidget {
final StatefulNavigationShell navigationShell; final StatefulNavigationShell navigationShell;
const HomeShell({super.key, required this.navigationShell}); const HomeShell({super.key, required this.navigationShell});
@override @override
State<HomeShell> createState() => _HomeShellState(); ConsumerState<HomeShell> createState() => _HomeShellState();
} }
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver { class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserver {
bool _isRadialMenuVisible = false; bool _isRadialMenuVisible = false;
final SecureChatService _chatService = SecureChatService(); final SecureChatService _chatService = SecureChatService();
StreamSubscription<RemoteMessage>? _notifSub; StreamSubscription<RemoteMessage>? _notifSub;
@ -264,7 +265,17 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
}, },
), ),
IconButton( 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', tooltip: 'Messages',
onPressed: () { onPressed: () {
Navigator.of(context, rootNavigator: true).push( Navigator.of(context, rootNavigator: true).push(
@ -276,7 +287,17 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
}, },
), ),
IconButton( 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', tooltip: 'Notifications',
onPressed: () { onPressed: () {
showGeneralDialog( showGeneralDialog(

View file

@ -11,6 +11,7 @@ import '../../widgets/media/signed_media_image.dart';
import '../profile/viewable_profile_screen.dart'; import '../profile/viewable_profile_screen.dart';
import '../post/post_detail_screen.dart'; import '../post/post_detail_screen.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../services/notification_service.dart';
/// Notifications screen showing user activity /// Notifications screen showing user activity
class NotificationsScreen extends ConsumerStatefulWidget { class NotificationsScreen extends ConsumerStatefulWidget {
@ -25,9 +26,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
bool _isLoading = false; bool _isLoading = false;
bool _hasMore = true; bool _hasMore = true;
String? _error; String? _error;
bool _showArchived = false; int _activeTabIndex = 0; // 0 for Active, 1 for Archived
final Set<String> _locallyArchivedIds = {}; final Set<String> _locallyArchivedIds = {};
StreamSubscription<AuthState>? _authSub; StreamSubscription<AuthState>? _authSub;
TabController? _tabController;
@override @override
void initState() { void initState() {
@ -48,6 +50,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
@override @override
void dispose() { void dispose() {
_authSub?.cancel(); _authSub?.cancel();
_tabController?.dispose();
super.dispose(); super.dispose();
} }
@ -63,7 +66,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
if (notifications.isNotEmpty && mounted) { if (notifications.isNotEmpty && mounted) {
final newNotification = notifications.first; final newNotification = notifications.first;
if (!_showArchived && if (_activeTabIndex != 1 && // If not in Archived tab
(newNotification.archivedAt != null || (newNotification.archivedAt != null ||
_locallyArchivedIds.contains(newNotification.id))) { _locallyArchivedIds.contains(newNotification.id))) {
return; return;
@ -97,20 +100,28 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
try { try {
final apiService = ref.read(apiServiceProvider); final apiService = ref.read(apiServiceProvider);
final showArchived = _activeTabIndex == 1;
final notifications = await apiService.getNotifications( final notifications = await apiService.getNotifications(
limit: 20, limit: 20,
offset: refresh ? 0 : _notifications.length, offset: refresh ? 0 : _notifications.length,
includeArchived: _showArchived, includeArchived: showArchived,
); );
if (mounted) { if (mounted) {
final filtered = _showArchived // If showing Active, filter out anything archived
final filtered = !showArchived
? notifications ? notifications
: notifications
.where((item) => .where((item) =>
item.archivedAt == null && item.archivedAt == null &&
!_locallyArchivedIds.contains(item.id)) !_locallyArchivedIds.contains(item.id))
.toList()
// If showing Archived, only show archived items
: notifications
.where((item) =>
item.archivedAt != null ||
_locallyArchivedIds.contains(item.id))
.toList(); .toList();
final fetchedCount = notifications.length; final fetchedCount = notifications.length;
setState(() { setState(() {
if (refresh) { if (refresh) {
@ -148,14 +159,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final index = final index =
_notifications.indexWhere((item) => item.id == notification.id); _notifications.indexWhere((item) => item.id == notification.id);
if (index != -1) { if (index != -1) {
if (_showArchived) {
_notifications[index] = notification.copyWith(isRead: true); _notifications[index] = notification.copyWith(isRead: true);
} else {
_locallyArchivedIds.add(notification.id);
_notifications.removeAt(index);
}
} }
}); });
// Clear the badge count on the bell icon immediately
NotificationService.instance.refreshBadge();
} }
} catch (e) { } catch (e) {
// Silently fail - not critical // Silently fail - not critical
@ -163,6 +172,26 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
} }
Future<void> _archiveAllNotifications() async { Future<void> _archiveAllNotifications() async {
final confirmed = await showDialog<bool>(
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 { try {
final apiService = ref.read(apiServiceProvider); final apiService = ref.read(apiServiceProvider);
await apiService.archiveAllNotifications(); await apiService.archiveAllNotifications();
@ -176,16 +205,19 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('All notifications cleared'), content: Text('Notifications moved to archive'),
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
); );
// Clear the badge count on the bell icon immediately
NotificationService.instance.refreshBadge();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to clear notifications: ${e.toString().replaceAll('Exception: ', '')}'), content: Text('Failed to archive notifications: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: AppTheme.error, backgroundColor: AppTheme.error,
), ),
); );
@ -236,10 +268,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
} }
void _handleNotificationTap(AppNotification notification) async { void _handleNotificationTap(AppNotification notification) async {
final archived = await _archiveNotification(notification); // Only mark as read, do NOT archive automatically on tap.
if (!archived) { // This allows the user to see the notification again later if they don't archive it.
await _markAsRead(notification); await _markAsRead(notification);
}
// Navigate based on notification type // Navigate based on notification type
switch (notification.type) { switch (notification.type) {
@ -309,6 +340,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
_locallyArchivedIds.add(notification.id); _locallyArchivedIds.add(notification.id);
_notifications.removeWhere((n) => n.id == notification.id); _notifications.removeWhere((n) => n.id == notification.id);
}); });
// Update badge count
NotificationService.instance.refreshBadge();
} }
return true; return true;
} catch (e) { } catch (e) {
@ -326,34 +360,46 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canClearNotifications = !_showArchived && _notifications.isNotEmpty; final canArchiveAll = _activeTabIndex == 0 && _notifications.isNotEmpty;
return AppScaffold( return DefaultTabController(
length: 2,
child: AppScaffold(
title: 'Notifications', title: 'Notifications',
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
actions: [ actions: [
IconButton( if (canArchiveAll)
tooltip: _showArchived ? 'Hide archived' : 'Show archived', TextButton(
icon: Icon(
_showArchived ? Icons.archive : Icons.archive_outlined,
),
onPressed: () {
setState(() {
_showArchived = !_showArchived;
});
_loadNotifications(refresh: true);
},
),
if (canClearNotifications)
IconButton(
tooltip: 'Clear notifications',
icon: Icon(Icons.clear_all, color: AppTheme.navyBlue),
onPressed: _archiveAllNotifications, 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 body: _error != null
? _ErrorState( ? _ErrorState(
message: _error!, message: _error!,
@ -365,6 +411,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
onRefresh: () => _loadNotifications(refresh: true), onRefresh: () => _loadNotifications(refresh: true),
child: ListView.builder( child: ListView.builder(
itemCount: _notifications.length + (_hasMore ? 1 : 0), itemCount: _notifications.length + (_hasMore ? 1 : 0),
padding: const EdgeInsets.symmetric(vertical: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == _notifications.length) { if (index == _notifications.length) {
// Load more indicator // Load more indicator
@ -380,7 +427,32 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
} }
final notification = _notifications[index]; final notification = _notifications[index];
return _NotificationItem( 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, notification: notification,
onTap: notification.type == NotificationType.follow_request onTap: notification.type == NotificationType.follow_request
? null ? null
@ -391,10 +463,21 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
onReject: notification.type == NotificationType.follow_request onReject: notification.type == NotificationType.follow_request
? () => _rejectFollowRequest(notification) ? () => _rejectFollowRequest(notification)
: null, : null,
),
); );
} else {
// No swipe to archive in Archived tab
return _NotificationItem(
notification: notification,
onTap: notification.type == NotificationType.follow_request
? null
: () => _handleNotificationTap(notification),
);
}
}, },
), ),
), ),
),
); );
} }
} }

View file

@ -15,8 +15,10 @@ import '../../widgets/media/signed_media_image.dart';
import '../compose/compose_screen.dart'; import '../compose/compose_screen.dart';
import '../discover/discover_screen.dart'; import '../discover/discover_screen.dart';
import '../secure_chat/secure_chat_full_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_body.dart';
import '../../widgets/post/post_view_mode.dart'; import '../../widgets/post/post_view_mode.dart';
import '../../providers/notification_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
class ThreadedConversationScreen extends ConsumerStatefulWidget { class ThreadedConversationScreen extends ConsumerStatefulWidget {
@ -53,6 +55,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
@override @override
void initState() { void initState() {
super.initState(); super.initState();
NotificationService.instance.activePostId = widget.rootPostId;
_initializeAnimations(); _initializeAnimations();
if (widget.rootPost != null) { if (widget.rootPost != null) {
@ -93,6 +96,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
@override @override
void dispose() { void dispose() {
NotificationService.instance.activePostId = null;
_slideController.dispose(); _slideController.dispose();
_fadeController.dispose(); _fadeController.dispose();
super.dispose(); super.dispose();
@ -198,11 +202,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
IconButton( IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push( onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => SecureChatFullScreen(), builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true, 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), const SizedBox(width: 8),
], ],

View file

@ -653,6 +653,24 @@ class _ConversationTileState extends State<_ConversationTile> {
], ],
), ),
), ),
// 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 // Hover delete button for web/desktop
if (_isHovered) if (_isHovered)
Padding( Padding(

View file

@ -7,6 +7,7 @@ import '../../services/api_service.dart';
import '../../services/secure_chat_service.dart'; import '../../services/secure_chat_service.dart';
import '../../services/local_message_store.dart'; import '../../services/local_message_store.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../services/notification_service.dart';
import '../../widgets/media/signed_media_image.dart'; import '../../widgets/media/signed_media_image.dart';
import '../../widgets/secure_chat/chat_bubble_widget.dart'; import '../../widgets/secure_chat/chat_bubble_widget.dart';
import '../../widgets/secure_chat/composer_widget.dart'; import '../../widgets/secure_chat/composer_widget.dart';
@ -58,6 +59,7 @@ class _SecureChatScreenState extends State<SecureChatScreen>
print('[DEBUG] SecureChatScreen initState - UPLOAD BUTTON SHOULD BE VISIBLE'); print('[DEBUG] SecureChatScreen initState - UPLOAD BUTTON SHOULD BE VISIBLE');
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_messageStream = _chatService.getMessagesStream(widget.conversation.id); _messageStream = _chatService.getMessagesStream(widget.conversation.id);
NotificationService.instance.activeConversationId = widget.conversation.id;
_markAsRead(); _markAsRead();
_hydrateAvatars(); _hydrateAvatars();
} }
@ -65,6 +67,7 @@ class _SecureChatScreenState extends State<SecureChatScreen>
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
NotificationService.instance.activeConversationId = null;
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_focusNode.dispose(); _focusNode.dispose();

View file

@ -500,6 +500,10 @@ class ImageUploadService {
/// Helper to force custom domains if raw R2 URLs slip through /// Helper to force custom domains if raw R2 URLs slip through
String _fixR2Url(String url) { String _fixR2Url(String url) {
if (url.contains('gosojorn.com')) {
return url.replaceAll('gosojorn.com', 'sojorn.net');
}
if (!url.contains('.r2.cloudflarestorage.com')) return url; if (!url.contains('.r2.cloudflarestorage.com')) return url;
// Fix Image URLs // Fix Image URLs

View file

@ -10,6 +10,7 @@ import 'package:permission_handler/permission_handler.dart';
import '../config/firebase_web_config.dart'; import '../config/firebase_web_config.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import '../services/secure_chat_service.dart'; import '../services/secure_chat_service.dart';
import '../theme/app_theme.dart';
import 'api_service.dart'; import 'api_service.dart';
/// NotificationPreferences model /// NotificationPreferences model
@ -174,6 +175,10 @@ class NotificationService {
// Global overlay entry for in-app notification banner // Global overlay entry for in-app notification banner
OverlayEntry? _currentBannerOverlay; OverlayEntry? _currentBannerOverlay;
// Active context tracking to suppress notifications for what the user is already seeing
String? activeConversationId;
String? activePostId;
Future<void> init() async { Future<void> init() async {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
@ -434,6 +439,23 @@ class NotificationService {
/// Show an in-app notification banner /// Show an in-app notification banner
void showNotificationBanner(BuildContext context, RemoteMessage message) { 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 // Dismiss any existing banner
_dismissCurrentBanner(); _dismissCurrentBanner();
@ -683,20 +705,15 @@ class _NotificationBannerState extends State<_NotificationBanner>
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: AppTheme.cardSurface,
colors: [ borderRadius: BorderRadius.circular(20),
Colors.grey[900]!,
Colors.grey[850]!,
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Colors.white.withOpacity(0.1), color: AppTheme.egyptianBlue.withOpacity(0.1),
width: 1, width: 1.5,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.3), color: AppTheme.brightNavy.withOpacity(0.12),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
@ -709,7 +726,7 @@ class _NotificationBannerState extends State<_NotificationBanner>
width: 44, width: 44,
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppTheme.brightNavy.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Center( child: Center(
@ -728,9 +745,9 @@ class _NotificationBannerState extends State<_NotificationBanner>
children: [ children: [
Text( Text(
notification?.title ?? 'Sojorn', notification?.title ?? 'Sojorn',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: AppTheme.textPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
fontSize: 15, fontSize: 15,
), ),
maxLines: 1, maxLines: 1,
@ -741,8 +758,9 @@ class _NotificationBannerState extends State<_NotificationBanner>
Text( Text(
notification!.body!, notification!.body!,
style: TextStyle( style: TextStyle(
color: Colors.white.withOpacity(0.7), color: AppTheme.textSecondary.withOpacity(0.8),
fontSize: 13, fontSize: 13,
height: 1.3,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -758,7 +776,7 @@ class _NotificationBannerState extends State<_NotificationBanner>
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Icon( child: Icon(
Icons.close, Icons.close,
color: Colors.white.withOpacity(0.5), color: AppTheme.textSecondary.withOpacity(0.3),
size: 18, size: 18,
), ),
), ),

View file

@ -13,6 +13,7 @@ class AppScaffold extends StatelessWidget {
final bool resizeToAvoidBottomInset; final bool resizeToAvoidBottomInset;
final PreferredSizeWidget? customAppBar; final PreferredSizeWidget? customAppBar;
final bool showAppBar; final bool showAppBar;
final PreferredSizeWidget? bottom;
const AppScaffold({ const AppScaffold({
super.key, super.key,
@ -26,6 +27,7 @@ class AppScaffold extends StatelessWidget {
this.resizeToAvoidBottomInset = true, this.resizeToAvoidBottomInset = true,
this.customAppBar, this.customAppBar,
this.showAppBar = true, this.showAppBar = true,
this.bottom,
}); });
// Responsive breakpoints and margins (moved from AppTheme) // Responsive breakpoints and margins (moved from AppTheme)
@ -89,6 +91,7 @@ class AppScaffold extends StatelessWidget {
elevation: 0, elevation: 0,
backgroundColor: AppTheme.queenPinkLight, backgroundColor: AppTheme.queenPinkLight,
iconTheme: IconThemeData(color: AppTheme.navyBlue), iconTheme: IconThemeData(color: AppTheme.navyBlue),
bottom: bottom,
); );
} }

View file

@ -70,7 +70,8 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
// Custom domain URLs are public and directly accessible - no signing needed // 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; return false;
} }

View file

@ -17,6 +17,7 @@ import '../widgets/reactions/reactions_display.dart';
import '../widgets/reactions/reaction_picker.dart'; import '../widgets/reactions/reaction_picker.dart';
import '../widgets/modals/sanctuary_sheet.dart'; import '../widgets/modals/sanctuary_sheet.dart';
import '../widgets/sojorn_snackbar.dart'; import '../widgets/sojorn_snackbar.dart';
import '../providers/notification_provider.dart';
import 'post/post_body.dart'; import 'post/post_body.dart';
import 'post/post_view_mode.dart'; import 'post/post_view_mode.dart';
@ -344,7 +345,17 @@ class _TraditionalQuipsSheetState extends ConsumerState<TraditionalQuipsSheet> {
), ),
IconButton( IconButton(
onPressed: () => context.go(AppRoutes.secureChat), 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 ...[ ] else ...[
IconButton( IconButton(