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)
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 {

View file

@ -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,

View file

@ -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)}';
}

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 '../../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<HomeShell> createState() => _HomeShellState();
ConsumerState<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserver {
bool _isRadialMenuVisible = false;
final SecureChatService _chatService = SecureChatService();
StreamSubscription<RemoteMessage>? _notifSub;
@ -264,7 +265,17 @@ class _HomeShellState extends State<HomeShell> 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<HomeShell> 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(

View file

@ -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<NotificationsScreen> {
bool _isLoading = false;
bool _hasMore = true;
String? _error;
bool _showArchived = false;
int _activeTabIndex = 0; // 0 for Active, 1 for Archived
final Set<String> _locallyArchivedIds = {};
StreamSubscription<AuthState>? _authSub;
TabController? _tabController;
@override
void initState() {
@ -48,6 +50,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
@override
void dispose() {
_authSub?.cancel();
_tabController?.dispose();
super.dispose();
}
@ -63,7 +66,7 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
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<NotificationsScreen> {
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<NotificationsScreen> {
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<NotificationsScreen> {
}
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 {
final apiService = ref.read(apiServiceProvider);
await apiService.archiveAllNotifications();
@ -176,16 +205,19 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
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<NotificationsScreen> {
}
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<NotificationsScreen> {
_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<NotificationsScreen> {
@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),
);
}
},
),
),
),
);
}
}

View file

@ -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<ThreadedConversatio
@override
void initState() {
super.initState();
NotificationService.instance.activePostId = widget.rootPostId;
_initializeAnimations();
if (widget.rootPost != null) {
@ -93,6 +96,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
@override
void dispose() {
NotificationService.instance.activePostId = null;
_slideController.dispose();
_fadeController.dispose();
super.dispose();
@ -198,11 +202,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
IconButton(
onPressed: () => 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),
],

View file

@ -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),

View file

@ -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<SecureChatScreen>
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<SecureChatScreen>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
NotificationService.instance.activeConversationId = null;
_messageController.dispose();
_scrollController.dispose();
_focusNode.dispose();

View file

@ -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

View file

@ -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<void> 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,
),
),

View file

@ -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)

View file

@ -70,7 +70,8 @@ class _SignedMediaImageState extends ConsumerState<SignedMediaImage> {
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;
}

View file

@ -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<TraditionalQuipsSheet> {
),
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(