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
28 changes: 21 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
import RouteSegmentTraceroutesModal from './components/RouteSegmentTraceroutesModal';

// Fix for default markers in React-Leaflet
delete (L.Icon.Default.prototype as any)._getIconUrl;

Check warning on line 111 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

Unexpected any. Specify a different type

Check warning on line 111 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

Unexpected any. Specify a different type

Check warning on line 111 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

Unexpected any. Specify a different type

Check warning on line 111 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

Unexpected any. Specify a different type

Check warning on line 111 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

Unexpected any. Specify a different type
L.Icon.Default.mergeOptions({
iconRetinaUrl:
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJDOS4yNCAyIDcgNC4yNCA3IDdDNyAxMy40NyAxMiAyMiAxMiAyMkMxMiAyMiAxNyAxMy40NyAxNyA3QzE3IDQuMjQgMTQuNzYgMiAxMiAyWk0xMiA5LjVDMTAuNjIgOS41IDkuNSA4LjM4IDkuNSA3UzkuNTEgNC41IDExIDQuNVMxNS41IDUuNjIgMTUuNSA3UzE0LjM4IDkuNSAxMiA5LjVaIiBmaWxsPSIjZmY2NjY2Ii8+Cjwvc3ZnPg==',
Expand Down Expand Up @@ -529,6 +529,16 @@
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,
Expand Down Expand Up @@ -784,7 +794,7 @@
// Compute connected node name for sidebar and page title
const connectedNodeName = useMemo(() => {
// Find the local node from the nodes array
let localNode = currentNodeId ? nodes.find(n => n.user?.id === currentNodeId) : null;

Check warning on line 797 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

'localNode' is never reassigned. Use 'const' instead

Check warning on line 797 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

'localNode' is never reassigned. Use 'const' instead

Check warning on line 797 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

'localNode' is never reassigned. Use 'const' instead

Check warning on line 797 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

'localNode' is never reassigned. Use 'const' instead

Check warning on line 797 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

'localNode' is never reassigned. Use 'const' instead

// If currentNodeId isn't available, use localNodeInfo from /api/config
if (!localNode && deviceInfo?.localNodeInfo) {
Expand Down Expand Up @@ -864,7 +874,7 @@
(error instanceof DOMException && error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
) {
throw new Error(`Request timeout after ${timeoutMs}ms`);

Check failure on line 877 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

There is no `cause` attached to the symptom error being thrown

Check failure on line 877 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

There is no `cause` attached to the symptom error being thrown

Check failure on line 877 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

There is no `cause` attached to the symptom error being thrown

Check failure on line 877 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

There is no `cause` attached to the symptom error being thrown

Check failure on line 877 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

There is no `cause` attached to the symptom error being thrown
}
throw error;
} finally {
Expand Down Expand Up @@ -1205,7 +1215,7 @@
};

initializeApp();
}, []);

Check warning on line 1218 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

React Hook useEffect has missing dependencies: 'authFetch', 'baseUrl', 'checkConnectionStatus', 'initialBaseUrl', 'setAutoAckChannels', 'setAutoAckCooldownSeconds', 'setAutoAckEnabled', 'setAutoAckIgnoredNodes', 'setAutoAckMatrix', 'setAutoAckMessage', 'setAutoAckMessageDirect', 'setAutoAckRegex', 'setAutoAckSkipIncompleteNodes', 'setAutoAckTestMessages', 'setAutoAnnounceChannelIndexes', 'setAutoAnnounceEnabled', 'setAutoAnnounceIntervalHours', 'setAutoAnnounceMessage', 'setAutoAnnounceNodeInfoChannels', 'setAutoAnnounceNodeInfoDelaySeconds', 'setAutoAnnounceNodeInfoEnabled', 'setAutoAnnounceOnStart', 'setAutoAnnounceSchedule', 'setAutoAnnounceUseSchedule', 'setAutoDeleteByDistanceAction', 'setAutoDeleteByDistanceEnabled', 'setAutoDeleteByDistanceIntervalHours', 'setAutoDeleteByDistanceLat', 'setAutoDeleteByDistanceLon', 'setAutoDeleteByDistanceThresholdKm', 'setAutoKeyManagementAutoPurge', 'setAutoKeyManagementEnabled', 'setAutoKeyManagementImmediatePurge', 'setAutoKeyManagementIntervalMinutes', 'setAutoKeyManagementMaxExchanges', 'setAutoResponderEnabled', 'setAutoResponderSkipIncompleteNodes', 'setAutoResponderTriggers', 'setAutoWelcomeDelay', 'setAutoWelcomeEnabled', 'setAutoWelcomeMaxHops', 'setAutoWelcomeMessage', 'setAutoWelcomeTarget', 'setAutoWelcomeWaitForName', 'setDistanceUnit', 'setError', 'setGeofenceTriggers', 'setInactiveNodeCheckIntervalMinutes', 'setInactiveNodeCooldownHours', 'setInactiveNodeThresholdHours', 'setMaxNodeAgeHours', 'setNodeAddress', 'setShowIncompleteNodes', 'setTelemetryVisualizationHours', 'setTemperatureUnit', 'setTimerTriggers', and 'sourceId'. Either include them or remove the dependency array

Check warning on line 1218 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

React Hook useEffect has missing dependencies: 'authFetch', 'baseUrl', 'checkConnectionStatus', 'initialBaseUrl', 'setAutoAckChannels', 'setAutoAckCooldownSeconds', 'setAutoAckEnabled', 'setAutoAckIgnoredNodes', 'setAutoAckMatrix', 'setAutoAckMessage', 'setAutoAckMessageDirect', 'setAutoAckRegex', 'setAutoAckSkipIncompleteNodes', 'setAutoAckTestMessages', 'setAutoAnnounceChannelIndexes', 'setAutoAnnounceEnabled', 'setAutoAnnounceIntervalHours', 'setAutoAnnounceMessage', 'setAutoAnnounceNodeInfoChannels', 'setAutoAnnounceNodeInfoDelaySeconds', 'setAutoAnnounceNodeInfoEnabled', 'setAutoAnnounceOnStart', 'setAutoAnnounceSchedule', 'setAutoAnnounceUseSchedule', 'setAutoDeleteByDistanceAction', 'setAutoDeleteByDistanceEnabled', 'setAutoDeleteByDistanceIntervalHours', 'setAutoDeleteByDistanceLat', 'setAutoDeleteByDistanceLon', 'setAutoDeleteByDistanceThresholdKm', 'setAutoKeyManagementAutoPurge', 'setAutoKeyManagementEnabled', 'setAutoKeyManagementImmediatePurge', 'setAutoKeyManagementIntervalMinutes', 'setAutoKeyManagementMaxExchanges', 'setAutoResponderEnabled', 'setAutoResponderSkipIncompleteNodes', 'setAutoResponderTriggers', 'setAutoWelcomeDelay', 'setAutoWelcomeEnabled', 'setAutoWelcomeMaxHops', 'setAutoWelcomeMessage', 'setAutoWelcomeTarget', 'setAutoWelcomeWaitForName', 'setDistanceUnit', 'setError', 'setGeofenceTriggers', 'setInactiveNodeCheckIntervalMinutes', 'setInactiveNodeCooldownHours', 'setInactiveNodeThresholdHours', 'setMaxNodeAgeHours', 'setNodeAddress', 'setShowIncompleteNodes', 'setTelemetryVisualizationHours', 'setTemperatureUnit', 'setTimerTriggers', and 'sourceId'. Either include them or remove the dependency array

Check warning on line 1218 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

React Hook useEffect has missing dependencies: 'authFetch', 'baseUrl', 'checkConnectionStatus', 'initialBaseUrl', 'setAutoAckChannels', 'setAutoAckCooldownSeconds', 'setAutoAckEnabled', 'setAutoAckIgnoredNodes', 'setAutoAckMatrix', 'setAutoAckMessage', 'setAutoAckMessageDirect', 'setAutoAckRegex', 'setAutoAckSkipIncompleteNodes', 'setAutoAckTestMessages', 'setAutoAnnounceChannelIndexes', 'setAutoAnnounceEnabled', 'setAutoAnnounceIntervalHours', 'setAutoAnnounceMessage', 'setAutoAnnounceNodeInfoChannels', 'setAutoAnnounceNodeInfoDelaySeconds', 'setAutoAnnounceNodeInfoEnabled', 'setAutoAnnounceOnStart', 'setAutoAnnounceSchedule', 'setAutoAnnounceUseSchedule', 'setAutoDeleteByDistanceAction', 'setAutoDeleteByDistanceEnabled', 'setAutoDeleteByDistanceIntervalHours', 'setAutoDeleteByDistanceLat', 'setAutoDeleteByDistanceLon', 'setAutoDeleteByDistanceThresholdKm', 'setAutoKeyManagementAutoPurge', 'setAutoKeyManagementEnabled', 'setAutoKeyManagementImmediatePurge', 'setAutoKeyManagementIntervalMinutes', 'setAutoKeyManagementMaxExchanges', 'setAutoResponderEnabled', 'setAutoResponderSkipIncompleteNodes', 'setAutoResponderTriggers', 'setAutoWelcomeDelay', 'setAutoWelcomeEnabled', 'setAutoWelcomeMaxHops', 'setAutoWelcomeMessage', 'setAutoWelcomeTarget', 'setAutoWelcomeWaitForName', 'setDistanceUnit', 'setError', 'setGeofenceTriggers', 'setInactiveNodeCheckIntervalMinutes', 'setInactiveNodeCooldownHours', 'setInactiveNodeThresholdHours', 'setMaxNodeAgeHours', 'setNodeAddress', 'setShowIncompleteNodes', 'setTelemetryVisualizationHours', 'setTemperatureUnit', 'setTimerTriggers', and 'sourceId'. Either include them or remove the dependency array

Check warning on line 1218 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

React Hook useEffect has missing dependencies: 'authFetch', 'baseUrl', 'checkConnectionStatus', 'initialBaseUrl', 'setAutoAckChannels', 'setAutoAckCooldownSeconds', 'setAutoAckEnabled', 'setAutoAckIgnoredNodes', 'setAutoAckMatrix', 'setAutoAckMessage', 'setAutoAckMessageDirect', 'setAutoAckRegex', 'setAutoAckSkipIncompleteNodes', 'setAutoAckTestMessages', 'setAutoAnnounceChannelIndexes', 'setAutoAnnounceEnabled', 'setAutoAnnounceIntervalHours', 'setAutoAnnounceMessage', 'setAutoAnnounceNodeInfoChannels', 'setAutoAnnounceNodeInfoDelaySeconds', 'setAutoAnnounceNodeInfoEnabled', 'setAutoAnnounceOnStart', 'setAutoAnnounceSchedule', 'setAutoAnnounceUseSchedule', 'setAutoDeleteByDistanceAction', 'setAutoDeleteByDistanceEnabled', 'setAutoDeleteByDistanceIntervalHours', 'setAutoDeleteByDistanceLat', 'setAutoDeleteByDistanceLon', 'setAutoDeleteByDistanceThresholdKm', 'setAutoKeyManagementAutoPurge', 'setAutoKeyManagementEnabled', 'setAutoKeyManagementImmediatePurge', 'setAutoKeyManagementIntervalMinutes', 'setAutoKeyManagementMaxExchanges', 'setAutoResponderEnabled', 'setAutoResponderSkipIncompleteNodes', 'setAutoResponderTriggers', 'setAutoWelcomeDelay', 'setAutoWelcomeEnabled', 'setAutoWelcomeMaxHops', 'setAutoWelcomeMessage', 'setAutoWelcomeTarget', 'setAutoWelcomeWaitForName', 'setDistanceUnit', 'setError', 'setGeofenceTriggers', 'setInactiveNodeCheckIntervalMinutes', 'setInactiveNodeCooldownHours', 'setInactiveNodeThresholdHours', 'setMaxNodeAgeHours', 'setNodeAddress', 'setShowIncompleteNodes', 'setTelemetryVisualizationHours', 'setTemperatureUnit', 'setTimerTriggers', and 'sourceId'. Either include them or remove the dependency array

Check warning on line 1218 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

React Hook useEffect has missing dependencies: 'authFetch', 'baseUrl', 'checkConnectionStatus', 'initialBaseUrl', 'setAutoAckChannels', 'setAutoAckCooldownSeconds', 'setAutoAckEnabled', 'setAutoAckIgnoredNodes', 'setAutoAckMatrix', 'setAutoAckMessage', 'setAutoAckMessageDirect', 'setAutoAckRegex', 'setAutoAckSkipIncompleteNodes', 'setAutoAckTestMessages', 'setAutoAnnounceChannelIndexes', 'setAutoAnnounceEnabled', 'setAutoAnnounceIntervalHours', 'setAutoAnnounceMessage', 'setAutoAnnounceNodeInfoChannels', 'setAutoAnnounceNodeInfoDelaySeconds', 'setAutoAnnounceNodeInfoEnabled', 'setAutoAnnounceOnStart', 'setAutoAnnounceSchedule', 'setAutoAnnounceUseSchedule', 'setAutoDeleteByDistanceAction', 'setAutoDeleteByDistanceEnabled', 'setAutoDeleteByDistanceIntervalHours', 'setAutoDeleteByDistanceLat', 'setAutoDeleteByDistanceLon', 'setAutoDeleteByDistanceThresholdKm', 'setAutoKeyManagementAutoPurge', 'setAutoKeyManagementEnabled', 'setAutoKeyManagementImmediatePurge', 'setAutoKeyManagementIntervalMinutes', 'setAutoKeyManagementMaxExchanges', 'setAutoResponderEnabled', 'setAutoResponderSkipIncompleteNodes', 'setAutoResponderTriggers', 'setAutoWelcomeDelay', 'setAutoWelcomeEnabled', 'setAutoWelcomeMaxHops', 'setAutoWelcomeMessage', 'setAutoWelcomeTarget', 'setAutoWelcomeWaitForName', 'setDistanceUnit', 'setError', 'setGeofenceTriggers', 'setInactiveNodeCheckIntervalMinutes', 'setInactiveNodeCooldownHours', 'setInactiveNodeThresholdHours', 'setMaxNodeAgeHours', 'setNodeAddress', 'setShowIncompleteNodes', 'setTelemetryVisualizationHours', 'setTemperatureUnit', 'setTimerTriggers', and 'sourceId'. Either include them or remove the dependency array

// Check for default admin password
// Check for configuration issues
Expand All @@ -1223,7 +1233,7 @@
};

checkConfigIssues();
}, [baseUrl]);

Check warning on line 1236 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

React Hook useEffect has a missing dependency: 'authFetch'. Either include it or remove the dependency array

Check warning on line 1236 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

React Hook useEffect has a missing dependency: 'authFetch'. Either include it or remove the dependency array

Check warning on line 1236 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

React Hook useEffect has a missing dependency: 'authFetch'. Either include it or remove the dependency array

Check warning on line 1236 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

React Hook useEffect has a missing dependency: 'authFetch'. Either include it or remove the dependency array

Check warning on line 1236 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

React Hook useEffect has a missing dependency: 'authFetch'. Either include it or remove the dependency array

// TX status is now handled by useTxStatus hook

Expand Down Expand Up @@ -1284,7 +1294,7 @@
checkForUpdates(interval);

return () => clearInterval(interval);
}, [baseUrl]);

Check warning on line 1297 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (22.x)

React Hook useEffect has missing dependencies: 'authFetch', 'pollUpgradeStatus', and 'upgradeInProgress'. Either include them or remove the dependency array

Check warning on line 1297 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Quick Tests

React Hook useEffect has missing dependencies: 'authFetch', 'pollUpgradeStatus', and 'upgradeInProgress'. Either include them or remove the dependency array

Check warning on line 1297 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (25.x)

React Hook useEffect has missing dependencies: 'authFetch', 'pollUpgradeStatus', and 'upgradeInProgress'. Either include them or remove the dependency array

Check warning on line 1297 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (20.x)

React Hook useEffect has missing dependencies: 'authFetch', 'pollUpgradeStatus', and 'upgradeInProgress'. Either include them or remove the dependency array

Check warning on line 1297 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Test Suite (24.x)

React Hook useEffect has missing dependencies: 'authFetch', 'pollUpgradeStatus', and 'upgradeInProgress'. Either include them or remove the dependency array

// Check if auto-upgrade is enabled and if an upgrade is already in progress
useEffect(() => {
Expand Down Expand Up @@ -2584,12 +2594,16 @@
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;
Expand Down Expand Up @@ -5415,8 +5429,8 @@
<SettingsProvider baseUrl={initialBaseUrl}>
<MapProvider>
<DataProvider>
<MessagingProvider baseUrl={initialBaseUrl}>
<UIProvider>
<UIProvider>
<MessagingProvider baseUrl={initialBaseUrl}>
<AutomationProvider baseUrl={initialBaseUrl}>
<ToastProvider>
<DeviceNotificationToaster />
Expand All @@ -5425,8 +5439,8 @@
</SaveBarProvider>
</ToastProvider>
</AutomationProvider>
</UIProvider>
</MessagingProvider>
</MessagingProvider>
</UIProvider>
</DataProvider>
</MapProvider>
</SettingsProvider>
Expand Down
4 changes: 2 additions & 2 deletions src/components/HopCountDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
}
return (
<>
<span style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 0.7 }} title={t('messages.signal_info')}>
<span style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 0.85 }} title={t('messages.signal_info')}>
({parts.join(' / ')})
</span>
{StoreForwardIndicator}
Expand All @@ -103,7 +103,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
return (
<>
<span
style={{ fontSize: '0.75em', marginLeft: '4px', opacity: isClickable ? 1 : 0.7, ...clickableStyle }}
style={{ fontSize: '0.75em', marginLeft: '4px', opacity: isClickable ? 1 : 0.85, ...clickableStyle }}
onClick={isClickable ? onClick : undefined}
title={isClickable ? t('messages.click_for_relay') : undefined}
>
Expand Down
7 changes: 7 additions & 0 deletions src/components/MeshCore/MeshCoreChannelsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,13 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
}}
onNodeNameClick={onNodeNameClick}
conversationKey={`channel-${active.id}`}
maxBytes={
showScopeOverride && overrideScope !== null && overrideScope !== ''
? 120
: resolvedScope
? 120
: 130
}
/>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/components/MeshCore/MeshCoreDirectMessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
emptyText={t('meshcore.no_messages', 'No messages with this contact yet')}
onSend={text => actions.sendMessage(text, selected)}
conversationKey={`dm-${selected}`}
maxBytes={150}
/>
)}
<div className="meshcore-detail-pane">
Expand Down
22 changes: 19 additions & 3 deletions src/components/MeshCore/MeshCoreMessageStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -60,6 +63,7 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
onSend,
onNodeNameClick,
conversationKey,
maxBytes = 130,
}) => {
const { t } = useTranslation();
const [draft, setDraft] = useState('');
Expand Down Expand Up @@ -164,8 +168,16 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
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);
Expand Down Expand Up @@ -338,15 +350,19 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
onKeyDown={handleKeyDown}
placeholder={t('meshcore.type_message', 'Type a message…')}
disabled={disabled || sending}
maxLength={230}
/>
<button
onClick={() => void handleSend()}
disabled={disabled || sending || !draft.trim()}
disabled={disabled || sending || !draft.trim() || overLimit}
>
{sending ? t('meshcore.sending', 'Sending…') : t('meshcore.send', 'Send')}
</button>
</div>
{draft.length > 0 && (
<div className={`meshcore-byte-counter ${byteCounter.className}`}>
{byteCounter.text}
</div>
)}
</div>
);
};
13 changes: 13 additions & 0 deletions src/components/MeshCore/MeshCorePage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/contexts/MessagingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -45,6 +46,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({ 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<string>('');
const [selectedChannel, setSelectedChannel] = useState<number>(-1);
Expand All @@ -55,11 +57,14 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({ 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 });

Expand Down
34 changes: 34 additions & 0 deletions src/db/repositories/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion src/db/repositories/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/hooks/useUnreadCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<UnreadCountsData> => {
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',
Expand Down
2 changes: 1 addition & 1 deletion src/server/routes/meshcoreRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
33 changes: 26 additions & 7 deletions src/server/routes/meshcoreRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 });
}
Expand Down
3 changes: 2 additions & 1 deletion src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading