feat: notify archive instead of delete, fix api domain failsafe
This commit is contained in:
parent
0f6a91e319
commit
33ea9b1d56
|
|
@ -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.%';
|
||||
|
|
@ -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%';
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
}
|
||||
|
|
|
|||
20
sojorn_app/lib/providers/notification_provider.dart
Normal file
20
sojorn_app/lib/providers/notification_provider.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue