From f460c38cfafd937f404086932339661e8d695f5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:56:38 +0000 Subject: [PATCH 1/4] fix(meshcore): exclude MQTT messages from unread dot and improve time/hop text readability (#3787) Two fixes for issue #3787: 1. MQTT notification dot: The unread-count badge was counting MQTT-relayed messages even when showMqttMessages=false, causing a phantom dot. Added excludeMqtt query param to /api/messages/unread-counts, threaded the showMqttMessages toggle from UIContext through MessagingContext and useUnreadCounts hook. Provider order in App.tsx fixed so UIProvider wraps MessagingProvider (UIContext must be available inside Messaging). 2. Text readability: Timestamp and hop-count metadata opacity bumped from 0.7 to 0.85; received-message timestamps use --ctp-subtext1 instead of the doubly-muted --ctp-subtext0. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01TpTiMaXqnhkW6TqNxWGMg5 --- src/App.tsx | 8 ++++---- src/components/HopCountDisplay.tsx | 4 ++-- src/contexts/MessagingContext.tsx | 7 ++++++- src/db/repositories/notifications.ts | 7 ++++++- src/hooks/useUnreadCounts.ts | 13 ++++++++++--- src/server/server.ts | 3 ++- src/services/database.ts | 10 ++++++---- src/styles/messages.css | 6 +++--- 8 files changed, 39 insertions(+), 19 deletions(-) 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..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/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.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/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 */ From 7031bab2af8b3e68507cba865f422f43742e6b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:56:48 +0000 Subject: [PATCH 2/4] feat(meshcore): byte-accurate character counter with per-context limits (#3782) Adds a live byte counter below the MeshCore message input that counts UTF-8 encoded bytes (not characters) to accurately reflect what the firmware enforces. Limits are context-aware: - Channel message: 130 bytes - Channel message with scope: 120 bytes (scope header consumes ~10 bytes) - Direct message: 150 bytes Counter appears only when the draft is non-empty. Color shifts to yellow at 90% of the limit and red when over. Send button is disabled when the limit is exceeded. Backend validation also uses Buffer.byteLength(text, 'utf8') with the same per-context limits instead of the previous character-count heuristic. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01TpTiMaXqnhkW6TqNxWGMg5 --- .../MeshCore/MeshCoreChannelsView.tsx | 7 ++++ .../MeshCore/MeshCoreDirectMessagesView.tsx | 1 + .../MeshCore/MeshCoreMessageStream.tsx | 28 ++++++++++++++-- src/server/routes/meshcoreRoutes.test.ts | 2 +- src/server/routes/meshcoreRoutes.ts | 33 +++++++++++++++---- 5 files changed, 60 insertions(+), 11 deletions(-) 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..75e451e7d 100644 --- a/src/components/MeshCore/MeshCoreMessageStream.tsx +++ b/src/components/MeshCore/MeshCoreMessageStream.tsx @@ -16,6 +16,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 +62,7 @@ export const MeshCoreMessageStream: React.FC = ({ onSend, onNodeNameClick, conversationKey, + maxBytes = 130, }) => { const { t } = useTranslation(); const [draft, setDraft] = useState(''); @@ -164,8 +167,11 @@ export const MeshCoreMessageStream: React.FC = ({ return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); + const byteLen = useMemo(() => new TextEncoder().encode(draft).length, [draft]); + 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 +344,31 @@ export const MeshCoreMessageStream: React.FC = ({ onKeyDown={handleKeyDown} placeholder={t('meshcore.type_message', 'Type a message…')} disabled={disabled || sending} - maxLength={230} />
+ {draft.length > 0 && ( +
= Math.floor(maxBytes * 0.9) + ? 'var(--ctp-yellow)' + : 'var(--ctp-subtext1)', + }} + > + {byteLen} / {maxBytes} +
+ )} ); }; 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 }); } From 1c8e44762ed3bc7d0bbedb7bbbd26a824b854ccb Mon Sep 17 00:00:00 2001 From: Randall Hand Date: Fri, 26 Jun 2026 10:39:32 -0400 Subject: [PATCH 3/4] fix(messaging): per-channel unread badge also respects Show MQTT toggle (#3787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated unread query already applies excludeMqtt, fixing the sidebar dot. But the per-channel unread badges in ChannelsTab are fed by the /poll aggregate, which is not filtered — so with MQTT hidden the dot went dark while a channel badge could still count MQTT-only traffic. Source the badge counts from the already-filtered unread query (unreadCountsData) instead of the raw poll payload, bridged into the memoized poll callback via a ref. Both unread surfaces now agree. Adds a NotificationsRepository test asserting excludeMqtt drops viaMqtt rows from the channel unread count. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 --- src/App.tsx | 20 +++++++++++-- src/db/repositories/notifications.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4753d523b..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; 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(); From 57ca9e4bd37ac042f481cb22be1212631f6d3755 Mon Sep 17 00:00:00 2001 From: Randall Hand Date: Fri, 26 Jun 2026 10:49:18 -0400 Subject: [PATCH 4/4] refactor(meshcore): reuse shared byte-counter helpers in message composer (#3782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MeshCore composer counter reimplemented UTF-8 byte counting, the 90%/100% warning thresholds, the N/M display format, and the warning/over colors inline — all of which already exist in src/utils/text.ts (getUtf8ByteLength + formatByteCount) and the shared .byte-counter* CSS used by the Meshtastic channel and DM composers. Reuse those helpers and the shared color classes. Per-context limits stay consumer-specific (Meshtastic 200 default; MeshCore channel 130 / scoped 120 / DM 150, passed via maxBytes). Only the layout is overridden — MeshCore shows the counter as a block under the input+button row rather than the absolute overlay the Meshtastic textarea uses. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4 --- .../MeshCore/MeshCoreMessageStream.tsx | 24 +++++++------------ src/components/MeshCore/MeshCorePage.css | 13 ++++++++++ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/components/MeshCore/MeshCoreMessageStream.tsx b/src/components/MeshCore/MeshCoreMessageStream.tsx index 75e451e7d..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 { @@ -167,7 +168,12 @@ export const MeshCoreMessageStream: React.FC = ({ return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); - const byteLen = useMemo(() => new TextEncoder().encode(draft).length, [draft]); + // 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 () => { @@ -353,20 +359,8 @@ export const MeshCoreMessageStream: React.FC = ({ {draft.length > 0 && ( -
= Math.floor(maxBytes * 0.9) - ? 'var(--ctp-yellow)' - : 'var(--ctp-subtext1)', - }} - > - {byteLen} / {maxBytes} +
+ {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