Notification System
This commit is contained in:
parent
2bc865fefd
commit
facade014a
7 changed files with 662 additions and 7 deletions
|
|
@ -778,7 +778,7 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [x] Follower/following counts (UserController.populateSocialCounts, UserDTO with followersCount/followingCount, frontend displays real counts)
|
- [x] Follower/following counts (UserController.populateSocialCounts, UserDTO with followersCount/followingCount, frontend displays real counts)
|
||||||
- [x] Heart rate chart over time on activity details (Chart.js line chart, elapsed time x-axis, heart rate y-axis)
|
- [x] Heart rate chart over time on activity details (Chart.js line chart, elapsed time x-axis, heart rate y-axis)
|
||||||
- [x] Speed/pace chart over time on activity details (Chart.js line chart with smoothing, displays speed in km/h with pace in tooltip)
|
- [x] Speed/pace chart over time on activity details (Chart.js line chart with smoothing, displays speed in km/h with pace in tooltip)
|
||||||
- [ ] Notifications system
|
- [x] Notifications system (Notification entity, NotificationRepository, NotificationService, NotificationController REST API, notifications.html UI, notification bell in nav with unread count, polling every 30s, mark as read/delete, all/unread filter tabs)
|
||||||
- [ ] Enhanced privacy controls UI
|
- [ ] Enhanced privacy controls UI
|
||||||
- [ ] Follow/unfollow buttons on user profiles
|
- [ ] Follow/unfollow buttons on user profiles
|
||||||
- [ ] Activity visibility to followers (implement FOLLOWERS visibility enforcement)
|
- [ ] Activity visibility to followers (implement FOLLOWERS visibility enforcement)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ public class SecurityConfig {
|
||||||
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
|
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
|
||||||
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
|
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
|
||||||
.requestMatchers("/discover").permitAll() // User discovery page
|
.requestMatchers("/discover").permitAll() // User discovery page
|
||||||
|
.requestMatchers("/notifications").permitAll() // Auth checked client-side
|
||||||
|
|
||||||
// Public endpoints - ActivityPub federation
|
// Public endpoints - ActivityPub federation
|
||||||
.requestMatchers("/.well-known/**").permitAll()
|
.requestMatchers("/.well-known/**").permitAll()
|
||||||
|
|
@ -97,6 +98,9 @@ public class SecurityConfig {
|
||||||
.requestMatchers(HttpMethod.POST, "/api/activities/*/comments").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/activities/*/comments").authenticated()
|
||||||
.requestMatchers(HttpMethod.DELETE, "/api/activities/*/comments/*").authenticated()
|
.requestMatchers(HttpMethod.DELETE, "/api/activities/*/comments/*").authenticated()
|
||||||
|
|
||||||
|
// Protected endpoints - Notifications API
|
||||||
|
.requestMatchers("/api/notifications/**").authenticated()
|
||||||
|
|
||||||
// Protected endpoints - Activities API (upload, edit, delete)
|
// Protected endpoints - Activities API (upload, edit, delete)
|
||||||
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for notifications-related web pages
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class NotificationsViewController {
|
||||||
|
|
||||||
|
@GetMapping("/notifications")
|
||||||
|
public String notifications() {
|
||||||
|
return "notifications";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,8 +38,16 @@ public class NotificationService {
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void createActivityLikedNotification(Activity activity, String likerActorUri) {
|
public void createActivityLikedNotification(Activity activity, String likerActorUri) {
|
||||||
|
// Get the activity owner
|
||||||
|
User activityOwner = userRepository.findById(activity.getUserId())
|
||||||
|
.orElse(null);
|
||||||
|
if (activityOwner == null) {
|
||||||
|
log.warn("Could not find activity owner for activity: {}", activity.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't notify if user liked their own activity
|
// Don't notify if user liked their own activity
|
||||||
String activityOwnerUri = activity.getUser().getActorUri(baseUrl);
|
String activityOwnerUri = activityOwner.getActorUri(baseUrl);
|
||||||
if (activityOwnerUri.equals(likerActorUri)) {
|
if (activityOwnerUri.equals(likerActorUri)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +60,7 @@ public class NotificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification notification = Notification.builder()
|
Notification notification = Notification.builder()
|
||||||
.user(activity.getUser())
|
.user(activityOwner)
|
||||||
.type(Notification.NotificationType.ACTIVITY_LIKED)
|
.type(Notification.NotificationType.ACTIVITY_LIKED)
|
||||||
.actorUri(likerActorUri)
|
.actorUri(likerActorUri)
|
||||||
.actorDisplayName(actorInfo.displayName)
|
.actorDisplayName(actorInfo.displayName)
|
||||||
|
|
@ -63,7 +71,7 @@ public class NotificationService {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username);
|
log.debug("Created ACTIVITY_LIKED notification for user {} from {}", activityOwner.getUsername(), actorInfo.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,8 +83,16 @@ public class NotificationService {
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void createActivityCommentedNotification(Activity activity, Comment comment, String commenterActorUri) {
|
public void createActivityCommentedNotification(Activity activity, Comment comment, String commenterActorUri) {
|
||||||
|
// Get the activity owner
|
||||||
|
User activityOwner = userRepository.findById(activity.getUserId())
|
||||||
|
.orElse(null);
|
||||||
|
if (activityOwner == null) {
|
||||||
|
log.warn("Could not find activity owner for activity: {}", activity.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't notify if user commented on their own activity
|
// Don't notify if user commented on their own activity
|
||||||
String activityOwnerUri = activity.getUser().getActorUri(baseUrl);
|
String activityOwnerUri = activityOwner.getActorUri(baseUrl);
|
||||||
if (activityOwnerUri.equals(commenterActorUri)) {
|
if (activityOwnerUri.equals(commenterActorUri)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +111,7 @@ public class NotificationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification notification = Notification.builder()
|
Notification notification = Notification.builder()
|
||||||
.user(activity.getUser())
|
.user(activityOwner)
|
||||||
.type(Notification.NotificationType.ACTIVITY_COMMENTED)
|
.type(Notification.NotificationType.ACTIVITY_COMMENTED)
|
||||||
.actorUri(commenterActorUri)
|
.actorUri(commenterActorUri)
|
||||||
.actorDisplayName(actorInfo.displayName)
|
.actorDisplayName(actorInfo.displayName)
|
||||||
|
|
@ -108,7 +124,7 @@ public class NotificationService {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
log.debug("Created ACTIVITY_COMMENTED notification for user {} from {}", activity.getUser().getUsername(), actorInfo.username);
|
log.debug("Created ACTIVITY_COMMENTED notification for user {} from {}", activityOwner.getUsername(), actorInfo.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@ const FitPubAuth = {
|
||||||
const usernameDisplay = document.getElementById('usernameDisplay');
|
const usernameDisplay = document.getElementById('usernameDisplay');
|
||||||
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
||||||
const uploadLink = document.getElementById('uploadLink');
|
const uploadLink = document.getElementById('uploadLink');
|
||||||
|
const notificationsBell = document.getElementById('notificationsBell');
|
||||||
|
|
||||||
if (this.isAuthenticated()) {
|
if (this.isAuthenticated()) {
|
||||||
// Show authenticated menu, hide guest menu
|
// Show authenticated menu, hide guest menu
|
||||||
|
|
@ -236,16 +237,27 @@ const FitPubAuth = {
|
||||||
uploadLink.parentElement.style.display = '';
|
uploadLink.parentElement.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show notifications bell
|
||||||
|
if (notificationsBell) {
|
||||||
|
notificationsBell.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
// Display username
|
// Display username
|
||||||
const username = this.getUsername();
|
const username = this.getUsername();
|
||||||
if (usernameDisplay && username) {
|
if (usernameDisplay && username) {
|
||||||
usernameDisplay.textContent = username;
|
usernameDisplay.textContent = username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start polling for unread notifications
|
||||||
|
this.startNotificationPolling();
|
||||||
} else {
|
} else {
|
||||||
// Show guest menu, hide authenticated menu
|
// Show guest menu, hide authenticated menu
|
||||||
if (authUserMenu) {
|
if (authUserMenu) {
|
||||||
authUserMenu.classList.add('d-none');
|
authUserMenu.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
if (notificationsBell) {
|
||||||
|
notificationsBell.classList.add('d-none');
|
||||||
|
}
|
||||||
if (guestMenu) {
|
if (guestMenu) {
|
||||||
guestMenu.style.display = '';
|
guestMenu.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +341,52 @@ const FitPubAuth = {
|
||||||
*/
|
*/
|
||||||
refreshPage: function() {
|
refreshPage: function() {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for unread notifications
|
||||||
|
*/
|
||||||
|
notificationPollInterval: null,
|
||||||
|
|
||||||
|
startNotificationPolling: function() {
|
||||||
|
// Stop any existing polling
|
||||||
|
this.stopNotificationPolling();
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
this.updateUnreadNotificationCount();
|
||||||
|
|
||||||
|
// Poll every 30 seconds
|
||||||
|
this.notificationPollInterval = setInterval(() => {
|
||||||
|
this.updateUnreadNotificationCount();
|
||||||
|
}, 30000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopNotificationPolling: function() {
|
||||||
|
if (this.notificationPollInterval) {
|
||||||
|
clearInterval(this.notificationPollInterval);
|
||||||
|
this.notificationPollInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUnreadNotificationCount() {
|
||||||
|
try {
|
||||||
|
const response = await this.authenticatedFetch('/api/notifications/unread/count');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const badge = document.getElementById('navNotificationCount');
|
||||||
|
if (badge) {
|
||||||
|
if (data.count > 0) {
|
||||||
|
badge.textContent = data.count > 99 ? '99+' : data.count;
|
||||||
|
badge.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - don't spam console with errors
|
||||||
|
console.debug('Failed to fetch notification count:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,18 @@
|
||||||
|
|
||||||
<!-- Right side navigation -->
|
<!-- Right side navigation -->
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
|
<!-- Notifications bell (hidden by default, shown by JS if JWT exists) -->
|
||||||
|
<li class="nav-item d-none" id="notificationsBell">
|
||||||
|
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
|
||||||
|
id="navNotificationCount"
|
||||||
|
style="display: none; font-size: 0.6rem;">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<!-- Authenticated user menu (hidden by default, shown by JS if JWT exists) -->
|
<!-- Authenticated user menu (hidden by default, shown by JS if JWT exists) -->
|
||||||
<li class="nav-item dropdown d-none" id="authUserMenu">
|
<li class="nav-item dropdown d-none" id="authUserMenu">
|
||||||
<a class="nav-link dropdown-toggle"
|
<a class="nav-link dropdown-toggle"
|
||||||
|
|
|
||||||
549
src/main/resources/templates/notifications.html
Normal file
549
src/main/resources/templates/notifications.html
Normal file
|
|
@ -0,0 +1,549 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notifications - FitPub</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" th:href="@{/css/fitpub.css}">
|
||||||
|
<style>
|
||||||
|
.notification-item {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread:hover {
|
||||||
|
background-color: #d4e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon.like {
|
||||||
|
background-color: #ffe0e0;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon.comment {
|
||||||
|
background-color: #e0f0ff;
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon.follow {
|
||||||
|
background-color: #e0ffe0;
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actor {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notifications {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notifications i {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: #dee2e6;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div th:replace="~{layout :: header}"></div>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 offset-lg-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<button id="markAllReadBtn" class="btn btn-outline-primary btn-sm" style="display: none;">
|
||||||
|
<i class="bi bi-check-all"></i> Mark all as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter tabs -->
|
||||||
|
<ul class="nav nav-tabs filter-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all"
|
||||||
|
type="button" role="tab" aria-controls="all" aria-selected="true">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="unread-tab" data-bs-toggle="tab" data-bs-target="#unread"
|
||||||
|
type="button" role="tab" aria-controls="unread" aria-selected="false">
|
||||||
|
Unread <span id="unreadCount" class="badge bg-primary ms-1" style="display: none;"></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="all" role="tabpanel" aria-labelledby="all-tab">
|
||||||
|
<div id="allNotifications" class="notifications-list">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-muted">Loading notifications...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="unread" role="tabpanel" aria-labelledby="unread-tab">
|
||||||
|
<div id="unreadNotifications" class="notifications-list">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-muted">Loading notifications...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<nav aria-label="Notifications pagination" class="mt-4">
|
||||||
|
<ul id="pagination" class="pagination justify-content-center">
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div th:replace="~{layout :: footer}"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script th:src="@{/js/auth.js}"></script>
|
||||||
|
<script>
|
||||||
|
const NotificationsPage = {
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
currentFilter: 'all',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Check authentication
|
||||||
|
if (!FitPubAuth.isAuthenticated()) {
|
||||||
|
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial notifications
|
||||||
|
this.loadNotifications('all');
|
||||||
|
this.updateUnreadCount();
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Tab switching
|
||||||
|
document.getElementById('all-tab').addEventListener('click', () => {
|
||||||
|
this.currentFilter = 'all';
|
||||||
|
this.currentPage = 0;
|
||||||
|
this.loadNotifications('all');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('unread-tab').addEventListener('click', () => {
|
||||||
|
this.currentFilter = 'unread';
|
||||||
|
this.currentPage = 0;
|
||||||
|
this.loadNotifications('unread');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark all as read
|
||||||
|
document.getElementById('markAllReadBtn').addEventListener('click', () => {
|
||||||
|
this.markAllAsRead();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadNotifications(filter) {
|
||||||
|
const endpoint = filter === 'unread'
|
||||||
|
? '/api/notifications/unread'
|
||||||
|
: '/api/notifications';
|
||||||
|
|
||||||
|
const containerId = filter === 'unread'
|
||||||
|
? 'unreadNotifications'
|
||||||
|
: 'allNotifications';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch(
|
||||||
|
`${endpoint}?page=${this.currentPage}&size=${this.pageSize}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.renderNotifications(data, containerId);
|
||||||
|
this.renderPagination(data);
|
||||||
|
|
||||||
|
// Show/hide mark all as read button
|
||||||
|
const hasUnread = data.content.some(n => !n.read);
|
||||||
|
document.getElementById('markAllReadBtn').style.display =
|
||||||
|
hasUnread ? 'block' : 'none';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading notifications:', error);
|
||||||
|
document.getElementById(containerId).innerHTML = `
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
Failed to load notifications. Please try again.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderNotifications(data, containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
|
||||||
|
if (data.content.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-notifications">
|
||||||
|
<i class="bi bi-bell-slash"></i>
|
||||||
|
<h4>No notifications</h4>
|
||||||
|
<p class="text-muted">You're all caught up!</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = data.content.map(notification =>
|
||||||
|
this.createNotificationHtml(notification)
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
container.querySelectorAll('.notification-item').forEach(item => {
|
||||||
|
const notificationId = item.dataset.id;
|
||||||
|
const isRead = item.dataset.read === 'true';
|
||||||
|
const activityId = item.dataset.activityId;
|
||||||
|
|
||||||
|
item.addEventListener('click', async (e) => {
|
||||||
|
// Don't trigger if clicking delete button
|
||||||
|
if (e.target.closest('.delete-notification')) return;
|
||||||
|
|
||||||
|
// Mark as read if unread
|
||||||
|
if (!isRead) {
|
||||||
|
await this.markAsRead(notificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to activity if applicable
|
||||||
|
if (activityId) {
|
||||||
|
window.location.href = `/activities/detail?id=${activityId}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add delete handlers
|
||||||
|
container.querySelectorAll('.delete-notification').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const notificationId = btn.dataset.id;
|
||||||
|
await this.deleteNotification(notificationId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
createNotificationHtml(notification) {
|
||||||
|
const icon = this.getNotificationIcon(notification.type);
|
||||||
|
const message = this.getNotificationMessage(notification);
|
||||||
|
const time = this.formatTimeAgo(notification.createdAt);
|
||||||
|
const unreadClass = notification.read ? '' : 'unread';
|
||||||
|
const unreadBadge = notification.read ? '' : '<span class="notification-badge"></span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="notification-item ${unreadClass}"
|
||||||
|
data-id="${notification.id}"
|
||||||
|
data-read="${notification.read}"
|
||||||
|
data-activity-id="${notification.activityId || ''}"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<div class="notification-icon ${icon.class}">
|
||||||
|
<i class="bi ${icon.icon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="notification-actor">${this.escapeHtml(notification.actorDisplayName || notification.actorUsername)}</span>
|
||||||
|
${unreadBadge}
|
||||||
|
</div>
|
||||||
|
<div>${message}</div>
|
||||||
|
<div class="notification-time">${time}</div>
|
||||||
|
</div>
|
||||||
|
<div class="notification-actions">
|
||||||
|
<button class="btn btn-sm btn-outline-danger delete-notification"
|
||||||
|
data-id="${notification.id}"
|
||||||
|
title="Delete notification">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getNotificationIcon(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'ACTIVITY_LIKED':
|
||||||
|
return { icon: 'bi-heart-fill', class: 'like' };
|
||||||
|
case 'ACTIVITY_COMMENTED':
|
||||||
|
return { icon: 'bi-chat-fill', class: 'comment' };
|
||||||
|
case 'USER_FOLLOWED':
|
||||||
|
return { icon: 'bi-person-plus-fill', class: 'follow' };
|
||||||
|
case 'FOLLOW_ACCEPTED':
|
||||||
|
return { icon: 'bi-check-circle-fill', class: 'follow' };
|
||||||
|
default:
|
||||||
|
return { icon: 'bi-bell-fill', class: 'comment' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getNotificationMessage(notification) {
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'ACTIVITY_LIKED':
|
||||||
|
return `liked your activity <strong>${this.escapeHtml(notification.activityTitle)}</strong>`;
|
||||||
|
case 'ACTIVITY_COMMENTED':
|
||||||
|
const preview = notification.commentText
|
||||||
|
? `: "${this.escapeHtml(notification.commentText)}"`
|
||||||
|
: '';
|
||||||
|
return `commented on your activity <strong>${this.escapeHtml(notification.activityTitle)}</strong>${preview}`;
|
||||||
|
case 'USER_FOLLOWED':
|
||||||
|
return 'started following you';
|
||||||
|
case 'FOLLOW_ACCEPTED':
|
||||||
|
return 'accepted your follow request';
|
||||||
|
default:
|
||||||
|
return 'notification';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTimeAgo(timestamp) {
|
||||||
|
const now = new Date();
|
||||||
|
const then = new Date(timestamp);
|
||||||
|
const seconds = Math.floor((now - then) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||||
|
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
||||||
|
if (seconds < 2592000) return `${Math.floor(seconds / 604800)}w ago`;
|
||||||
|
return then.toLocaleDateString();
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAsRead(notificationId) {
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch(
|
||||||
|
`/api/notifications/${notificationId}/read`,
|
||||||
|
{ method: 'PUT' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Update UI
|
||||||
|
const item = document.querySelector(`[data-id="${notificationId}"]`);
|
||||||
|
if (item) {
|
||||||
|
item.classList.remove('unread');
|
||||||
|
item.dataset.read = 'true';
|
||||||
|
const badge = item.querySelector('.notification-badge');
|
||||||
|
if (badge) badge.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update unread count
|
||||||
|
this.updateUnreadCount();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notification as read:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async markAllAsRead() {
|
||||||
|
if (!confirm('Mark all notifications as read?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch(
|
||||||
|
'/api/notifications/read-all',
|
||||||
|
{ method: 'PUT' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload current view
|
||||||
|
this.loadNotifications(this.currentFilter);
|
||||||
|
this.updateUnreadCount();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking all as read:', error);
|
||||||
|
alert('Failed to mark all notifications as read');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteNotification(notificationId) {
|
||||||
|
if (!confirm('Delete this notification?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch(
|
||||||
|
`/api/notifications/${notificationId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Remove from UI
|
||||||
|
const item = document.querySelector(`[data-id="${notificationId}"]`);
|
||||||
|
if (item) {
|
||||||
|
item.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload if no notifications left
|
||||||
|
const container = document.getElementById(
|
||||||
|
this.currentFilter === 'unread' ? 'unreadNotifications' : 'allNotifications'
|
||||||
|
);
|
||||||
|
if (container.querySelectorAll('.notification-item').length === 0) {
|
||||||
|
this.loadNotifications(this.currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateUnreadCount();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting notification:', error);
|
||||||
|
alert('Failed to delete notification');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUnreadCount() {
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch('/api/notifications/unread/count');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const badge = document.getElementById('unreadCount');
|
||||||
|
if (data.count > 0) {
|
||||||
|
badge.textContent = data.count;
|
||||||
|
badge.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update navigation bell badge
|
||||||
|
const navBadge = document.getElementById('navNotificationCount');
|
||||||
|
if (navBadge) {
|
||||||
|
if (data.count > 0) {
|
||||||
|
navBadge.textContent = data.count > 99 ? '99+' : data.count;
|
||||||
|
navBadge.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
navBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unread count:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPagination(data) {
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (data.totalPages <= 1) {
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
if (data.number > 0) {
|
||||||
|
html += `<li class="page-item">
|
||||||
|
<a class="page-link" href="#" data-page="${data.number - 1}">Previous</a>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
for (let i = 0; i < data.totalPages; i++) {
|
||||||
|
if (i === data.number) {
|
||||||
|
html += `<li class="page-item active"><span class="page-link">${i + 1}</span></li>`;
|
||||||
|
} else if (i < 3 || i >= data.totalPages - 3 || Math.abs(i - data.number) <= 1) {
|
||||||
|
html += `<li class="page-item">
|
||||||
|
<a class="page-link" href="#" data-page="${i}">${i + 1}</a>
|
||||||
|
</li>`;
|
||||||
|
} else if (Math.abs(i - data.number) === 2) {
|
||||||
|
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if (data.number < data.totalPages - 1) {
|
||||||
|
html += `<li class="page-item">
|
||||||
|
<a class="page-link" href="#" data-page="${data.number + 1}">Next</a>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination.innerHTML = html;
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
pagination.querySelectorAll('a[data-page]').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.currentPage = parseInt(link.dataset.page);
|
||||||
|
this.loadNotifications(this.currentFilter);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
NotificationsPage.init();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue