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)
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)}';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 '../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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue