feat: Implement intelligent refresh and multi-channel notifications

Replaces the blind 60-second refresh with a smart polling system that only updates the UI when new messages actually arrive.

Key improvements:
- Lightweight update checks every 10 seconds (vs full refresh every 60s)
- Chat view refreshes only when new messages appear on active channel
- Notification bell with global unread count across all channels
- Per-channel unread badges in channel selector (e.g., "Malopolska (3)")
- Last-seen timestamp tracking per channel with localStorage persistence
- Bell ring animation when new messages arrive

Backend changes:
- New /api/messages/updates endpoint for efficient update polling
- Returns per-channel update status and unread counts

Frontend changes:
- Smart auto-refresh mechanism with conditional UI updates
- Unread message tracking system with localStorage
- Notification bell UI component with badge
- Channel selector badges for unread messages
- CSS animations for bell ring effect

This dramatically reduces network traffic and server load while providing better UX through instant notifications about activity on other channels.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2025-12-23 18:12:53 +01:00
parent 3faf39c3dc
commit 76134b133a
5 changed files with 429 additions and 8 deletions

View File

@@ -643,3 +643,103 @@ def get_channel_qr(index):
'success': False,
'error': str(e)
}), 500
@api_bp.route('/messages/updates', methods=['GET'])
def get_messages_updates():
"""
Check for new messages across all channels without fetching full message content.
Used for intelligent refresh mechanism and unread notifications.
Query parameters:
last_seen (str): JSON object with last seen timestamps per channel
Format: {"0": 1234567890, "1": 1234567891, ...}
Returns:
JSON with update information per channel:
{
"success": true,
"channels": [
{
"index": 0,
"name": "Public",
"has_updates": true,
"latest_timestamp": 1234567900,
"unread_count": 5
},
...
],
"total_unread": 10
}
"""
try:
# Parse last_seen timestamps from query param
last_seen_str = request.args.get('last_seen', '{}')
try:
last_seen = json.loads(last_seen_str)
# Convert keys to integers and values to floats
last_seen = {int(k): float(v) for k, v in last_seen.items()}
except (json.JSONDecodeError, ValueError):
last_seen = {}
# Get list of channels
success_ch, channels = cli.get_channels()
if not success_ch:
return jsonify({
'success': False,
'error': 'Failed to get channels'
}), 500
updates = []
total_unread = 0
# Check each channel for new messages
for channel in channels:
channel_idx = channel['index']
# Get latest message for this channel
messages = parser.read_messages(
limit=1,
channel_idx=channel_idx,
days=7 # Only check recent messages
)
latest_timestamp = 0
if messages and len(messages) > 0:
latest_timestamp = messages[0]['timestamp']
# Check if there are updates
last_seen_ts = last_seen.get(channel_idx, 0)
has_updates = latest_timestamp > last_seen_ts
# Count unread messages (messages newer than last_seen)
unread_count = 0
if has_updates:
all_messages = parser.read_messages(
limit=500,
channel_idx=channel_idx,
days=7
)
unread_count = sum(1 for msg in all_messages if msg['timestamp'] > last_seen_ts)
total_unread += unread_count
updates.append({
'index': channel_idx,
'name': channel['name'],
'has_updates': has_updates,
'latest_timestamp': latest_timestamp,
'unread_count': unread_count
})
return jsonify({
'success': True,
'channels': updates,
'total_unread': total_unread
}), 200
except Exception as e:
logger.error(f"Error checking message updates: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -253,3 +253,51 @@ main {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Notification Bell Badge */
.notification-badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #dc3545;
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 0.65rem;
font-weight: bold;
line-height: 1;
min-width: 18px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
}
/* Bell Ring Animation */
@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-8deg); }
30% { transform: rotate(14deg); }
40% { transform: rotate(-4deg); }
50% { transform: rotate(10deg); }
60% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
.bell-ring {
animation: bell-ring 1s ease-in-out;
transform-origin: 50% 4px;
}
/* Notification Bell Container */
#notificationBell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
#notificationBell:hover .notification-badge {
transform: scale(1.1);
transition: transform 0.2s ease;
}

View File

@@ -9,11 +9,16 @@ let isUserScrolling = false;
let currentArchiveDate = null; // Current selected archive date (null = live)
let currentChannelIdx = 0; // Current active channel (0 = Public)
let availableChannels = []; // List of channels from API
let lastSeenTimestamps = {}; // Track last seen message timestamp per channel
let unreadCounts = {}; // Track unread message counts per channel
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('mc-webui initialized');
// Load last seen timestamps from localStorage
loadLastSeenTimestamps();
// Load channels list
loadChannels();
@@ -69,8 +74,9 @@ function setupEventListeners() {
});
// Manual refresh button
document.getElementById('refreshBtn').addEventListener('click', function() {
loadMessages();
document.getElementById('refreshBtn').addEventListener('click', async function() {
await loadMessages();
await checkForUpdates();
});
// Date selector (archive selection)
@@ -260,6 +266,12 @@ function displayMessages(messages) {
}
lastMessageCount = messages.length;
// Mark current channel as read (update last seen timestamp to latest message)
if (messages.length > 0 && !currentArchiveDate) {
const latestTimestamp = Math.max(...messages.map(m => m.timestamp));
markChannelAsRead(currentChannelIdx, latestTimestamp);
}
}
/**
@@ -425,16 +437,23 @@ async function cleanupContacts() {
}
/**
* Setup auto-refresh
* Setup intelligent auto-refresh
* Checks for updates regularly but only refreshes UI when new messages arrive
*/
function setupAutoRefresh() {
const interval = window.MC_CONFIG?.refreshInterval || 60000;
// Check every 10 seconds for new messages (lightweight check)
const checkInterval = 10000;
autoRefreshInterval = setInterval(() => {
loadMessages();
}, interval);
autoRefreshInterval = setInterval(async () => {
// Don't check for updates when viewing archives
if (currentArchiveDate) {
return;
}
console.log(`Auto-refresh enabled: every ${interval / 1000}s`);
await checkForUpdates();
}, checkInterval);
console.log(`Intelligent auto-refresh enabled: checking every ${checkInterval / 1000}s`);
}
/**
@@ -587,6 +606,135 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* Load last seen timestamps from localStorage
*/
function loadLastSeenTimestamps() {
try {
const saved = localStorage.getItem('mc_last_seen_timestamps');
if (saved) {
lastSeenTimestamps = JSON.parse(saved);
console.log('Loaded last seen timestamps:', lastSeenTimestamps);
}
} catch (error) {
console.error('Error loading last seen timestamps:', error);
lastSeenTimestamps = {};
}
}
/**
* Save last seen timestamps to localStorage
*/
function saveLastSeenTimestamps() {
try {
localStorage.setItem('mc_last_seen_timestamps', JSON.stringify(lastSeenTimestamps));
} catch (error) {
console.error('Error saving last seen timestamps:', error);
}
}
/**
* Update last seen timestamp for current channel
*/
function markChannelAsRead(channelIdx, timestamp) {
lastSeenTimestamps[channelIdx] = timestamp;
unreadCounts[channelIdx] = 0;
saveLastSeenTimestamps();
updateUnreadBadges();
}
/**
* Check for new messages across all channels
*/
async function checkForUpdates() {
try {
// Build query with last seen timestamps
const lastSeenParam = encodeURIComponent(JSON.stringify(lastSeenTimestamps));
const response = await fetch(`/api/messages/updates?last_seen=${lastSeenParam}`);
const data = await response.json();
if (data.success) {
// Update unread counts
data.channels.forEach(channel => {
unreadCounts[channel.index] = channel.unread_count;
});
// Update UI badges
updateUnreadBadges();
// If current channel has updates, refresh the view
const currentChannelUpdate = data.channels.find(ch => ch.index === currentChannelIdx);
if (currentChannelUpdate && currentChannelUpdate.has_updates) {
console.log(`New messages detected on channel ${currentChannelIdx}, refreshing...`);
await loadMessages();
}
}
} catch (error) {
console.error('Error checking for updates:', error);
}
}
/**
* Update unread badges on channel selector and notification bell
*/
function updateUnreadBadges() {
// Update channel selector options
const selector = document.getElementById('channelSelector');
if (selector) {
Array.from(selector.options).forEach(option => {
const channelIdx = parseInt(option.value);
const unreadCount = unreadCounts[channelIdx] || 0;
// Get base channel name (remove existing badge if any)
let channelName = option.textContent.replace(/\s*\(\d+\)$/, '');
// Add badge if there are unread messages and it's not the current channel
if (unreadCount > 0 && channelIdx !== currentChannelIdx) {
option.textContent = `${channelName} (${unreadCount})`;
} else {
option.textContent = channelName;
}
});
}
// Update notification bell
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
updateNotificationBell(totalUnread);
}
/**
* Update notification bell icon with unread count
*/
function updateNotificationBell(count) {
const bellContainer = document.getElementById('notificationBell');
if (!bellContainer) return;
const bellIcon = bellContainer.querySelector('i');
let badge = bellContainer.querySelector('.notification-badge');
if (count > 0) {
// Show badge
if (!badge) {
badge = document.createElement('span');
badge.className = 'notification-badge';
bellContainer.appendChild(badge);
}
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline-block';
// Animate bell icon
if (bellIcon) {
bellIcon.classList.add('bell-ring');
setTimeout(() => bellIcon.classList.remove('bell-ring'), 1000);
}
} else {
// Hide badge
if (badge) {
badge.style.display = 'none';
}
}
}
/**
* Setup emoji picker
*/
@@ -657,6 +805,9 @@ async function loadChannels() {
availableChannels = data.channels;
console.log('[loadChannels] Channels loaded:', availableChannels.length);
populateChannelSelector(data.channels);
// Check for unread messages after channels are loaded
await checkForUpdates();
} else {
console.error('[loadChannels] Error loading channels:', data.error);
}

View File

@@ -36,6 +36,9 @@
<button class="btn btn-outline-light btn-sm" id="refreshBtn" title="Refresh messages">
<i class="bi bi-arrow-clockwise"></i> <span class="btn-text">Refresh</span>
</button>
<div id="notificationBell" class="btn btn-outline-light btn-sm position-relative" style="cursor: default;" title="Unread messages">
<i class="bi bi-bell"></i>
</div>
<select id="channelSelector" class="form-select form-select-sm" style="width: auto; min-width: 120px;" title="Select channel">
<option value="0">Public</option>
<!-- Channels loaded dynamically via JavaScript -->

View File

@@ -0,0 +1,119 @@
# Smart Refresh & Notifications Feature
## Zaimplementowane zmiany
### 1. Inteligentne odświeżanie
- ✅ Aplikacja sprawdza nowe wiadomości co **10 sekund** (zamiast pełnego odświeżania co 60s)
- ✅ Widok czatu odświeża się **tylko gdy faktycznie pojawiają się nowe wiadomości** na aktywnym kanale
- ✅ Znacznie zmniejszone obciążenie - zamiast pobierać 500 wiadomości co minutę, sprawdzamy tylko czy są aktualizacje
### 2. System powiadomień o nieprzeczytanych wiadomościach
#### Ikona dzwoneczka 🔔
- Dodana w navbar (między przyciskiem Refresh a selektorem kanału)
- Pokazuje **globalny licznik** wszystkich nieprzeczytanych wiadomości
- Animacja dzwonka przy nowych wiadomościach
- Czerwony badge z liczbą (np. "5" lub "99+" dla >99)
#### Badge przy nazwach kanałów
- W selektorze kanału przy każdym kanale pokazuje się liczba nieprzeczytanych (np. "Malopolska (3)")
- Badge znika gdy przełączysz się na dany kanał
- Nie pokazuje się dla aktualnie otwartego kanału
### 3. Tracking przeczytanych wiadomości
- System automatycznie śledzi timestamp ostatnio przeczytanej wiadomości per kanał
- Dane zapisywane w `localStorage` (przetrwają restart przeglądarki)
- Wiadomość jest oznaczona jako przeczytana gdy:
- Jest wyświetlona w oknie czatu
- Kanał jest aktywny (otwarty)
## API Endpoint
### `GET /api/messages/updates`
Nowy endpoint do sprawdzania aktualizacji bez pobierania pełnych wiadomości.
**Query params:**
- `last_seen` - JSON object z timestampami per kanał (np. `{"0": 1234567890, "1": 1234567891}`)
**Response:**
```json
{
"success": true,
"channels": [
{
"index": 0,
"name": "Public",
"has_updates": true,
"latest_timestamp": 1234567900,
"unread_count": 5
},
{
"index": 1,
"name": "Malopolska",
"has_updates": false,
"latest_timestamp": 1234567800,
"unread_count": 0
}
],
"total_unread": 5
}
```
## Pliki zmodyfikowane
### Backend:
- `app/routes/api.py` - dodany endpoint `/api/messages/updates`
### Frontend:
- `app/static/js/app.js` - cała logika smart refresh i notyfikacji
- `app/templates/base.html` - dodana ikona dzwoneczka w navbar
- `app/static/css/style.css` - style dla badge'ów i animacji
## Jak przetestować
1. Uruchom aplikację:
```bash
docker compose up
```
2. Otwórz aplikację w przeglądarce
3. **Test 1: Inteligentne odświeżanie**
- Otwórz konsolę przeglądarki (F12)
- Obserwuj logi - co 10s pojawi się sprawdzenie aktualizacji
- Wyślij wiadomość z innego urządzenia
- Aplikacja powinna automatycznie odświeżyć widok w ciągu 10 sekund
4. **Test 2: Powiadomienia multi-channel**
- Utwórz/dołącz do drugiego kanału
- Pozostań na kanale Public
- Wyślij wiadomość na drugim kanale (z innego urządzenia)
- Po ~10 sekundach powinieneś zobaczyć:
- Czerwony badge na ikonie dzwoneczka (np. "3")
- Badge przy nazwie kanału w selektorze (np. "Malopolska (3)")
- Dzwonek powinien się lekko "zakołysać" (animacja)
5. **Test 3: Oznaczanie jako przeczytane**
- Przełącz się na kanał z nieprzeczytanymi wiadomościami
- Badge powinien natychmiast zniknąć
- Jeśli wszystkie kanały są przeczytane, dzwonek powinien być bez badge'a
6. **Test 4: Persistence**
- Odśwież stronę (F5)
- Stan przeczytanych wiadomości powinien się zachować (dzięki localStorage)
## Zalety nowego rozwiązania
1. **Wydajność** - mniejsze obciążenie serwera i sieci (lekkie sprawdzenia vs pełne pobieranie)
2. **UX** - użytkownik od razu wie gdy są nowe wiadomości na innych kanałach
3. **Optymalizacja** - odświeżanie tylko gdy jest potrzebne
4. **Responsywność** - sprawdzanie co 10s zamiast 60s = szybsze reakcje
5. **Persistence** - stan przeczytanych zachowany między sesjami
## Uwagi techniczne
- Checking interval: **10 sekund** (można zmienić w `setupAutoRefresh()`)
- Badge nie pokazuje się dla archiwów (tylko live view)
- Auto-refresh wyłączony gdy przeglądasz archiwum
- LocalStorage key: `mc_last_seen_timestamps`