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