mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
119
technotes/smart-refresh-feature.md
Normal file
119
technotes/smart-refresh-feature.md
Normal 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`
|
||||
Reference in New Issue
Block a user