feat(notifications): name the sender, quote the message, land on the right screen

A notification said "New: 1 channel" and nothing more, because
sendBrowserNotification() was driven by badge-counter deltas and never saw a
message. The socket payload has carried the sender, the text and the channel
all along.

Channel messages now notify as "<channel>" / "<sender>: <text>", DMs as
"<sender>" / "<text>", each conversation on its own tag so one cannot bury
another, and repeats on the same tag collapse with a count in the title.
Notifications carry a deep link in options.data.url; a tap resolves it in place
when the page is already up, and the wrapper hands it to the Intent so a cold
start lands there too. Channel links are new (/?channel=<idx>); DM links reuse
the conversation parameter /dm already accepted.

Two faults that made notifications look unreliable while the app was awake:

- A message on the channel last left on screen never notified at all. A channel
  now counts as open only while the document is visible.
- The first checkAndNotify() compared against zeroed baselines, so a page
  loading in the background announced its whole backlog as new ("New: 492
  channels"). The first pass now only takes the measurement.

The count-only notification stays as the fallback for messages the socket never
delivered; the socket path advances its baselines so the same message is not
announced twice.

dm.js's checkDmNotifications() is removed rather than updated: dm.html never
loads app.js, so its areNotificationsEnabled() guard returned on the first line
in every context it ran in, and it would now be a second, vaguer alert for a
message app.js already announces in full.

Verified against the local container with a Playwright harness (14 checks):
content, collapsing, mute and own-message suppression, taps on a running page,
cold starts on both link kinds, and no stray count-only alerts.

Background delivery on Android is a separate, architectural problem and is
untouched here - the wrapper still has no foreground service, so a frozen
process still stops notifications altogether.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-08-27 16:04:44 +02:00
1 parent 98f2afff7e
commit 2a78d3fed3
7 files changed
+383 -120

No files matched your search

@@ -32,14 +32,20 @@
resolve(permission);
};
/** Called from Kotlin when the user taps a notification. */
/**
* Called from Kotlin when the user taps a notification. Returns whether the
* page could act on it - a page that has reloaded since no longer knows the
* notification, and the wrapper navigates to its target instead.
*/
window.__mcNotifyClicked = function (tag) {
var notification = live[tag];
if (!notification || typeof notification.onclick !== 'function') return;
if (!notification || typeof notification.onclick !== 'function') return false;
try {
notification.onclick.call(notification);
return true;
} catch (e) {
console.error('mc-webui: notification onclick failed', e);
return false;
}
};
@@ -51,6 +57,10 @@
this.badge = options.badge;
this.silent = !!options.silent;
this.requireInteraction = !!options.requireInteraction;
// Where a tap should land. Android has to be told at post time: the tap
// may arrive long after this page is gone, and the Intent is the only
// thing that outlives it.
this.data = options.data || null;
// A tag replaces the previous notification with the same name, so an
// untagged one needs its own id rather than silently replacing another
this.tag = options.tag ? String(options.tag) : 'mc-auto-' + (nextTag++);
@@ -60,9 +70,11 @@
this.onerror = null;
this.onshow = null;
var url = (this.data && typeof this.data.url === 'string') ? this.data.url : '';
live[this.tag] = this;
try {
bridge.notify(this.tag, this.title, this.body, this.silent);
bridge.notify(this.tag, this.title, this.body, this.silent, url);
} catch (e) {
console.error('mc-webui: native notify failed', e);
}
@@ -48,6 +48,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
import java.net.URI
/**
* A thin wrapper around the user's own mc-webui instance: one screen to enter
@@ -114,7 +115,11 @@ class MainActivity : AppCompatActivity() {
})
val savedUrl = prefs.getString(KEY_URL, null)
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl)
// Started by a notification tap: open the conversation it named rather
// than wherever the app happened to be left
val tapped = intent.getStringExtra(EXTRA_CLICKED_URL)?.let { resolveTarget(it) }
intent.removeExtra(EXTRA_CLICKED_URL)
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(tapped ?: savedUrl)
}
/**
@@ -132,16 +137,54 @@ class MainActivity : AppCompatActivity() {
}
/**
* A notification tap on a running app lands here rather than in [onCreate].
* When the app was not running, simply being launched is the whole point of
* the tap, so there is nothing extra to deliver.
* A notification tap on a running app lands here rather than in [onCreate],
* which handles the cold-start case instead.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val tag = intent.getStringExtra(EXTRA_CLICKED_TAG) ?: return
val tag = intent.getStringExtra(EXTRA_CLICKED_TAG)
val url = intent.getStringExtra(EXTRA_CLICKED_URL)
intent.removeExtra(EXTRA_CLICKED_TAG)
callJs("window.__mcNotifyClicked", tag)
intent.removeExtra(EXTRA_CLICKED_URL)
if (tag == null && url == null) return
deliverNotificationTap(tag, url)
}
/**
* A tap goes to the page first: it still holds the notification and can
* switch conversation without reloading anything. Only when it no longer
* recognises the notification - the page has reloaded since, or the whole
* WebView was rebuilt - does the wrapper navigate there itself.
*/
private fun deliverNotificationTap(tag: String?, url: String?) {
val target = url?.let { resolveTarget(it) }
if (tag == null) {
target?.let { webView.loadUrl(it) }
return
}
val call = "typeof window.__mcNotifyClicked === 'function' && " +
"window.__mcNotifyClicked(${JSONObject.quote(tag)})"
webView.evaluateJavascript(call) { handled ->
if (handled != "true" && target != null) webView.loadUrl(target)
}
}
/**
* Turns a notification's target into an address to load. The page hands
* over a path within its own site; anything else is refused, because the
* wrapper follows the address the user typed and a notification is not the
* place to change it.
*/
private fun resolveTarget(path: String): String? {
if (!path.startsWith("/") || path.startsWith("//")) return null
val base = prefs.getString(KEY_URL, null)
if (base.isNullOrEmpty()) return null
return try {
URI(base).resolve(path).toString()
} catch (e: Exception) {
null
}
}
// ---------------------------------------------------------------- screens
@@ -519,9 +562,8 @@ class MainActivity : AppCompatActivity() {
}
@JavascriptInterface
fun notify(tag: String, title: String, body: String, silent: Boolean) = runOnUiThread {
postNotification(tag, title, body, silent)
}
fun notify(tag: String, title: String, body: String, silent: Boolean, url: String) =
runOnUiThread { postNotification(tag, title, body, silent, url) }
@JavascriptInterface
fun close(tag: String) = runOnUiThread {
@@ -544,7 +586,13 @@ class MainActivity : AppCompatActivity() {
// areNotificationsEnabled() is false whenever POST_NOTIFICATIONS is missing,
// so the guard below is the permission check - lint just cannot see that
@SuppressLint("MissingPermission")
private fun postNotification(tag: String, title: String, body: String, silent: Boolean) {
private fun postNotification(
tag: String,
title: String,
body: String,
silent: Boolean,
url: String
) {
val manager = NotificationManagerCompat.from(this)
// Covers both the runtime permission and the channel being switched off
if (!manager.areNotificationsEnabled()) return
@@ -554,6 +602,7 @@ class MainActivity : AppCompatActivity() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(EXTRA_CLICKED_TAG, tag)
if (url.isNotEmpty()) putExtra(EXTRA_CLICKED_URL, url)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
@@ -669,6 +718,7 @@ class MainActivity : AppCompatActivity() {
private const val CHANNEL_ID = "mc_webui_activity"
private const val NOTIFICATION_ID = 1
private const val EXTRA_CLICKED_TAG = "clicked_tag"
private const val EXTRA_CLICKED_URL = "clicked_url"
/**
* The page's own background colour, for painting the system bar strips.
+300 -42
View File
@@ -614,6 +614,7 @@ function startResyncHeartbeat() {
* the wrapper says so itself.
*/
window.__mcAppResumed = function() {
notificationBacklog.clear();
if (chatSocket && !chatSocket.connected) chatSocket.connect();
resyncFromServer('app resumed');
};
@@ -676,11 +677,21 @@ function connectChatSocket() {
// Reorder: move this channel to the top of its tier in sidebar + dropdown
moveChannelToTopOfTier(data.channel_idx);
// Update unread count for this channel
if (data.channel_idx !== currentChannelIdx) {
const onOpenChannel = data.channel_idx === currentChannelIdx;
if (!onOpenChannel) {
unreadCounts[data.channel_idx] = (unreadCounts[data.channel_idx] || 0) + 1;
updateUnreadBadges();
checkAndNotify();
} else if (!currentArchiveDate) {
}
// A message on the channel already on screen still deserves a
// notification while the app is away: a channel only counts as open
// while somebody is actually looking at it
if (!data.is_own && (!onOpenChannel || document.visibilityState === 'hidden')) {
notifyChannelMessage(data);
}
// Announced in full above, so the count-only fallback must not
// announce the same message a second time
syncNotifyBaselines();
if (onOpenChannel && !currentArchiveDate) {
// Refresh sidebar preview even for the active channel
updateChannelSidebarBadges();
// Skip own messages — already appended optimistically on send
@@ -689,8 +700,9 @@ function connectChatSocket() {
appendMessageFromSocket(data);
}
} else if (data.type === 'dm') {
if (!data.is_own) notifyDmMessage(data);
// Update DM badge on main page
checkDmUpdates();
checkDmUpdates().then(syncNotifyBaselines);
}
});
@@ -750,6 +762,17 @@ document.addEventListener('DOMContentLoaded', async function() {
currentChannelIdx = parseInt(savedChannel);
}
// A notification tap says which conversation the user meant to open, and
// outranks whatever they were last looking at. The channel has to be picked
// before anything paints; the panels wait until the interface is up.
const notificationTarget = takeNotificationParamsFromUrl();
const targetChannel = notificationTarget.get('channel');
if (targetChannel !== null && /^\d+$/.test(targetChannel)) {
currentChannelIdx = parseInt(targetChannel, 10);
localStorage.setItem('mc_active_channel', currentChannelIdx);
notificationTarget.delete('channel');
}
// Setup event listeners and emoji picker early (sync, fast)
setupEventListeners();
setupEmojiPicker();
@@ -828,6 +851,10 @@ document.addEventListener('DOMContentLoaded', async function() {
console.log(`[init] UI ready in ${(performance.now() - initStart).toFixed(0)}ms`);
// A notification tap that started the app from cold: the interface is up,
// so the panel it pointed at can be opened now
applyNotificationTarget(notificationTarget);
// DEFERRED: Check for updates AFTER messages are displayed
// This updates the unread badges without blocking initial load
checkForUpdates(); // No await - runs in background
@@ -859,6 +886,10 @@ document.addEventListener('visibilitychange', function() {
return;
}
// Back in front of the user, so the pile of unseen messages behind each
// notification tag starts again from zero
notificationBacklog.clear();
// App became visible again, force viewport recalculation
console.log('App became visible, recalculating viewport');
setTimeout(() => {
@@ -4764,7 +4795,83 @@ async function saveChannelScope() {
}
/**
* Send browser notification when new messages arrive
* Post one notification through the Web Notifications API.
*
* In the Android wrapper `window.Notification` is a stand-in over Android's own
* notifications (the wrapper's notification_shim.js), so `options.data.url` is
* the only way a tap can say where it wants to land: the shim hands that url to
* the notification's Intent, and a cold start reads it back from there.
*
* @param {Object} opts
* @param {string} opts.tag - Replaces the notification carrying the same tag
* @param {string} opts.title
* @param {string} opts.body
* @param {string} [opts.url] - Where a tap should take the user
*/
function postNotification({ tag, title, body, url }) {
try {
const notification = new Notification(title, {
body: body,
icon: '/static/images/android-chrome-192x192.png',
badge: '/static/images/android-chrome-192x192.png',
tag: tag,
requireInteraction: false, // Auto-dismiss after ~5s
silent: false,
data: url ? { url: url } : undefined
});
notification.onclick = function() {
window.focus();
notification.close();
if (url) openNotificationTarget(url);
};
} catch (error) {
console.error('Error sending notification:', error);
}
}
/**
* How many messages piled up behind each notification tag while the app was
* away. A tag replaces its predecessor, so without counting them the last
* notification standing would claim to be the only one that ever arrived.
*/
const notificationBacklog = new Map();
/**
* Notify about a single incoming message, naming who wrote it and where.
*
* @param {Object} opts
* @param {string} opts.tag - One per conversation, so a busy channel collapses
* into a single notification instead of a stack
* @param {string} opts.title - Channel name, or the sender's name for a DM
* @param {string} opts.body - The message itself
* @param {string} opts.url - Where a tap should land
*/
function notifyIncomingMessage({ tag, title, body, url }) {
if (!areNotificationsEnabled() || document.visibilityState !== 'hidden') {
return;
}
const count = (notificationBacklog.get(tag) || 0) + 1;
notificationBacklog.set(tag, count);
postNotification({
tag: tag,
title: count > 1 ? t('notify.title_count', { name: title, count: count }) : title,
body: body,
url: url
});
}
/**
* Send browser notification when new messages arrive.
*
* The fallback for messages the socket never delivered - checkForUpdates()
* finding them by polling knows counts and nothing else. Anything that did
* arrive over the socket is announced by notifyIncomingMessage() instead, which
* has the sender and the text to show.
*
* @param {number} channelCount - Number of channels with new messages
* @param {number} dmCount - Number of DMs with new messages
* @param {number} pendingCount - Number of pending contacts
@@ -4792,25 +4899,146 @@ function sendBrowserNotification(channelCount, dmCount, pendingCount) {
message = t('notify.new', { parts: parts.join(', ') });
try {
const notification = new Notification('mc-webui', {
body: message,
icon: '/static/images/android-chrome-192x192.png',
badge: '/static/images/android-chrome-192x192.png',
tag: 'mc-webui-updates', // Prevents spam - replaces previous notification
requireInteraction: false, // Auto-dismiss after ~5s
silent: false
});
// Only a pending-contacts-only alert has one obvious place to land
const url = (pendingCount > 0 && channelCount === 0 && dmCount === 0)
? '/?contacts=pending'
: undefined;
// Click handler - bring app to focus
notification.onclick = function() {
window.focus();
notification.close();
};
postNotification({
tag: 'mc-webui-updates', // Prevents spam - replaces previous notification
title: 'mc-webui',
body: message,
url: url
});
}
} catch (error) {
console.error('Error sending notification:', error);
/** Longest stretch of message text a notification body carries. */
const NOTIFICATION_BODY_MAX = 140;
/**
* Announce one channel message: the channel on top, "who: what" underneath.
*
* @param {Object} data - A 'new_message' payload of type 'channel'
*/
function notifyChannelMessage(data) {
if (mutedChannels.has(data.channel_idx)) return;
const channel = (availableChannels || []).find(ch => ch && ch.index === data.channel_idx);
notifyIncomingMessage({
tag: `mc-channel-${data.channel_idx}`,
title: channel ? channel.name : `#${data.channel_idx}`,
body: `${data.sender}: ${makeChannelPreview(data.content, NOTIFICATION_BODY_MAX)}`,
url: `/?channel=${data.channel_idx}`
});
}
/**
* Announce one direct message: the sender on top, the message underneath.
*
* @param {Object} data - A 'new_message' payload of type 'dm'
*/
function notifyDmMessage(data) {
const pubkey = data.contact_pubkey || '';
notifyIncomingMessage({
tag: `mc-dm-${pubkey || data.sender}`,
title: data.sender,
body: makeChannelPreview(data.content, NOTIFICATION_BODY_MAX),
url: pubkey ? `/?dm=pk_${encodeURIComponent(pubkey)}` : '/'
});
}
// =============================================================================
// Notification targets - where a tapped notification lands
// =============================================================================
/**
* Read a notification's deep link off the current URL and strip it again, so a
* later reload does not drag the user back to the same conversation.
*
* @returns {URLSearchParams} the parameters that were on the URL
*/
function takeNotificationParamsFromUrl() {
const params = new URLSearchParams(window.location.search);
if (params.has('channel') || params.has('dm') || params.has('contacts')) {
const url = new URL(window.location.href);
['channel', 'dm', 'contacts'].forEach(key => url.searchParams.delete(key));
window.history.replaceState({}, '', url);
}
return params;
}
/**
* Take the user where a notification points. A target on this page is handled
* in place - navigating would drop the socket and rebuild the whole interface.
*
* @param {string} url - Root-relative or absolute url from the notification
*/
function openNotificationTarget(url) {
let target;
try {
target = new URL(url, window.location.origin);
} catch (error) {
console.error('Unusable notification target:', url, error);
return;
}
if (target.pathname !== window.location.pathname) {
window.location.href = target.href;
return;
}
applyNotificationTarget(target.searchParams);
}
/**
* Act on a notification's deep link: switch channel, or open the panel it
* points at.
*
* @param {URLSearchParams} params
* @returns {boolean} whether anything was recognised
*/
function applyNotificationTarget(params) {
const channel = params.get('channel');
if (channel !== null && /^\d+$/.test(channel)) {
const idx = parseInt(channel, 10);
const known = (availableChannels || []).find(ch => ch && ch.index === idx);
selectChannelFromDropdown(idx, known ? known.name : `#${idx}`);
return true;
}
const conversation = params.get('dm');
if (conversation) {
openDmConversation(conversation);
return true;
}
if (params.get('contacts') === 'pending') {
openPendingContacts();
return true;
}
return false;
}
/**
* Open the DM panel on one conversation. The panel is an iframe, so the
* conversation travels the same way a direct visit to /dm would carry it.
*
* @param {string} conversationId - e.g. 'pk_<public key>'
*/
function openDmConversation(conversationId) {
const frame = document.getElementById('dmFrame');
if (frame) frame.src = `/dm?conversation=${encodeURIComponent(conversationId)}`;
const modal = document.getElementById('dmModal');
if (modal) bootstrap.Modal.getOrCreateInstance(modal).show();
}
/**
* Open the contacts panel on the contacts waiting for approval.
*/
function openPendingContacts() {
const frame = document.getElementById('contactsFrame');
if (frame) frame.src = '/contacts/pending';
const modal = document.getElementById('contactsModal');
if (modal) bootstrap.Modal.getOrCreateInstance(modal).show();
}
/**
@@ -4821,45 +5049,75 @@ let previousDmUnread = 0;
let previousPendingCount = 0;
/**
* Check if we should send notification based on count changes
* Whether the counters above have ever been filled in. Until they have, every
* unread message the page starts with looks like an arrival, and a page that
* loads in the background would announce a backlog nobody just sent.
*/
function checkAndNotify() {
// Calculate current totals (exclude muted channels)
let currentTotalUnread = 0;
let notifyBaselinesReady = false;
/**
* The three counters notifications are derived from, as they stand right now.
*
* @returns {{channels: number, dms: number, pending: number}}
*/
function currentNotifyCounts() {
// Channel unread (exclude muted channels)
let channels = 0;
for (const [idx, count] of Object.entries(unreadCounts)) {
if (!mutedChannels.has(parseInt(idx))) {
currentTotalUnread += count;
channels += count;
}
}
// Get DM unread count from badge
const dmBadge = document.querySelector('.fab-badge-dm');
const currentDmUnread = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0;
const dms = dmBadge ? parseInt(dmBadge.textContent) || 0 : 0;
// Get pending contacts count from badge (forced to 0 when notifications are suppressed)
const pendingBadge = document.querySelector('.fab-badge-pending');
const rawPendingCount = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0;
const currentPendingCount = window.contactsSettings?.suppress_advert_notifications
? 0 : rawPendingCount;
const rawPending = pendingBadge ? parseInt(pendingBadge.textContent) || 0 : 0;
const pending = window.contactsSettings?.suppress_advert_notifications ? 0 : rawPending;
return { channels, dms, pending };
}
/**
* Move the baselines checkAndNotify() compares against up to the current counts
* without notifying, so a message already announced in full by
* notifyIncomingMessage() is not announced a second time as a bare count.
*/
function syncNotifyBaselines() {
const counts = currentNotifyCounts();
previousTotalUnread = counts.channels;
previousDmUnread = counts.dms;
previousPendingCount = counts.pending;
// Deliberately not marking the baselines ready: unreadCounts only holds
// real numbers once checkForUpdates() has filled it in, and until then a
// socket message would arm the fallback against a near-empty count.
}
/**
* Check if we should send notification based on count changes
*/
function checkAndNotify() {
const counts = currentNotifyCounts();
// Detect increases (new messages/contacts)
const channelIncrease = currentTotalUnread > previousTotalUnread;
const dmIncrease = currentDmUnread > previousDmUnread;
const pendingIncrease = currentPendingCount > previousPendingCount;
// Send notification if ANY category increased
if (channelIncrease || dmIncrease || pendingIncrease) {
const channelDelta = channelIncrease ? (currentTotalUnread - previousTotalUnread) : 0;
const dmDelta = dmIncrease ? (currentDmUnread - previousDmUnread) : 0;
const pendingDelta = pendingIncrease ? (currentPendingCount - previousPendingCount) : 0;
const channelDelta = Math.max(0, counts.channels - previousTotalUnread);
const dmDelta = Math.max(0, counts.dms - previousDmUnread);
const pendingDelta = Math.max(0, counts.pending - previousPendingCount);
// Send notification if ANY category increased. The first pass only takes
// the measurement: what is already unread at that point is history, not news.
if (notifyBaselinesReady && (channelDelta || dmDelta || pendingDelta)) {
sendBrowserNotification(channelDelta, dmDelta, pendingDelta);
}
// Update previous counts
previousTotalUnread = currentTotalUnread;
previousDmUnread = currentDmUnread;
previousPendingCount = currentPendingCount;
previousTotalUnread = counts.channels;
previousDmUnread = counts.dms;
previousPendingCount = counts.pending;
notifyBaselinesReady = true;
}
/**
-55
View File
@@ -715,9 +715,6 @@ async function loadConversations() {
if (convData.success) {
dmConversations = convData.conversations || [];
populateConversationSelector();
// Check for new DM notifications
checkDmNotifications(dmConversations);
} else {
console.error('Failed to load conversations:', convData.error);
// Still populate selector with just contacts
@@ -2051,58 +2048,6 @@ function showNotification(message, type = 'info') {
toast.show();
}
// ============================================================================
// PWA Notifications for DM
// ============================================================================
/**
* Track previous DM unread for notifications
*/
let previousDmTotalUnread = 0;
/**
* Check if we should send DM notification
*/
function checkDmNotifications(conversations) {
// Only check if notifications are enabled
// areNotificationsEnabled is defined in app.js and should be available globally
if (typeof areNotificationsEnabled === 'undefined' || !areNotificationsEnabled()) {
return;
}
if (document.visibilityState !== 'hidden') {
return;
}
// Calculate total DM unread
const currentDmTotalUnread = conversations.reduce((sum, conv) => sum + conv.unread_count, 0);
// Detect increase
if (currentDmTotalUnread > previousDmTotalUnread) {
const delta = currentDmTotalUnread - previousDmTotalUnread;
try {
const notification = new Notification('mc-webui', {
body: tn('dm.notify.new_messages', delta),
icon: '/static/images/android-chrome-192x192.png',
badge: '/static/images/android-chrome-192x192.png',
tag: 'mc-webui-dm',
requireInteraction: false,
silent: false
});
notification.onclick = function() {
window.focus();
notification.close();
};
} catch (error) {
console.error('Error sending DM notification:', error);
}
}
previousDmTotalUnread = currentDmTotalUnread;
}
// =============================================================================
// DM Chat Filter Functionality
// =============================================================================
+1 -4
View File
@@ -510,10 +510,6 @@
"dm.load_messages_error": "Error loading messages",
"dm.msg_input_ph": "Message {name}...",
"dm.no_contact_info": "No contact information available.",
"dm.notify.new_messages": {
"one": "New private message: {count}",
"other": "New private messages: {count}"
},
"dm.select_chat_ph": "Select chat...",
"dm.sidebar.search_ph": "Search contacts...",
"dm.status.delivered": "Delivered",
@@ -605,6 +601,7 @@
"one": "{count} pending contact",
"other": "{count} pending contacts"
},
"notify.title_count": "{name} ({count})",
"observer.add_broker": "Add broker",
"observer.confirm.delete": "Delete broker \"{name}\"?",
"observer.counters": "packets captured: <strong>{seen}</strong>, published: <strong>{published}</strong>",
+1 -6
View File
@@ -530,12 +530,6 @@
"dm.load_messages_error": "Błąd wczytywania wiadomości",
"dm.msg_input_ph": "Wiadomość do {name}...",
"dm.no_contact_info": "Brak informacji o kontakcie.",
"dm.notify.new_messages": {
"one": "Nowa wiadomość prywatna: {count}",
"few": "Nowe wiadomości prywatne: {count}",
"many": "Nowych wiadomości prywatnych: {count}",
"other": "Nowe wiadomości prywatne: {count}"
},
"dm.select_chat_ph": "Wybierz rozmowę...",
"dm.sidebar.search_ph": "Szukaj kontaktów...",
"dm.status.delivered": "Dostarczono",
@@ -637,6 +631,7 @@
"many": "{count} oczekujących kontaktów",
"other": "{count} oczekującego kontaktu"
},
"notify.title_count": "{name} ({count})",
"observer.add_broker": "Dodaj brokera",
"observer.confirm.delete": "Usunąć brokera \"{name}\"?",
"observer.counters": "przechwycone pakiety: <strong>{seen}</strong>, opublikowane: <strong>{published}</strong>",
+6
View File
@@ -16,12 +16,18 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### Features
- **Notifications now say who wrote what, where — and tapping one takes you there.** A new message used to produce the same line every time, *New: 1 channel*, with the app's own name on top and nothing else: not the sender, not the text, not even which channel it landed on. Tapping it did no more than bring the app forward, wherever it happened to have been left. Everything needed was already arriving with the message and simply was not being used. A channel message now shows the channel as the title and `sender: message` underneath, a direct message shows the sender's name and the message itself, and each conversation keeps its own notification instead of one shared line, so a busy channel cannot bury a DM. When several messages arrive in the same place while you are away they collapse into one notification carrying the newest text, with a count beside the name — *#krakow (4)* — rather than replacing each other silently. Tapping opens the conversation it is about: the channel, or the direct-message panel on that contact. That works whether the app was already running or started from cold, in the Android app and in the browser alike, and it reaches the right place without reloading the interface when the app was merely in the background.
- **An instance you can hand to strangers: demo mode.** mc-webui has never had a login, which is the right trade on a home network and the wrong one the moment you share the address — to show the project off, to collect feedback, or because a store wants a dozen testers who use the app for a fortnight. What happens next is predictable: visitors assume the node is theirs and rename it, and the console is right there for anyone curious enough to type into it. Putting `MC_DEMO=true` in `.env` turns the instance read-only for everybody except you. Everything that reconfigures the radio is refused — the name, the coordinates, the frequency and the rest of the radio settings, reboot, advert and flood advert — along with everything that deletes data, everything that reaches other people's repeaters, the update trigger that rebuilds the containers, the settings that publish packets to an external broker, and the handful of things that hand out secrets such as stored repeater passwords, database backups and the server log. The console stays open for the commands that only read, so it is still worth demonstrating, and refuses the rest with a list of what it will accept. What does keep working is the part that makes a demo worth visiting: reading everything, sending messages in channels and as DMs, and changing the theme and layout, which live in your browser and affect nobody else. The refusal happens on the server, in front of every endpoint and on the console's own connection, so a stale page or a crafted request gets the same answer as a click. In the interface the locked controls are greyed out rather than hidden — the point is to show mc-webui off, and a feature nobody can see impresses nobody — with a yellow **DEMO** badge next to the version, a short explanation at the top of Settings and above the console, and a message rather than a silent shrug when something is clicked. Full guide in [demo-mode.md](demo-mode.md).
- **Two ways to keep full access to your own demo.** Locking the instance is only half of it; you still need to administer the thing. `MC_DEMO_TRUSTED_NETS` takes a list of networks — `192.168.0.0/16`, say — whose visitors are never restricted, so from your own LAN the app behaves exactly as it always has, with nothing to type. `MC_DEMO_UNLOCK_CODE` sets a code you enter once under **Settings → Operator access**; the browser is then remembered for thirty days from any network, which is what you want when the instance is published through a tunnel and every visitor looks like it is coming from the same place. A *Lock again* button sits in the same spot for when you want to see the instance the way a visitor does. The cookie holds a derived token rather than the code itself, so changing the code in `.env` logs every unlocked browser out — which is also how you take access back. One warning worth repeating from the guide: if mc-webui is published through a tunnel or a reverse proxy, every visitor arrives from that one address, and if the tunnel runs on your LAN then listing your LAN as trusted would unlock the instance for the whole internet. mc-webui declines to make that mistake — an address that arrives with a proxy header is not matched against the trusted list unless `MC_TRUST_PROXY` is on — and says so in the log at startup, but the safe habit is to use the unlock code whenever the app is not reached directly.
### Fixes
- **New messages on the channel you had open no longer arrive in silence, and old unread ones stop announcing themselves as new.** Two separate faults made notifications look unreliable even while the app was awake and connected. The first: a message on whichever channel you last had on screen was quietly excluded from notifications, on the assumption that you were looking at it — which is exactly wrong once the app is in the background, and meant that anyone who left mc-webui on their busiest channel was told about every channel except that one. A channel now counts as open only while somebody is actually looking at it. The second: the first time the app compared its unread counts against the server it treated the entire backlog as having just arrived, so a page that loaded in the background could announce *New: 492 channels* about messages that were days old. That first comparison is now only a measurement, and anything already unread when the app starts is treated as history rather than news.
- **Still unresolved: notifications going quiet after a while on Android.** The changes above fix what a notification says and where it takes you, but not the separate problem of the Android app falling silent some minutes after you switch away from it. That has a different cause — Android freezes apps that have nothing running in the foreground, which stops the connection the notifications depend on — and no setting on the phone can prevent it. Fixing it properly needs a background service in the app itself, which is planned for a later release.
- **Syncing a repeater's clock now uses the server's time, and your own node's clock is kept right by itself.** Pressing *Sync clock* under **My Repeaters → Actions** could set a repeater to a time that was wrong by an hour or more, and the time it reported back rarely matched what the same panel showed a minute later. Two separate things were behind it. The command mc-webui sent asked the repeater to copy the timestamp out of the packet that carried the command — and that timestamp comes from your own node, not from the server, because the firmware deliberately stamps command packets with its own clock. Your node's clock, meanwhile, is only ever set by hand and then left to run free, and on boards without a dedicated timekeeping crystal it drifts badly: the test device here turned out to be sixteen hours behind. So the button was faithfully copying a wrong time onto the repeater. mc-webui now puts the moment inside the command itself, taken from the server's clock and untouched by anything in between, and it checks your node against the server when it connects and once an hour after that, correcting it whenever it has drifted more than half a minute. One limit is worth knowing, because nothing in the app can work around it: the firmware refuses to move any clock backwards, so a node or repeater that has run *fast* cannot be corrected over the air at all and needs a restart before it will accept the right time. When that applies to your own node it is now written to the log instead of being retried in silence every hour.
- **Clocks now say which timezone they are showing.** The same instant was being written three different ways with nothing on screen to tell them apart: the *Clock* row under **My Repeaters → Status** is drawn in your browser's timezone, the console printed the server's, and repeaters answer their own `clock` command in UTC. Reading 17:16 from one and 19:16 from another, minutes apart, is enough to make a perfectly healthy setup look broken. The Status row now names the zone alongside the time (`23.08.2026, 21:21:01 CEST`), and the console prints both readings at once (`2026-08-23 21:15:50 CEST / 19:15:50 UTC`), so a console reading can be held against a repeater's own reply without doing the arithmetic first.