Notification System

This commit is contained in:
Tim Zöller 2025-12-04 08:59:44 +01:00
parent 2bc865fefd
commit facade014a
7 changed files with 662 additions and 7 deletions

View file

@ -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] 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)
- [ ] 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
- [ ] Follow/unfollow buttons on user profiles
- [ ] Activity visibility to followers (implement FOLLOWERS visibility enforcement)

View file

@ -59,6 +59,7 @@ public class SecurityConfig {
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
.requestMatchers("/discover").permitAll() // User discovery page
.requestMatchers("/notifications").permitAll() // Auth checked client-side
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
@ -97,6 +98,9 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/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)
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()

View file

@ -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";
}
}

View file

@ -38,8 +38,16 @@ public class NotificationService {
*/
@Transactional
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
String activityOwnerUri = activity.getUser().getActorUri(baseUrl);
String activityOwnerUri = activityOwner.getActorUri(baseUrl);
if (activityOwnerUri.equals(likerActorUri)) {
return;
}
@ -52,7 +60,7 @@ public class NotificationService {
}
Notification notification = Notification.builder()
.user(activity.getUser())
.user(activityOwner)
.type(Notification.NotificationType.ACTIVITY_LIKED)
.actorUri(likerActorUri)
.actorDisplayName(actorInfo.displayName)
@ -63,7 +71,7 @@ public class NotificationService {
.build();
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
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
String activityOwnerUri = activity.getUser().getActorUri(baseUrl);
String activityOwnerUri = activityOwner.getActorUri(baseUrl);
if (activityOwnerUri.equals(commenterActorUri)) {
return;
}
@ -95,7 +111,7 @@ public class NotificationService {
}
Notification notification = Notification.builder()
.user(activity.getUser())
.user(activityOwner)
.type(Notification.NotificationType.ACTIVITY_COMMENTED)
.actorUri(commenterActorUri)
.actorDisplayName(actorInfo.displayName)
@ -108,7 +124,7 @@ public class NotificationService {
.build();
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);
}
/**

View file

@ -216,6 +216,7 @@ const FitPubAuth = {
const usernameDisplay = document.getElementById('usernameDisplay');
const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink');
const notificationsBell = document.getElementById('notificationsBell');
if (this.isAuthenticated()) {
// Show authenticated menu, hide guest menu
@ -236,16 +237,27 @@ const FitPubAuth = {
uploadLink.parentElement.style.display = '';
}
// Show notifications bell
if (notificationsBell) {
notificationsBell.classList.remove('d-none');
}
// Display username
const username = this.getUsername();
if (usernameDisplay && username) {
usernameDisplay.textContent = username;
}
// Start polling for unread notifications
this.startNotificationPolling();
} else {
// Show guest menu, hide authenticated menu
if (authUserMenu) {
authUserMenu.classList.add('d-none');
}
if (notificationsBell) {
notificationsBell.classList.add('d-none');
}
if (guestMenu) {
guestMenu.style.display = '';
}
@ -329,6 +341,52 @@ const FitPubAuth = {
*/
refreshPage: function() {
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);
}
}
};

View file

@ -74,6 +74,18 @@
<!-- Right side navigation -->
<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) -->
<li class="nav-item dropdown d-none" id="authUserMenu">
<a class="nav-link dropdown-toggle"

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