diff --git a/src/renderer/components/ChatComposer.test.tsx b/src/renderer/components/ChatComposer.test.tsx
index 291150ead..6ab4698ab 100644
--- a/src/renderer/components/ChatComposer.test.tsx
+++ b/src/renderer/components/ChatComposer.test.tsx
@@ -1,11 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { axe } from 'vitest-axe';
import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers';
import { MESHTASTIC_PAYLOAD_LIMIT } from '@/renderer/lib/chatComposerLimits';
import { draftsStorageKey } from '@/renderer/lib/chatPanelProtocolStorage';
+import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing';
+import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants';
import { ChatComposer } from './ChatComposer';
@@ -59,6 +61,7 @@ vi.mock('react-i18next', () => ({
describe('ChatComposer', () => {
beforeEach(() => {
localStorage.clear();
+ resetMeshtasticTextSendPacingForTests();
});
it('has no axe violations when connected', async () => {
@@ -337,6 +340,72 @@ describe('ChatComposer', () => {
expect(screen.getByRole('button', { name: 'Send 2 parts' })).toBeInTheDocument();
});
+ it('paces meshtastic multi-chunk sends to avoid firmware RATE_LIMIT_EXCEEDED', async () => {
+ // Regression: firmware rejects a second TEXT_MESSAGE_APP within 2s of the first
+ // (Routing_Error.RATE_LIMIT_EXCEEDED). Chunks must not fire back-to-back.
+ vi.useFakeTimers();
+ try {
+ const onSendChunk = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ const textarea = screen.getByRole('textbox');
+ fireEvent.change(textarea, { target: { value: 'a'.repeat(250) } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send 2 parts' }));
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(onSendChunk).toHaveBeenCalledTimes(1);
+ expect(onSendChunk).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('[1/2]'),
+ expect.objectContaining({ chunkIndex: 0 }),
+ );
+
+ await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS - 100);
+ expect(onSendChunk).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(200);
+ expect(onSendChunk).toHaveBeenCalledTimes(2);
+ expect(onSendChunk).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('[2/2]'),
+ expect.objectContaining({ chunkIndex: 1 }),
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('does not delay single-chunk meshtastic sends', async () => {
+ vi.useFakeTimers();
+ try {
+ const onSendChunk = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ const textarea = screen.getByRole('textbox');
+ fireEvent.change(textarea, { target: { value: 'hello' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(onSendChunk).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it('hides GIF button when MeshCore Open wire compat is disabled', () => {
render(
+ onSendChunk(textsToSend[i], {
+ replyId: i === 0 && typeof replyKey === 'number' ? replyKey : undefined,
+ replyHash: i === 0 ? reticulumReplyHash : undefined,
+ chunkIndex: i,
+ floodScopeOverride:
+ floodScopeOverride === '__unscoped__'
+ ? ''
+ : floodScopeOverride
+ ? floodScopeOverride
+ : undefined,
+ });
+ // Shared Meshtastic TEXT_MESSAGE_APP pacing (with outbox drain) — firmware rejects
+ // a second locally-originated text within ~2s (RATE_LIMIT_EXCEEDED).
+ if (protocol === 'meshtastic') {
+ await withMeshtasticTextSendPacing(sendChunk);
+ } else {
+ await sendChunk();
+ }
}
rememberFloodScopeIfNeeded(floodScopeOverride);
clearSentDraft(draftSnapshot);
diff --git a/src/renderer/hooks/useChatOutbox.test.ts b/src/renderer/hooks/useChatOutbox.test.ts
index 5944ba4f3..551712b4b 100644
--- a/src/renderer/hooks/useChatOutbox.test.ts
+++ b/src/renderer/hooks/useChatOutbox.test.ts
@@ -1,6 +1,8 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { resetMeshtasticTextSendPacingForTests } from '@/renderer/lib/meshtasticTextSendPacing';
+import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from '@/renderer/lib/timeConstants';
import type { OutboxEntry } from '@/shared/electron-api.types';
import { useChatOutbox } from './useChatOutbox';
@@ -31,6 +33,7 @@ describe('useChatOutbox', () => {
const mockOutbox = window.electronAPI.chat.outbox;
beforeEach(() => {
+ resetMeshtasticTextSendPacingForTests();
vi.mocked(mockOutbox.list).mockClear();
vi.mocked(mockOutbox.add).mockClear();
vi.mocked(mockOutbox.updateStatus).mockClear();
@@ -161,22 +164,56 @@ describe('useChatOutbox', () => {
});
it('retry resets status to queued and triggers drain', async () => {
- const entry = makeEntry({ id: 4, status: 'failed' });
- vi.mocked(mockOutbox.list).mockResolvedValue([entry]);
- const sendFn = vi.fn().mockResolvedValue(undefined);
- const { result } = renderHook(() =>
- useChatOutbox({ protocol: 'meshtastic', isSendAvailable: true, sendFn }),
- );
- await waitFor(() => {
+ vi.useFakeTimers();
+ try {
+ const entry = makeEntry({ id: 4, status: 'failed' });
+ vi.mocked(mockOutbox.list).mockResolvedValue([entry]);
+ const sendFn = vi.fn().mockResolvedValue(undefined);
+ const { result } = renderHook(() =>
+ useChatOutbox({ protocol: 'meshtastic', isSendAvailable: true, sendFn }),
+ );
+ await vi.advanceTimersByTimeAsync(0);
expect(result.current.rows).toHaveLength(0);
- }); // initially drains the failed row and fails again
- // Now manually call retry
- vi.mocked(mockOutbox.updateStatus).mockResolvedValue(undefined);
- vi.mocked(mockOutbox.list).mockResolvedValue([{ ...entry, status: 'queued' }]);
- result.current.retry(4);
- await waitFor(() => {
+ expect(sendFn).toHaveBeenCalledTimes(1);
+
+ vi.mocked(mockOutbox.updateStatus).mockResolvedValue(undefined);
+ vi.mocked(mockOutbox.list).mockResolvedValue([{ ...entry, status: 'queued' }]);
+ result.current.retry(4);
+ await vi.advanceTimersByTimeAsync(0);
expect(mockOutbox.updateStatus).toHaveBeenCalledWith(4, 'queued', undefined, undefined);
- });
+ // Second send is paced behind the first — advance past the interval so no timer dangles.
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS);
+ expect(sendFn).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('paces successive meshtastic sends within one drain to avoid RATE_LIMIT_EXCEEDED', async () => {
+ // Regression: firmware rejects a second TEXT_MESSAGE_APP within 2s of the first
+ // (Routing_Error.RATE_LIMIT_EXCEEDED). Rows draining back-to-back must be paced.
+ vi.useFakeTimers();
+ try {
+ const rowA = makeEntry({ id: 30, payload: 'first' });
+ const rowB = makeEntry({ id: 31, payload: 'second' });
+ vi.mocked(mockOutbox.list).mockResolvedValue([rowA, rowB]);
+ const sendFn = vi.fn().mockResolvedValue(undefined);
+ renderHook(() => useChatOutbox({ protocol: 'meshtastic', isSendAvailable: true, sendFn }));
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(sendFn).toHaveBeenCalledTimes(1);
+ expect(sendFn).toHaveBeenNthCalledWith(1, 'first', 0, undefined, undefined);
+
+ await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS - 100);
+ expect(sendFn).toHaveBeenCalledTimes(1);
+
+ await vi.advanceTimersByTimeAsync(200);
+ expect(sendFn).toHaveBeenCalledTimes(2);
+ expect(sendFn).toHaveBeenNthCalledWith(2, 'second', 0, undefined, undefined);
+ } finally {
+ vi.useRealTimers();
+ }
});
it('does not drain when isSendAvailable is false', async () => {
diff --git a/src/renderer/hooks/useChatOutbox.ts b/src/renderer/hooks/useChatOutbox.ts
index 437344deb..f37ae1984 100644
--- a/src/renderer/hooks/useChatOutbox.ts
+++ b/src/renderer/hooks/useChatOutbox.ts
@@ -4,6 +4,7 @@ import type { MeshProtocol } from '@/renderer/lib/types';
import type { OutboxEntry, OutboxEntryInput, OutboxStatus } from '@/shared/electron-api.types';
import { registerChatOutboxDrainListener } from '../lib/chatOutboxDrain';
+import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing';
export type { OutboxEntry };
@@ -192,7 +193,14 @@ export function useChatOutbox({
const now = Date.now();
for (const row of freshRows.filter((r) => isEligibleForDrain(r, now))) {
if (!isSendAvailableRef.current) break;
- await sendOneOutboxRow(row, sendFnRef.current, updateRow, removeRow);
+ const sendRow = () => sendOneOutboxRow(row, sendFnRef.current, updateRow, removeRow);
+ // Shared with ChatComposer so live multi-chunk sends and outbox drain cannot race
+ // firmware's TEXT_MESSAGE_APP RATE_LIMIT_EXCEEDED window.
+ if (protocol === 'meshtastic') {
+ await withMeshtasticTextSendPacing(sendRow);
+ } else {
+ await sendRow();
+ }
}
} catch (err: unknown) {
console.warn('[useChatOutbox] drainOnce failed', err);
diff --git a/src/renderer/lib/chatComposerLimits.test.ts b/src/renderer/lib/chatComposerLimits.test.ts
index 1f7ac66ee..14a7dc4cc 100644
--- a/src/renderer/lib/chatComposerLimits.test.ts
+++ b/src/renderer/lib/chatComposerLimits.test.ts
@@ -4,6 +4,7 @@ import {
computeComposerLimitStatus,
computeComposerTotalMaxChars,
countMessageChars,
+ countMessageWireBytes,
getChatPayloadLimit,
getComposerWireOverhead,
getMeshcoreChannelPayloadLimit,
@@ -11,6 +12,7 @@ import {
MAX_CHUNKS,
MESHCORE_PAYLOAD_LIMIT,
MESHTASTIC_PAYLOAD_LIMIT,
+ MESHTASTIC_REPLY_ID_WIRE_BYTES,
RETICULUM_LXMF_PAYLOAD_LIMIT,
splitChatMessage,
} from './chatComposerLimits';
@@ -41,6 +43,11 @@ describe('getMeshcoreChannelPayloadLimit', () => {
it('caps name length at 32 characters', () => {
expect(getMeshcoreChannelPayloadLimit('x'.repeat(40))).toBe(126);
});
+
+ it('reserves UTF-8 wire bytes for multi-byte display names', () => {
+ // Cyrillic 'п' is 2 UTF-8 bytes; 10 codepoints → 20 wire bytes + ": " (2) → body 138.
+ expect(getMeshcoreChannelPayloadLimit('п'.repeat(10))).toBe(160 - 20 - 2);
+ });
});
describe('getMeshcoreRoomPayloadLimit', () => {
@@ -50,10 +57,26 @@ describe('getMeshcoreRoomPayloadLimit', () => {
});
describe('getComposerWireOverhead', () => {
- it('returns 0 for meshtastic replies', () => {
+ it('returns 0 for meshtastic when no reply is pending', () => {
expect(getComposerWireOverhead({ protocol: 'meshtastic', replyToSenderName: 'Bob' })).toBe(0);
});
+ it('reserves 5 wire bytes for meshtastic replies (fixed32 reply_id field)', () => {
+ expect(
+ getComposerWireOverhead({
+ protocol: 'meshtastic',
+ replyToSenderName: 'Bob',
+ replyKey: 2_113_407_456,
+ }),
+ ).toBe(MESHTASTIC_REPLY_ID_WIRE_BYTES);
+ });
+
+ it('returns 0 for meshtastic when replyKey is 0', () => {
+ expect(
+ getComposerWireOverhead({ protocol: 'meshtastic', replyToSenderName: 'Bob', replyKey: 0 }),
+ ).toBe(0);
+ });
+
it('counts MeshCore reply prefix on first chunk', () => {
expect(getComposerWireOverhead({ protocol: 'meshcore', replyToSenderName: 'Bob' })).toBe(7);
});
@@ -78,6 +101,44 @@ describe('getComposerWireOverhead', () => {
}),
).toBe(countMessageChars('@[Bob#1780235760847] '));
});
+
+ it('reserves the "Unknown" fallback length for an all-emoji sender name (keyless)', () => {
+ // Regression: sanitizeMeshcoreWireName strips an all-pictographic name to '', and the real
+ // wire builder falls back to the literal "Unknown" — a naive estimate from the raw name
+ // ("@[😀] ", 5 chars) under-reserves by 6 bytes versus the true "@[Unknown] " (11 bytes).
+ expect(getComposerWireOverhead({ protocol: 'meshcore', replyToSenderName: '😀' })).toBe(
+ countMessageWireBytes('@[Unknown] '),
+ );
+ });
+
+ it('reserves the "Unknown" fallback length for an all-emoji sender name (keyed)', () => {
+ expect(
+ getComposerWireOverhead({
+ protocol: 'meshcore',
+ replyToSenderName: '🔥🔥',
+ replyKey: 1_780_235_760_847,
+ useKeyedReplies: true,
+ }),
+ ).toBe(countMessageWireBytes('@[Unknown#1780235760847] '));
+ });
+});
+
+describe('countMessageWireBytes', () => {
+ it('matches countMessageChars for ASCII text', () => {
+ expect(countMessageWireBytes('hello')).toBe(countMessageChars('hello'));
+ });
+
+ it('counts multi-byte UTF-8 characters by their real byte cost, not codepoint count', () => {
+ // Cyrillic characters are 2 bytes each in UTF-8, but 1 codepoint each.
+ const text = 'привет';
+ expect(countMessageChars(text)).toBe(6);
+ expect(countMessageWireBytes(text)).toBe(12);
+ });
+
+ it('counts an emoji as 4 bytes despite being 1 codepoint', () => {
+ expect(countMessageChars('🦊')).toBe(1);
+ expect(countMessageWireBytes('🦊')).toBe(4);
+ });
});
describe('countMessageChars', () => {
@@ -197,6 +258,36 @@ describe('splitChatMessage', () => {
expect(splitChatMessage(fitsWithout, 'meshcore', limit, overhead)).not.toEqual([]);
});
+ it('splits a max-length meshtastic reply instead of overflowing the radio payload', () => {
+ // Regression: a 228-char reply previously fit in one chunk (overhead was ignored),
+ // silently overflowing the true wire payload once the 5-byte fixed32 reply_id field
+ // was added by the SDK/radio, which the firmware NAKed as TOO_LARGE.
+ const text = 'a'.repeat(MESHTASTIC_PAYLOAD_LIMIT);
+ const overhead = getComposerWireOverhead({ protocol: 'meshtastic', replyKey: 2_113_407_456 });
+ expect(overhead).toBe(MESHTASTIC_REPLY_ID_WIRE_BYTES);
+ expect(splitChatMessage(text, 'meshtastic', MESHTASTIC_PAYLOAD_LIMIT, 0)).toEqual([]);
+ const chunks = splitChatMessage(text, 'meshtastic', MESHTASTIC_PAYLOAD_LIMIT, overhead);
+ expect(chunks).not.toEqual([]);
+ expect(chunks).not.toBeNull();
+ });
+
+ it('splits multi-byte text that fits the codepoint limit but not the real byte limit', () => {
+ // Regression: Cyrillic 'п' is 1 codepoint but 2 UTF-8 bytes. 200 codepoints is under the
+ // 228-codepoint limit (previously judged "fits in one message"), but 400 real wire bytes —
+ // nearly double the true 228-byte Meshtastic payload — which the radio would NAK as TOO_LARGE.
+ const text = 'п'.repeat(200);
+ expect(countMessageChars(text)).toBeLessThanOrEqual(MESHTASTIC_PAYLOAD_LIMIT);
+ expect(countMessageWireBytes(text)).toBeGreaterThan(MESHTASTIC_PAYLOAD_LIMIT);
+ const chunks = splitChatMessage(text, 'meshtastic');
+ expect(chunks).not.toBeNull();
+ expect(chunks!.length).toBeGreaterThan(1);
+ for (const chunk of chunks!) {
+ expect(countMessageWireBytes(chunk)).toBeLessThanOrEqual(MESHTASTIC_PAYLOAD_LIMIT);
+ }
+ const bodies = chunks!.map((c) => c.replace(/^\[\d+\/\d+\] /, ''));
+ expect(bodies.join('')).toBe(text);
+ });
+
it('returns null when text requires more than MAX_CHUNKS chunks', () => {
const text = 'x'.repeat(9 * 127 + 1);
expect(splitChatMessage(text, 'meshcore')).toBeNull();
diff --git a/src/renderer/lib/chatComposerLimits.ts b/src/renderer/lib/chatComposerLimits.ts
index 60ae21d88..efe8a6d50 100644
--- a/src/renderer/lib/chatComposerLimits.ts
+++ b/src/renderer/lib/chatComposerLimits.ts
@@ -1,3 +1,7 @@
+import {
+ formatMeshcoreWireReplyPrefix,
+ formatMeshcoreWireTapbackPrefix,
+} from './meshcoreChannelText';
import type { MeshProtocol } from './types';
export const MESHTASTIC_PAYLOAD_LIMIT = 228;
@@ -12,6 +16,15 @@ export const MESHCORE_MAX_NAME_LEN = 32;
export const MESHCORE_NAME_SUFFIX_LEN = 2; // ": "
export const MESHCORE_ROOM_PUBKEY_PREFIX_LEN = 4;
+/**
+ * Meshtastic `Data.reply_id` (field 7) is `fixed32`: 1 tag byte + 4 fixed value bytes = 5 bytes,
+ * always, whenever a reply is sent — regardless of the referenced packet id's value. Unlike
+ * MeshCore's reply prefix, this overhead is invisible wire bytes, not visible text, but it still
+ * has to come out of the same payload budget or a near-limit reply overflows the radio's true
+ * packet size and gets NAKed as TOO_LARGE.
+ */
+export const MESHTASTIC_REPLY_ID_WIRE_BYTES = 5;
+
export type ComposerWireContext = 'channel' | 'dm' | 'room';
export type ComposerLimitPhase = 'ok' | 'warn' | 'split' | 'overMax';
@@ -32,7 +45,10 @@ export function getChatPayloadLimit(protocol: MeshProtocol, override?: number):
}
export function getMeshcoreChannelPayloadLimit(displayName: string): number {
- const nameLen = Math.min(countMessageChars(displayName.trim()), MESHCORE_MAX_NAME_LEN);
+ // Cap by codepoints (companion name length), then charge real UTF-8 bytes for `Name: `
+ // against the 160-byte wire max — multi-byte names previously under-reserved the body.
+ const cappedName = Array.from(displayName.trim()).slice(0, MESHCORE_MAX_NAME_LEN).join('');
+ const nameLen = countMessageWireBytes(cappedName);
return Math.max(1, MESHCORE_WIRE_MAX - nameLen - MESHCORE_NAME_SUFFIX_LEN);
}
@@ -59,7 +75,7 @@ export function getComposerPayloadLimit(opts: {
return getMeshcoreChannelPayloadLimit(opts.senderDisplayName ?? '');
}
-/** MeshCore reply wire prefix on the first chunk only (keyless companion or keyed Open). */
+/** Reply wire overhead on the first chunk only (MeshCore visible prefix; Meshtastic reply_id field). */
export function getComposerWireOverhead(opts: {
protocol: MeshProtocol;
replyToSenderName?: string;
@@ -67,19 +83,55 @@ export function getComposerWireOverhead(opts: {
/** When true, count keyed `@[Name#key] ` overhead (MeshCore Open compat). */
useKeyedReplies?: boolean;
}): number {
+ if (opts.protocol === 'meshtastic') {
+ return opts.replyKey != null && Number.isFinite(opts.replyKey) && opts.replyKey !== 0
+ ? MESHTASTIC_REPLY_ID_WIRE_BYTES
+ : 0;
+ }
if (opts.protocol !== 'meshcore' || !opts.replyToSenderName?.trim()) return 0;
- const cleanName = opts.replyToSenderName.trim();
const key = opts.replyKey;
- if (opts.useKeyedReplies && key != null && Number.isFinite(key) && key > 0) {
- return countMessageChars(`@[${cleanName}#${Math.trunc(key)}] `);
- }
- return countMessageChars(`@[${cleanName}] `);
+ // Reuse the exact wire-format builders (incl. sanitize + "Unknown" fallback for all-emoji
+ // names) so this estimate can never drift from what actually goes out on the wire.
+ const prefix =
+ opts.useKeyedReplies && key != null && Number.isFinite(key) && key > 0
+ ? formatMeshcoreWireReplyPrefix(opts.replyToSenderName, key)
+ : formatMeshcoreWireTapbackPrefix(opts.replyToSenderName);
+ return countMessageWireBytes(`${prefix} `);
}
export function countMessageChars(text: string): number {
return Array.from(text).length;
}
+const wireTextEncoder = new TextEncoder();
+
+/**
+ * Real transport wire byte length (UTF-8), as opposed to `countMessageChars`' codepoint count.
+ * Meshtastic and MeshCore both encode outbound text with `TextEncoder` before transmission, so
+ * a codepoint count alone understates cost for any non-ASCII text (non-Latin scripts, emoji) —
+ * this is what must be checked against real byte-based wire limits like `MESHTASTIC_PAYLOAD_LIMIT`.
+ */
+export function countMessageWireBytes(text: string): number {
+ return wireTextEncoder.encode(text).length;
+}
+
+/**
+ * Number of leading codepoints from `chars` whose combined UTF-8 byte length fits `byteBudget`.
+ * Always takes at least one codepoint to guarantee forward progress, even when a single
+ * multi-byte codepoint alone exceeds the budget (only possible with a pathologically tiny limit).
+ */
+function takeCharsWithinByteBudget(chars: readonly string[], byteBudget: number): number {
+ let bytes = 0;
+ let count = 0;
+ for (const ch of chars) {
+ const chBytes = countMessageWireBytes(ch);
+ if (count > 0 && bytes + chBytes > byteBudget) break;
+ bytes += chBytes;
+ count++;
+ }
+ return count;
+}
+
/** Max user-typed characters across MAX_CHUNKS split messages. */
export function computeComposerTotalMaxChars(
singleMessageLimit: number,
@@ -180,13 +232,16 @@ export function splitChatMessage(
if (bodyLimit <= 0) return bodies;
const remaining = chars.slice(pos);
- if (remaining.length <= bodyLimit) {
+ if (countMessageWireBytes(remaining.join('')) <= bodyLimit) {
bodies.push(remaining.join(''));
break;
}
- const window = remaining.slice(0, bodyLimit);
- let breakAt = bodyLimit;
- for (let i = bodyLimit - 1; i > 0; i--) {
+ // bodyLimit is a byte budget (real wire limits are byte limits), so the window must be
+ // sized by accumulated UTF-8 byte length, not codepoint count.
+ const windowLen = takeCharsWithinByteBudget(remaining, bodyLimit);
+ const window = remaining.slice(0, windowLen);
+ let breakAt = windowLen;
+ for (let i = windowLen - 1; i > 0; i--) {
if (window[i] === ' ' || window[i] === '\n') {
breakAt = i;
break;
@@ -194,13 +249,13 @@ export function splitChatMessage(
}
const body = window.slice(0, breakAt).join('').trimEnd();
bodies.push(body);
- pos += breakAt === bodyLimit ? bodyLimit : breakAt + 1;
+ pos += breakAt === windowLen ? windowLen : breakAt + 1;
isFirst = false;
}
return bodies;
}
- if (countMessageChars(trimmed) + overhead <= limit) return [];
+ if (countMessageWireBytes(trimmed) + overhead <= limit) return [];
const estimatedPrefixLen = `[${MAX_CHUNKS}/${MAX_CHUNKS}] `.length;
const bodies = chunkBodies(estimatedPrefixLen);
diff --git a/src/renderer/lib/meshtasticTextSendPacing.test.ts b/src/renderer/lib/meshtasticTextSendPacing.test.ts
new file mode 100644
index 000000000..c2ee37db2
--- /dev/null
+++ b/src/renderer/lib/meshtasticTextSendPacing.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ resetMeshtasticTextSendPacingForTests,
+ withMeshtasticTextSendPacing,
+} from './meshtasticTextSendPacing';
+import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants';
+
+describe('withMeshtasticTextSendPacing', () => {
+ beforeEach(() => {
+ resetMeshtasticTextSendPacingForTests();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ resetMeshtasticTextSendPacingForTests();
+ });
+
+ it('does not delay the first send', async () => {
+ const send = vi.fn().mockResolvedValue('ok');
+ const pending = withMeshtasticTextSendPacing(send);
+ await vi.advanceTimersByTimeAsync(0);
+ await expect(pending).resolves.toBe('ok');
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it('paces a second send from completion of the first, not from start', async () => {
+ // Regression: stamping before await send() let a slow first write shrink the gap
+ // under firmware's 2s RATE_LIMIT_EXCEEDED window.
+ const slowSend = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(resolve, 800);
+ }),
+ );
+ const second = vi.fn().mockResolvedValue(undefined);
+
+ const firstPending = withMeshtasticTextSendPacing(slowSend);
+ await vi.advanceTimersByTimeAsync(800);
+ await firstPending;
+
+ const secondPending = withMeshtasticTextSendPacing(second);
+ await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS - 100);
+ expect(second).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(200);
+ await secondPending;
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ it('stamps even when send rejects so the next attempt still waits', async () => {
+ const failing = vi.fn().mockRejectedValue(new Error('radio busy'));
+ const next = vi.fn().mockResolvedValue(undefined);
+
+ const first = withMeshtasticTextSendPacing(failing);
+ const firstExpectation = expect(first).rejects.toThrow('radio busy');
+ await vi.advanceTimersByTimeAsync(0);
+ await firstExpectation;
+
+ const secondPending = withMeshtasticTextSendPacing(next);
+ await vi.advanceTimersByTimeAsync(MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS - 100);
+ expect(next).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(200);
+ await secondPending;
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/renderer/lib/meshtasticTextSendPacing.ts b/src/renderer/lib/meshtasticTextSendPacing.ts
new file mode 100644
index 000000000..4f716990b
--- /dev/null
+++ b/src/renderer/lib/meshtasticTextSendPacing.ts
@@ -0,0 +1,35 @@
+import { MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS } from './timeConstants';
+
+/**
+ * Shared completion timestamp for Meshtastic TEXT_MESSAGE_APP sends.
+ * Module-level so ChatComposer multi-chunk sends and useChatOutbox drain share one clock
+ * and cannot race the firmware's ~2s PhoneAPI rate limit.
+ */
+let lastMeshtasticTextSendAtMs = 0;
+
+/** Test-only: clear the shared pacing clock between cases. */
+export function resetMeshtasticTextSendPacingForTests(): void {
+ lastMeshtasticTextSendAtMs = 0;
+}
+
+/**
+ * Wait until the Meshtastic text-send slot is free, run `send`, then stamp completion.
+ * Stamping after `send` settles (not before) keeps the next gap measured from when the
+ * prior attempt finished — including IPC / SDK work — so a slow write cannot shrink the
+ * radio-visible interval under firmware's 2s RATE_LIMIT_EXCEEDED window.
+ */
+export async function withMeshtasticTextSendPacing(send: () => Promise | T): Promise {
+ if (lastMeshtasticTextSendAtMs > 0) {
+ const wait = MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS - (Date.now() - lastMeshtasticTextSendAtMs);
+ if (wait > 0) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, wait);
+ });
+ }
+ }
+ try {
+ return await send();
+ } finally {
+ lastMeshtasticTextSendAtMs = Date.now();
+ }
+}
diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts
index 4aea54f59..4a0c99dfb 100644
--- a/src/renderer/lib/timeConstants.ts
+++ b/src/renderer/lib/timeConstants.ts
@@ -284,3 +284,13 @@ export const NOMAD_PAGE_FETCH_RETRY_SETTLE_MS = 750;
* the latest selection after this debounce.
*/
export const NOMAD_PAGE_FETCH_DEBOUNCE_MS = 300;
+
+/**
+ * Minimum gap between successive TEXT_MESSAGE_APP sends to the connected Meshtastic radio.
+ * Firmware's PhoneAPI rate-limits locally-originated text packets to one per 2s
+ * (`Throttle::isWithinTimespanMs(lastPortNumToRadio[TEXT_MESSAGE_APP], TWO_SECONDS_MS)` in
+ * `PhoneAPI.cpp`) and rejects a closer one with `Routing_Error.RATE_LIMIT_EXCEEDED` — a
+ * multi-chunk split message sent back-to-back trips this on chunk 2+. Padded above the
+ * firmware's exact 2000ms boundary for serial/BLE/TCP write and timer-granularity slack.
+ */
+export const MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS = 2.5 * MS_PER_SECOND;