diff --git a/lxc/sparse-cone.txt b/lxc/sparse-cone.txt
index 2225a0778..d61936665 100644
--- a/lxc/sparse-cone.txt
+++ b/lxc/sparse-cone.txt
@@ -1,30 +1,30 @@
-# lxc/sparse-cone.txt
-# ---------------------------------------------------------------------------
-# Sparse-checkout cone list for the MeshMonitor LXC template build.
-#
-# WHAT THIS FILE CONTROLS
-# build-lxc-template.sh reads this file at build time and passes the listed
-# directories to git sparse-checkout set. Only these directories, plus
-# all root-level files (package.json, tsconfig*.json, vite.config.ts, etc.),
-# which cone mode always includes automatically, are materialized inside
-# /opt/meshmonitor in the container rootfs. Everything else (docs/, desktop/,
-# .github/, etc.) is never fetched, keeping the template lean (~8MB .git vs
-# ~51MB for a full clone).
-#
-# MAINTENANCE
-# If you add a new top-level directory that is required at runtime, add it
-# here or the LXC template will silently omit those files on the next build.
-# After updating: rebuild the template and confirm meshmonitor-update works.
-#
-# FOR AI ASSISTANTS
-# This is the single source of truth for LXC template directory inclusion.
-# Update this file in the same commit whenever you add a runtime-required
-# top-level directory. See CLAUDE.md "LXC Template Build" for the full rule.
-# ---------------------------------------------------------------------------
-
-src
-public
-docker
-protobufs
-scripts
-lxc
\ No newline at end of file
+# lxc/sparse-cone.txt
+# ---------------------------------------------------------------------------
+# Sparse-checkout cone list for the MeshMonitor LXC template build.
+#
+# WHAT THIS FILE CONTROLS
+# build-lxc-template.sh reads this file at build time and passes the listed
+# directories to git sparse-checkout set. Only these directories, plus
+# all root-level files (package.json, tsconfig*.json, vite.config.ts, etc.),
+# which cone mode always includes automatically, are materialized inside
+# /opt/meshmonitor in the container rootfs. Everything else (docs/, desktop/,
+# .github/, etc.) is never fetched, keeping the template lean (~8MB .git vs
+# ~51MB for a full clone).
+#
+# MAINTENANCE
+# If you add a new top-level directory that is required at runtime, add it
+# here or the LXC template will silently omit those files on the next build.
+# After updating: rebuild the template and confirm meshmonitor-update works.
+#
+# FOR AI ASSISTANTS
+# This is the single source of truth for LXC template directory inclusion.
+# Update this file in the same commit whenever you add a runtime-required
+# top-level directory. See CLAUDE.md "LXC Template Build" for the full rule.
+# ---------------------------------------------------------------------------
+
+src
+public
+docker
+protobufs
+scripts
+lxc
diff --git a/src/App.tsx b/src/App.tsx
index d0624729c..4753d523b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5415,8 +5415,8 @@ const AppWithToast = () => {
-
-
+
+
@@ -5425,8 +5425,8 @@ const AppWithToast = () => {
-
-
+
+
diff --git a/src/components/HopCountDisplay.tsx b/src/components/HopCountDisplay.tsx
index 8c1e5bff7..02af58042 100644
--- a/src/components/HopCountDisplay.tsx
+++ b/src/components/HopCountDisplay.tsx
@@ -91,7 +91,7 @@ const HopCountDisplay: React.FC = ({
}
return (
<>
-
+
({parts.join(' / ')})
{StoreForwardIndicator}
@@ -103,7 +103,7 @@ const HopCountDisplay: React.FC = ({
return (
<>
diff --git a/src/contexts/MessagingContext.tsx b/src/contexts/MessagingContext.tsx
index f7ffbfea5..7d4570172 100644
--- a/src/contexts/MessagingContext.tsx
+++ b/src/contexts/MessagingContext.tsx
@@ -3,6 +3,7 @@ import { MeshMessage } from '../types/message';
import { useUnreadCounts, useMarkAsRead } from '../hooks/useUnreadCounts';
import { useAuth } from './AuthContext';
import { useSource } from './SourceContext';
+import { useUI } from './UIContext';
interface UnreadCounts {
channels: { [channelId: number]: number };
@@ -45,6 +46,7 @@ export const MessagingProvider: React.FC = ({ children,
// Scope unread counts to the current source so per-source tabs don't show
// badges for messages other sources received but the current source did not.
const { sourceId } = useSource();
+ const { showMqttMessages } = useUI();
const [selectedDMNode, setSelectedDMNode] = useState('');
const [selectedChannel, setSelectedChannel] = useState(-1);
@@ -55,11 +57,14 @@ export const MessagingProvider: React.FC = ({ children,
const [isChannelScrolledToBottom, setIsChannelScrolledToBottom] = useState(true);
const [isDMScrolledToBottom, setIsDMScrolledToBottom] = useState(true);
- // Use TanStack Query hooks for unread counts - only enable when authenticated
+ // Use TanStack Query hooks for unread counts - only enable when authenticated.
+ // excludeMqtt mirrors the user's "Show MQTT/Bridge Messages" preference so the
+ // unread badge only lights up for messages that are actually visible.
const { data: unreadCountsData, refetch: refetchUnreadCounts } = useUnreadCounts({
baseUrl,
enabled: isAuthenticated,
sourceId,
+ excludeMqtt: !showMqttMessages,
});
const { mutateAsync: markAsReadMutation } = useMarkAsRead({ baseUrl });
diff --git a/src/db/repositories/notifications.ts b/src/db/repositories/notifications.ts
index 37615e877..9f45e695a 100644
--- a/src/db/repositories/notifications.ts
+++ b/src/db/repositories/notifications.ts
@@ -832,7 +832,8 @@ export class NotificationsRepository extends BaseRepository {
async getUnreadCountsByChannelAsync(
userId: number | null,
localNodeId?: string,
- sourceId?: string
+ sourceId?: string,
+ excludeMqtt?: boolean
): Promise<{ [channelId: number]: number }> {
const messages = this.tables.messages;
const readMessages = this.tables.readMessages;
@@ -846,6 +847,9 @@ export class NotificationsRepository extends BaseRepository {
if (localNodeId) {
conditions.push(ne(messages.fromNodeId, localNodeId));
}
+ if (excludeMqtt) {
+ conditions.push(or(isNull(messages.viaMqtt), eq(messages.viaMqtt, false)));
+ }
try {
const rows: any[] = await this.db
diff --git a/src/hooks/useUnreadCounts.ts b/src/hooks/useUnreadCounts.ts
index ae579eb59..52cbf469a 100644
--- a/src/hooks/useUnreadCounts.ts
+++ b/src/hooks/useUnreadCounts.ts
@@ -31,6 +31,8 @@ interface UseUnreadCountsOptions {
refetchInterval?: number;
/** Optional source scope — when set, counts are filtered to this source */
sourceId?: string | null;
+ /** When true, MQTT/bridge messages are excluded from unread counts */
+ excludeMqtt?: boolean;
}
/**
@@ -58,12 +60,17 @@ export function useUnreadCounts({
enabled = true,
refetchInterval = 10000,
sourceId = null,
+ excludeMqtt = false,
}: UseUnreadCountsOptions = {}) {
return useQuery({
- queryKey: ['unreadCounts', baseUrl, sourceId],
+ queryKey: ['unreadCounts', baseUrl, sourceId, excludeMqtt],
queryFn: async (): Promise => {
- const url = sourceId
- ? `${baseUrl}/api/messages/unread-counts?sourceId=${encodeURIComponent(sourceId)}`
+ const params = new URLSearchParams();
+ if (sourceId) params.set('sourceId', sourceId);
+ if (excludeMqtt) params.set('excludeMqtt', '1');
+ const query = params.toString();
+ const url = query
+ ? `${baseUrl}/api/messages/unread-counts?${query}`
: `${baseUrl}/api/messages/unread-counts`;
const response = await fetch(url, {
credentials: 'include',
diff --git a/src/server/constants/autoFavorite.ts b/src/server/constants/autoFavorite.ts
index a1ce7e447..27a634491 100644
--- a/src/server/constants/autoFavorite.ts
+++ b/src/server/constants/autoFavorite.ts
@@ -7,7 +7,7 @@ export const AUTO_FAVORITE_LOCAL_ROLES: Set = new Set([
DeviceRole.CLIENT_BASE,
]);
-/** Roles eligible as zero-cost relay favorites (for ROUTER/ROUTER_LATE local) */
+/** Roles eligible as zero-cost relay favorites (for all eligible local roles) */
export const ZERO_HOP_RELAY_ROLES: Set = new Set([
DeviceRole.ROUTER,
DeviceRole.ROUTER_LATE,
@@ -28,8 +28,7 @@ interface AutoFavoriteTarget {
* - Target must be 0-hop (hopsAway === 0)
* - Target must not have been received via MQTT
* - Target must not already be favorited
- * - For ROUTER/ROUTER_LATE local: target must also be ROUTER/ROUTER_LATE/CLIENT_BASE
- * - For CLIENT_BASE local: any role is eligible
+ * - Target must be ROUTER, ROUTER_LATE, or CLIENT_BASE (relay-capable roles for all eligible locals)
*/
export function isAutoFavoriteEligible(
localRole: number | undefined | null,
@@ -47,10 +46,8 @@ export function isAutoFavoriteEligible(
if (target.isFavorite) {
return false;
}
- if (localRole === DeviceRole.ROUTER || localRole === DeviceRole.ROUTER_LATE) {
- if (target.role == null || !ZERO_HOP_RELAY_ROLES.has(target.role)) {
- return false;
- }
+ if (target.role == null || !ZERO_HOP_RELAY_ROLES.has(target.role)) {
+ return false;
}
return true;
}
diff --git a/src/server/meshtasticManager.autoFavorite.test.ts b/src/server/meshtasticManager.autoFavorite.test.ts
index c1dea07c6..f09b9d716 100644
--- a/src/server/meshtasticManager.autoFavorite.test.ts
+++ b/src/server/meshtasticManager.autoFavorite.test.ts
@@ -19,8 +19,12 @@ describe('isAutoFavoriteEligible', () => {
expect(isAutoFavoriteEligible(DeviceRole.ROUTER, { hopsAway: 0, role: DeviceRole.CLIENT, isFavorite: false })).toBe(false);
});
- it('returns true for 0-hop CLIENT when local is CLIENT_BASE (any role eligible)', () => {
- expect(isAutoFavoriteEligible(DeviceRole.CLIENT_BASE, { hopsAway: 0, role: DeviceRole.CLIENT, isFavorite: false })).toBe(true);
+ it('returns false for 0-hop CLIENT when local is CLIENT_BASE (only relay-capable roles eligible)', () => {
+ expect(isAutoFavoriteEligible(DeviceRole.CLIENT_BASE, { hopsAway: 0, role: DeviceRole.CLIENT, isFavorite: false })).toBe(false);
+ });
+
+ it('returns false for 0-hop CLIENT_MUTE when local is CLIENT_BASE', () => {
+ expect(isAutoFavoriteEligible(DeviceRole.CLIENT_BASE, { hopsAway: 0, role: DeviceRole.CLIENT_MUTE, isFavorite: false })).toBe(false);
});
it('returns true for 0-hop ROUTER when local is CLIENT_BASE', () => {
diff --git a/src/server/server.ts b/src/server/server.ts
index 0be8b8821..9ace1e23f 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -2600,6 +2600,7 @@ apiRouter.get('/messages/unread-counts', optionalAuth(), async (req, res) => {
const unreadSourceId = typeof req.query.sourceId === 'string' && req.query.sourceId.length > 0
? req.query.sourceId
: undefined;
+ const excludeMqtt = req.query.excludeMqtt === '1' || req.query.excludeMqtt === 'true';
const unreadManager = resolveSourceManager(unreadSourceId);
const localNodeInfo = unreadManager.getLocalNodeInfo();
@@ -2632,7 +2633,7 @@ apiRouter.get('/messages/unread-counts', optionalAuth(), async (req, res) => {
// Get channel unread counts if user has channels permission
// Only count incoming messages (exclude messages sent by our node)
if (hasChannelsRead) {
- const rawCounts = await databaseService.getUnreadCountsByChannelAsync(userId, localNodeInfo?.nodeId, unreadSourceId);
+ const rawCounts = await databaseService.getUnreadCountsByChannelAsync(userId, localNodeInfo?.nodeId, unreadSourceId, excludeMqtt);
// MM-SEC-3: filter by per-channel read permission as well as mute prefs.
// The bare `channel_0:read` gate above lets a viewer reach this handler
diff --git a/src/services/database.ts b/src/services/database.ts
index 615d49f3d..89c531410 100644
--- a/src/services/database.ts
+++ b/src/services/database.ts
@@ -8060,7 +8060,7 @@ class DatabaseService {
return rows.map(row => row.id);
}
- getUnreadCountsByChannel(userId: number | null, localNodeId?: string, sourceId?: string): {[channelId: number]: number} {
+ getUnreadCountsByChannel(userId: number | null, localNodeId?: string, sourceId?: string, excludeMqtt?: boolean): {[channelId: number]: number} {
// For PostgreSQL/MySQL, use async method via cache or return empty for sync call
if (this.drizzleDbType === 'postgres' || this.drizzleDbType === 'mysql') {
// Sync method can't do async DB query - return empty and let caller use async version
@@ -8072,6 +8072,7 @@ class DatabaseService {
// counts from other sources into this tab.
const fromClause = localNodeId ? 'AND m.fromNodeId != ?' : '';
const sourceClause = sourceId ? 'AND m.sourceId = ?' : '';
+ const mqttClause = excludeMqtt ? 'AND (m.viaMqtt IS NULL OR m.viaMqtt = 0)' : '';
// eslint-disable-next-line no-restricted-syntax -- legacy raw SQL, pending future Drizzle migration batch
const stmt = this.db.prepare(`
SELECT m.channel, COUNT(*) as count
@@ -8082,6 +8083,7 @@ class DatabaseService {
AND m.portnum = 1
${fromClause}
${sourceClause}
+ ${mqttClause}
GROUP BY m.channel
`);
@@ -8134,13 +8136,13 @@ class DatabaseService {
* Async version of getUnreadCountsByChannel for PostgreSQL/MySQL.
* Delegates to NotificationsRepository for Drizzle-based execution on all backends.
*/
- async getUnreadCountsByChannelAsync(userId: number | null, localNodeId?: string, sourceId?: string): Promise<{[channelId: number]: number}> {
+ async getUnreadCountsByChannelAsync(userId: number | null, localNodeId?: string, sourceId?: string, excludeMqtt?: boolean): Promise<{[channelId: number]: number}> {
// For SQLite, use sync version (legacy compatibility)
if (this.drizzleDbType !== 'postgres' && this.drizzleDbType !== 'mysql') {
- return this.getUnreadCountsByChannel(userId, localNodeId, sourceId);
+ return this.getUnreadCountsByChannel(userId, localNodeId, sourceId, excludeMqtt);
}
if (!this.notificationsRepo) return {};
- return this.notificationsRepo.getUnreadCountsByChannelAsync(userId, localNodeId, sourceId);
+ return this.notificationsRepo.getUnreadCountsByChannelAsync(userId, localNodeId, sourceId, excludeMqtt);
}
/**
diff --git a/src/styles/messages.css b/src/styles/messages.css
index 716ab1fb6..1c29d5430 100644
--- a/src/styles/messages.css
+++ b/src/styles/messages.css
@@ -243,17 +243,17 @@
.message-bubble .message-time {
font-size: 0.85rem;
margin-top: 0.25rem;
- opacity: 0.7;
+ opacity: 0.9;
text-align: right;
}
.message-bubble.theirs .message-time {
- color: var(--ctp-subtext0);
+ color: var(--ctp-text);
}
.message-bubble.mine .message-time {
color: var(--ctp-chatBubbleSentText, var(--ctp-accent-text));
- opacity: 0.7;
+ opacity: 0.9;
}
/* Reply button that appears on hover */