Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 30 additions & 30 deletions lxc/sparse-cone.txt
Original file line number Diff line number Diff line change
@@ -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
# 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
8 changes: 4 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5415,8 +5415,8 @@ const AppWithToast = () => {
<SettingsProvider baseUrl={initialBaseUrl}>
<MapProvider>
<DataProvider>
<MessagingProvider baseUrl={initialBaseUrl}>
<UIProvider>
<UIProvider>
<MessagingProvider baseUrl={initialBaseUrl}>
<AutomationProvider baseUrl={initialBaseUrl}>
<ToastProvider>
<DeviceNotificationToaster />
Expand All @@ -5425,8 +5425,8 @@ const AppWithToast = () => {
</SaveBarProvider>
</ToastProvider>
</AutomationProvider>
</UIProvider>
</MessagingProvider>
</MessagingProvider>
</UIProvider>
</DataProvider>
</MapProvider>
</SettingsProvider>
Expand Down
4 changes: 2 additions & 2 deletions src/components/HopCountDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
}
return (
<>
<span style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 0.7 }} title={t('messages.signal_info')}>
<span style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 0.9 }} title={t('messages.signal_info')}>
({parts.join(' / ')})
</span>
{StoreForwardIndicator}
Expand All @@ -103,7 +103,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
return (
<>
<span
style={{ fontSize: '0.75em', marginLeft: '4px', opacity: isClickable ? 1 : 0.7, ...clickableStyle }}
style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 1, ...clickableStyle }}
onClick={isClickable ? onClick : undefined}
title={isClickable ? t('messages.click_for_relay') : undefined}
>
Expand Down
7 changes: 6 additions & 1 deletion src/contexts/MessagingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -45,6 +46,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({ 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<string>('');
const [selectedChannel, setSelectedChannel] = useState<number>(-1);
Expand All @@ -55,11 +57,14 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({ 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 });

Expand Down
6 changes: 5 additions & 1 deletion src/db/repositories/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/hooks/useUnreadCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<UnreadCountsData> => {
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',
Expand Down
11 changes: 4 additions & 7 deletions src/server/constants/autoFavorite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const AUTO_FAVORITE_LOCAL_ROLES: Set<number> = 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<number> = new Set([
DeviceRole.ROUTER,
DeviceRole.ROUTER_LATE,
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/server/meshtasticManager.autoFavorite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/services/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -8082,6 +8083,7 @@ class DatabaseService {
AND m.portnum = 1
${fromClause}
${sourceClause}
${mqttClause}
GROUP BY m.channel
`);

Expand Down Expand Up @@ -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);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/styles/messages.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down