diff --git a/src/App.tsx b/src/App.tsx index d0624729c..8e2fa9284 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -529,6 +529,16 @@ const location = useLocation(); unreadCountsData, } = useMessaging(); + // The poll callback (processPollData) is memoized without unreadCountsData in + // its deps, so bridge the latest filtered counts through a ref. This lets the + // per-channel unread badges honor the "Show MQTT/Bridge Messages" toggle the + // same way the sidebar dot does (#3787) — the dedicated unread query already + // applies excludeMqtt, but the /poll aggregate does not. + const unreadCountsDataRef = useRef(unreadCountsData); + useEffect(() => { + unreadCountsDataRef.current = unreadCountsData; + }, [unreadCountsData]); + // UI context const { activeTab, @@ -2584,12 +2594,16 @@ const location = useLocation(); channelGroups[msg.channel].push(msg); }); - // Update unread counts from backend + // Update unread counts from the dedicated (filtered) unread query rather + // than the raw /poll aggregate, so the per-channel badges respect the + // "Show MQTT/Bridge Messages" toggle just like the sidebar dot (#3787). + // Fall back to the poll payload only until the first dedicated fetch lands. const currentSelected = selectedChannelRef.current; const newUnreadCounts: { [key: number]: number } = {}; - if (data.unreadCounts?.channels) { - Object.entries(data.unreadCounts.channels).forEach(([channelId, count]) => { + const filteredChannelUnreads = unreadCountsDataRef.current?.channels ?? data.unreadCounts?.channels; + if (filteredChannelUnreads) { + Object.entries(filteredChannelUnreads).forEach(([channelId, count]) => { const chId = parseInt(channelId, 10); if (chId === currentSelected) { newUnreadCounts[chId] = 0; @@ -5415,8 +5429,8 @@ const AppWithToast = () => { - - + + @@ -5425,8 +5439,8 @@ const AppWithToast = () => { - - + + diff --git a/src/components/HopCountDisplay.tsx b/src/components/HopCountDisplay.tsx index 8c1e5bff7..47f605c14 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/components/MeshCore/MeshCoreChannelsView.tsx b/src/components/MeshCore/MeshCoreChannelsView.tsx index b632f5aa0..cb4f704c9 100644 --- a/src/components/MeshCore/MeshCoreChannelsView.tsx +++ b/src/components/MeshCore/MeshCoreChannelsView.tsx @@ -611,6 +611,13 @@ export const MeshCoreChannelsView: React.FC = ({ }} onNodeNameClick={onNodeNameClick} conversationKey={`channel-${active.id}`} + maxBytes={ + showScopeOverride && overrideScope !== null && overrideScope !== '' + ? 120 + : resolvedScope + ? 120 + : 130 + } /> diff --git a/src/components/MeshCore/MeshCoreDirectMessagesView.tsx b/src/components/MeshCore/MeshCoreDirectMessagesView.tsx index 7f641ffa6..650c1c781 100644 --- a/src/components/MeshCore/MeshCoreDirectMessagesView.tsx +++ b/src/components/MeshCore/MeshCoreDirectMessagesView.tsx @@ -396,6 +396,7 @@ export const MeshCoreDirectMessagesView: React.FC actions.sendMessage(text, selected)} conversationKey={`dm-${selected}`} + maxBytes={150} /> )}
diff --git a/src/components/MeshCore/MeshCoreMessageStream.tsx b/src/components/MeshCore/MeshCoreMessageStream.tsx index d730c19b5..98c9c1061 100644 --- a/src/components/MeshCore/MeshCoreMessageStream.tsx +++ b/src/components/MeshCore/MeshCoreMessageStream.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { MeshCoreMessage } from './hooks/useMeshCore'; import { MeshCoreContact } from '../../utils/meshcoreHelpers'; import { getMessageDateSeparator, shouldShowDateSeparator } from '../../utils/datetime'; +import { getUtf8ByteLength, formatByteCount } from '../../utils/text'; import LinkPreview from '../LinkPreview'; interface MeshCoreMessageStreamProps { @@ -16,6 +17,8 @@ interface MeshCoreMessageStreamProps { /** Stable key identifying the current conversation. When it changes, the * stream scrolls to the bottom. */ conversationKey?: string; + /** Maximum UTF-8 byte length for a message. Send is blocked when exceeded. */ + maxBytes?: number; } function formatTime(ts: number): string { @@ -60,6 +63,7 @@ export const MeshCoreMessageStream: React.FC = ({ onSend, onNodeNameClick, conversationKey, + maxBytes = 130, }) => { const { t } = useTranslation(); const [draft, setDraft] = useState(''); @@ -164,8 +168,16 @@ export const MeshCoreMessageStream: React.FC = ({ return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); + // Reuse the shared composer byte-count helpers (same as the Meshtastic + // channel/DM composers) so counting + warning thresholds + display stay in + // one place. Only the per-context limit differs (channel 130 / scoped 120 / + // DM 150), passed in via maxBytes. + const byteLen = useMemo(() => getUtf8ByteLength(draft), [draft]); + const byteCounter = useMemo(() => formatByteCount(byteLen, maxBytes), [byteLen, maxBytes]); + const overLimit = byteLen > maxBytes; + const handleSend = async () => { - if (!draft.trim() || sending) return; + if (!draft.trim() || sending || overLimit) return; setSending(true); const ok = await onSend(draft); setSending(false); @@ -338,15 +350,19 @@ export const MeshCoreMessageStream: React.FC = ({ onKeyDown={handleKeyDown} placeholder={t('meshcore.type_message', 'Type a message…')} disabled={disabled || sending} - maxLength={230} />
+ {draft.length > 0 && ( +
+ {byteCounter.text} +
+ )} ); }; diff --git a/src/components/MeshCore/MeshCorePage.css b/src/components/MeshCore/MeshCorePage.css index 2659f1e10..140d6a75e 100644 --- a/src/components/MeshCore/MeshCorePage.css +++ b/src/components/MeshCore/MeshCorePage.css @@ -816,6 +816,19 @@ cursor: not-allowed; } +/* Byte counter below the MeshCore send bar. Reuses the shared .byte-counter + color states (warning/over) from the Meshtastic composer, but overrides the + absolute overlay layout to sit as a block under the input+button row (the + overlay would land on the Send button here). */ +.meshcore-byte-counter.byte-counter { + position: static; + display: block; + text-align: right; + padding: 2px 0.5rem 0 0; + background: none; + opacity: 1; +} + /* Contact detail pane (below the message stream in the DM view). No longer height-capped — the surrounding `.meshcore-main-pane` is the page-level scroll container, so this block flows naturally below the diff --git a/src/contexts/MessagingContext.tsx b/src/contexts/MessagingContext.tsx index f7ffbfea5..6cfafa778 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. + // Exclude MQTT messages from the count when the user has opted to hide them, + // so the sidebar dot and channel badges don't light up for MQTT-only traffic. const { data: unreadCountsData, refetch: refetchUnreadCounts } = useUnreadCounts({ baseUrl, enabled: isAuthenticated, sourceId, + excludeMqtt: !showMqttMessages, }); const { mutateAsync: markAsReadMutation } = useMarkAsRead({ baseUrl }); diff --git a/src/db/repositories/notifications.test.ts b/src/db/repositories/notifications.test.ts index 60f8297ae..7566fb66b 100644 --- a/src/db/repositories/notifications.test.ts +++ b/src/db/repositories/notifications.test.ts @@ -351,6 +351,18 @@ function insertMessageWithSourceSql(backend: TestBackend, id: string, channel: n } } +// SQL to insert a text message flagged as received via MQTT (viaMqtt = true). +// Used to assert the excludeMqtt unread filter (#3787). +function insertMqttMessageSql(backend: TestBackend, id: string, channel: number, timestamp: number): string { + if (backend.dbType === 'sqlite') { + return `INSERT INTO messages (id, fromNodeNum, toNodeNum, fromNodeId, toNodeId, text, channel, portnum, timestamp, createdAt, viaMqtt) VALUES ('${id}', 1, 2, '!node1', '!node2', 'test', ${channel}, 1, ${timestamp}, ${timestamp}, 1)`; + } else if (backend.dbType === 'postgres') { + return `INSERT INTO messages (id, "fromNodeNum", "toNodeNum", "fromNodeId", "toNodeId", text, channel, portnum, timestamp, "viaMqtt") VALUES ('${id}', 1, 2, '!node1', '!node2', 'test', ${channel}, 1, ${timestamp}, true)`; + } else { + return `INSERT INTO messages (id, fromNodeNum, toNodeNum, fromNodeId, toNodeId, \`text\`, channel, portnum, timestamp, viaMqtt) VALUES ('${id}', 1, 2, '!node1', '!node2', 'test', ${channel}, 1, ${timestamp}, true)`; + } +} + // SQL to insert a node (needed for foreign keys in messages for sqlite) function insertNodeSql(backend: TestBackend, nodeNum: number): string { const now = Date.now(); @@ -708,6 +720,28 @@ function runNotificationsTests(getBackend: () => TestBackend) { // ============ markChannelMessagesAsRead ============ + describe('getUnreadCountsByChannelAsync — excludeMqtt (#3787)', () => { + it('counts MQTT messages by default but excludes them when excludeMqtt is set', async () => { + const backend = getBackend(); + if (!backend.available) { console.log(`⚠ Skipped: ${backend.skipReason}`); return; } + + await backend.exec(insertUserSql(backend, 1, 'testuser')); + // Two RF (non-MQTT) text messages and one MQTT-bridged message on channel 0. + await backend.exec(insertMessageSql(backend, 'rf1', 0, 1, 1000)); + await backend.exec(insertMessageSql(backend, 'rf2', 0, 1, 2000)); + await backend.exec(insertMqttMessageSql(backend, 'mqtt1', 0, 3000)); + + // Default: all three unread messages counted. + const all = await repo.getUnreadCountsByChannelAsync(1, undefined, undefined, false); + expect(all[0]).toBe(3); + + // excludeMqtt: the MQTT-bridged message is dropped from the count, so the + // sidebar dot and per-channel badge stay in sync with the hidden-MQTT view. + const rfOnly = await repo.getUnreadCountsByChannelAsync(1, undefined, undefined, true); + expect(rfOnly[0]).toBe(2); + }); + }); + describe('markChannelMessagesAsRead', () => { it('marks channel messages as read for a user', async () => { const backend = getBackend(); diff --git a/src/db/repositories/notifications.ts b/src/db/repositories/notifications.ts index 37615e877..06afe9df7 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,10 @@ export class NotificationsRepository extends BaseRepository { if (localNodeId) { conditions.push(ne(messages.fromNodeId, localNodeId)); } + if (excludeMqtt) { + // Include rows where viaMqtt is NULL (pre-column records) or false/0 + conditions.push(or(isNull(messages.viaMqtt as any), eq(messages.viaMqtt as any, false))); + } try { const rows: any[] = await this.db diff --git a/src/hooks/useUnreadCounts.ts b/src/hooks/useUnreadCounts.ts index ae579eb59..23b86c936 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 channel 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', 'true'); + const qs = params.toString(); + const url = qs + ? `${baseUrl}/api/messages/unread-counts?${qs}` : `${baseUrl}/api/messages/unread-counts`; const response = await fetch(url, { credentials: 'include', diff --git a/src/server/routes/meshcoreRoutes.test.ts b/src/server/routes/meshcoreRoutes.test.ts index 3a7ab99ca..1226261a5 100644 --- a/src/server/routes/meshcoreRoutes.test.ts +++ b/src/server/routes/meshcoreRoutes.test.ts @@ -458,7 +458,7 @@ describe('MeshCore Routes', () => { .post('/api/sources/test-source/meshcore/messages/send') .send({ text: longMessage }); expect(response.status).toBe(400); - expect(response.body.error).toContain('maximum length'); + expect(response.body.error).toContain('maximum size'); }); it('should reject invalid public key format', async () => { diff --git a/src/server/routes/meshcoreRoutes.ts b/src/server/routes/meshcoreRoutes.ts index 4e094eed3..4ef979434 100644 --- a/src/server/routes/meshcoreRoutes.ts +++ b/src/server/routes/meshcoreRoutes.ts @@ -69,8 +69,12 @@ router.use((req, res, next) => { const VALIDATION = { /** MeshCore public keys are 64-character hex strings (32 bytes) */ PUBLIC_KEY_LENGTH: 64, - /** Maximum message length (LoRa packet size limit) */ - MAX_MESSAGE_LENGTH: 230, + /** Maximum message byte limits per context (UTF-8 byte count, not char count) */ + MAX_MESSAGE_BYTES_CHANNEL: 130, + MAX_MESSAGE_BYTES_CHANNEL_SCOPED: 120, + MAX_MESSAGE_BYTES_DM: 150, + /** Legacy fallback — keep for safety ceiling in shared validation path */ + MAX_MESSAGE_LENGTH: 150, /** Maximum device name length */ MAX_NAME_LENGTH: 32, /** Maximum message history limit */ @@ -198,12 +202,14 @@ function parseHexPathChain(input: string, hashBytes: 1 | 2 | 3 = 1): Uint8Array return out; } -function isValidMessage(text: string | undefined): { valid: boolean; error?: string } { +function isValidMessage(text: string | undefined, maxBytes?: number): { valid: boolean; error?: string } { if (!text || typeof text !== 'string') { return { valid: false, error: 'Message text required' }; } - if (text.length > VALIDATION.MAX_MESSAGE_LENGTH) { - return { valid: false, error: `Message exceeds maximum length of ${VALIDATION.MAX_MESSAGE_LENGTH} characters` }; + const limit = maxBytes ?? VALIDATION.MAX_MESSAGE_LENGTH; + const byteLen = Buffer.byteLength(text, 'utf8'); + if (byteLen > limit) { + return { valid: false, error: `Message exceeds maximum size of ${limit} bytes (${byteLen} bytes encoded)` }; } return { valid: true }; } @@ -1482,8 +1488,21 @@ router.post('/messages/send', messageLimiter, requireAuth(), requirePermission(' try { const { text, toPublicKey, channelIdx, scope } = req.body; - // Validate message text - const textValidation = isValidMessage(text); + // Determine per-context byte limit before validating the message. + // DM (toPublicKey present) → 150 bytes. + // Channel with scope → 120 bytes. Channel without scope → 130 bytes. + let msgMaxBytes: number; + if (toPublicKey !== undefined && toPublicKey !== null && toPublicKey !== '') { + msgMaxBytes = VALIDATION.MAX_MESSAGE_BYTES_DM; + } else { + const hasScope = typeof scope === 'string' && scope.trim().length > 0; + msgMaxBytes = hasScope + ? VALIDATION.MAX_MESSAGE_BYTES_CHANNEL_SCOPED + : VALIDATION.MAX_MESSAGE_BYTES_CHANNEL; + } + + // Validate message text using the context-appropriate byte limit. + const textValidation = isValidMessage(text, msgMaxBytes); if (!textValidation.valid) { return res.status(400).json({ success: false, error: textValidation.error }); } diff --git a/src/server/server.ts b/src/server/server.ts index 0be8b8821..ab21127ca 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 === '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..f2e8e29bc 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.85; text-align: right; } .message-bubble.theirs .message-time { - color: var(--ctp-subtext0); + color: var(--ctp-subtext1); } .message-bubble.mine .message-time { color: var(--ctp-chatBubbleSentText, var(--ctp-accent-text)); - opacity: 0.7; + opacity: 0.85; } /* Reply button that appears on hover */