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 */