Skip to content
Merged
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
71 changes: 70 additions & 1 deletion src/renderer/components/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -59,6 +61,7 @@ vi.mock('react-i18next', () => ({
describe('ChatComposer', () => {
beforeEach(() => {
localStorage.clear();
resetMeshtasticTextSendPacingForTests();
});

it('has no axe violations when connected', async () => {
Expand Down Expand Up @@ -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(
<ChatComposer
protocol="meshtastic"
viewKey="ch:0"
isConnected
allowOutbox={false}
onSendChunk={onSendChunk}
/>,
);
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(
<ChatComposer
protocol="meshtastic"
viewKey="ch:0"
isConnected
allowOutbox={false}
onSendChunk={onSendChunk}
/>,
);
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(
<ChatComposer
Expand Down
31 changes: 20 additions & 11 deletions src/renderer/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
normalizeMeshcoreGifOutboundWire,
parseMeshcoreGifId,
} from '../lib/meshcoreGifWire';
import { withMeshtasticTextSendPacing } from '../lib/meshtasticTextSendPacing';
import { HelpTooltip } from './HelpTooltip';
import MentionAutocomplete, { buildMentionCandidates } from './MentionAutocomplete';
import { useToast } from './Toast';
Expand Down Expand Up @@ -460,17 +461,25 @@ export function ChatComposer({
setChatActionError(null);
try {
for (let i = 0; i < textsToSend.length; i++) {
await onSendChunk(textsToSend[i], {
replyId: i === 0 && typeof replyKey === 'number' ? replyKey : undefined,
replyHash: i === 0 ? reticulumReplyHash : undefined,
chunkIndex: i,
floodScopeOverride:
floodScopeOverride === '__unscoped__'
? ''
: floodScopeOverride
? floodScopeOverride
: undefined,
});
const sendChunk = () =>
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);
Expand Down
65 changes: 51 additions & 14 deletions src/renderer/hooks/useChatOutbox.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/hooks/useChatOutbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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();
}
Comment on lines +199 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'useChatOutbox\.ts|ChatComposer\.tsx' . | sed 's#^\./##'

echo
echo "== useChatOutbox outline =="
ast-grep outline src/renderer/hooks/useChatOutbox.ts 2>/dev/null || true

echo
echo "== useChatOutbox relevant lines =="
cat -n src/renderer/hooks/useChatOutbox.ts | sed -n '1,280p'

echo
echo "== ChatComposer relevant lines around 430-490 =="
cat -n src/renderer/components/ChatComposer.tsx | sed -n '430,495p'

echo
echo "== search MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS and lastMeshtasticSendAtRef =="
rg -n "MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS|lastMeshtasticSendAtRef|handleSend|drainOnce" src

Repository: Colorado-Mesh/mesh-client

Length of output: 20949


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ChatPanel handleSendChunk =="
cat -n src/renderer/components/ChatPanel.tsx | sed -n '1400,1510p'

echo
echo "== RrcPanel handleSend and send chunk implementation =="
cat -n src/renderer/components/RrcPanel.tsx | sed -n '530,610p'
rg -n "sendOneMessage|sendTextMessage|TEXT_MESSAGE|meshtastic|message send|electronAPI" src/renderer/components/RrcPanel.tsx src/renderer -g '*.ts' -g '*.tsx' | head -n 200

echo
echo "== electron API chat implementations =="
rg -n "chat\.|ChatOutbox|outbox\.add|sendTextMessage|TEXT_MESSAGE_APP|schedule|setTimeout|interval|rate" src/main src/shared -g '*.ts' -g '*.tsx' | head -n 240

Repository: Colorado-Mesh/mesh-client

Length of output: 49022


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate App meshtastic send handlers =="
cat -n src/renderer/App.tsx | sed -n '1018,1095p'
cat -n src/renderer/App.tsx | sed -n '1316,1352p'

echo
echo "== locate meshtastic runtime/actions files =="
fd -i 'meshtastic|protocol' src/renderer src/main src/shared | sed -n '1,160p'

echo
echo "== search for Meshtastic send implementation identifiers =="
rg -n "class|function|const .*meshtastic|sendTextMessage|send\\(|TEXT_MESSAGE|PortNum.TEXT_MESSAGE|ToRadio|meshtasticPanelActions\\.send|sendChunk|sendText" src/renderer src/main src/shared -g '*.ts' -g '*.tsx' | head -n 260

Repository: Colorado-Mesh/mesh-client

Length of output: 40335


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== useMeshtasticPanelActions outline and relevant lines =="
ast-grep outline src/renderer/hooks/useMeshtasticPanelActions.ts 2>/dev/null || true
cat -n src/renderer/hooks/useMeshtasticPanelActions.ts | sed -n '1,260p'

echo
echo "== protocol registry meshtastic send references =="
rg -n "send\\(|sendTextMessage|TEXT_MESSAGE|MeshtasticProtocol|meshtasticPanelActions\\.send" src/renderer lib -g '*.ts' -g '*.tsx' | head -n 240

echo
echo "== App handleSend callers =="
rg -n "onSendChunk=|onSend=|handleSendChunk|handleSend\\(" src/renderer/components src/renderer/App.tsx | sed -n '1,260p'

Repository: Colorado-Mesh/mesh-client

Length of output: 18070


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== MeshtasticProtocol sendMessage sections =="
cat -n src/renderer/lib/protocols/MeshtasticProtocol.ts | sed -n '260,320p'
cat -n src/renderer/lib/protocols/MeshtasticProtocol.ts | sed -n '488,518p'
cat -n src/renderer/lib/protocols/MeshtasticProtocol.ts | sed -n '756,810p'

echo
echo "== App protocol message facade around handleSend =="
rg -n "const sendMessage|function sendMessage|sendMessage\\(" src/renderer/App.tsx src/renderer -g '*.ts' -g '*.tsx' | head -n 80
cat -n src/renderer/App.tsx | sed -n '1840,1905p'

echo
echo "== tests about outbox + composer MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS =="
cat -n src/renderer/hooks/useChatOutbox.test.ts | sed -n '160,220p'
cat -n src/renderer/components/ChatComposer.test.tsx | sed -n '320,380p'

Repository: Colorado-Mesh/mesh-client

Length of output: 20942


Use shared Meshtastic pacing for composer sends and outbox rows.

lastMeshtasticSendAtRef in useChatOutbox only covers queued rows drained by that hook instance. ChatComposer.handleSend still checks chunks only inside its own loop, and Meshtastic sends are invoked through MeshtasticProtocol.sendMessagedevice.sendText. If a live composer send and an outbox drain run close together on the same Meshtastic radio, the 2-second TEXT_MESSAGE_APP rate limit can still be violated. Move the last-send timestamp/state to a shared path used by both the Composer send path and useChatOutbox.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/useChatOutbox.ts` around lines 198 - 208, The Meshtastic
pacing state in useChatOutbox is local to one hook and does not coordinate with
ChatComposer sends. Move lastMeshtasticSendAtRef and the associated interval
enforcement into a shared Meshtastic send path used by both
ChatComposer.handleSend and the useChatOutbox drain flow, ensuring every
device.sendText invocation is spaced by MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS
across both paths.

}
} catch (err: unknown) {
console.warn('[useChatOutbox] drainOnce failed', err);
Expand Down
93 changes: 92 additions & 1 deletion src/renderer/lib/chatComposerLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
computeComposerLimitStatus,
computeComposerTotalMaxChars,
countMessageChars,
countMessageWireBytes,
getChatPayloadLimit,
getComposerWireOverhead,
getMeshcoreChannelPayloadLimit,
getMeshcoreRoomPayloadLimit,
MAX_CHUNKS,
MESHCORE_PAYLOAD_LIMIT,
MESHTASTIC_PAYLOAD_LIMIT,
MESHTASTIC_REPLY_ID_WIRE_BYTES,
RETICULUM_LXMF_PAYLOAD_LIMIT,
splitChatMessage,
} from './chatComposerLimits';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down
Loading