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
35 changes: 35 additions & 0 deletions src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,41 @@ describe('Sidebar', () => {
expect(screen.queryByText('0')).not.toBeInTheDocument();
});

it('caps Remote pending-offer badge at 99+ with matching aria', () => {
render(
<Sidebar
tabs={['Remote']}
tabSlotIds={['Remote']}
active={0}
onChange={vi.fn()}
remotePendingOffers={150}
collapsed={false}
onToggle={vi.fn()}
/>,
);
expect(screen.getByText('99+')).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: '99+ pending inbound file offers' }),
).toBeInTheDocument();
});

it('has no axe violations when Remote pending-offer badge shows', async () => {
render(
<Sidebar
tabs={['Remote']}
tabSlotIds={['Remote']}
active={0}
onChange={vi.fn()}
remotePendingOffers={2}
collapsed={false}
onToggle={vi.fn()}
/>,
);
const badge = screen.getByText('2');
hydrateAxeThemeColors(badge);
expect(await axe(badge)).toHaveNoViolations();
});

it('hides RRC unread badge when rrcUnread is 0', () => {
const onChange = vi.fn();
render(
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ export default function Sidebar({
const showBadge = showChatBadge || showRoomsBadge || showRrcBadge || showRemoteBadge;
const tabAriaLabel = showBadge
? showRemoteBadge
? t('reticulumRemote.transfer.pendingOffersBadgeAria', {
count: badgeCount > 99 ? 99 : badgeCount,
})
? badgeCount > 99
? t('reticulumRemote.transfer.pendingOffersBadgeAriaCapped')
: t('reticulumRemote.transfer.pendingOffersBadgeAria', { count: badgeCount })
: t('aria.tabWithUnread', {
label: displayLabel,
count: badgeCount > 99 ? '99+' : badgeCount,
Expand Down Expand Up @@ -120,7 +120,7 @@ export default function Sidebar({
{showBadge && (
<span
className={`absolute -top-1.5 -right-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px] font-bold text-white ${
showRemoteBadge ? 'bg-amber-600' : 'bg-red-600'
showRemoteBadge ? 'bg-amber-800' : 'bg-red-600'
}`}
>
{badgeCount > 99 ? '99+' : badgeCount}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { axe } from 'vitest-axe';

import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers';
import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore';
import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore';

Expand Down Expand Up @@ -52,11 +54,13 @@ describe('ChatDmRncpOfferBanner', () => {
});

const user = userEvent.setup();
render(<ChatDmRncpOfferBanner lxmfPeerHash={PEER_HASH} />);
const { container } = render(<ChatDmRncpOfferBanner lxmfPeerHash={PEER_HASH} />);

expect(screen.getByText('Incoming file offers')).toBeInTheDocument();
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
expect(screen.queryByText('other.txt')).not.toBeInTheDocument();
hydrateAxeThemeColors(container);
expect(await axe(container)).toHaveNoViolations();

await user.click(screen.getByRole('button', { name: 'Accept photo.jpg' }));
expect(window.electronAPI.reticulum.rncp.accept).toHaveBeenCalledWith({ transfer_id: 't1' });
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/components/remote/RemoteTransferSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ describe('RemoteTransferSection', () => {
expect(window.electronAPI.reticulum.rncp.send).not.toHaveBeenCalled();
expect(sendRncpRequestEnable).not.toHaveBeenCalled();
expect(useRncpTransferStore.getState().transfers.get('failed-2')?.retryCount).toBe(0);
expect(addToast).toHaveBeenCalledWith(
'No path to their file receive destination; file receiving may be off. Ask them to enable rncp receive and send their hash, then try again.',
'error',
);
});

it('sends when the receive dest is reachable', async () => {
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/components/remote/RemoteTransferSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ export function RemoteTransferSection({
lxmfPeerHash: resolveLxmfPeerHash(destinationHash),
});
if (reach.status === 'reachable') return true;
if (reach.status === 'listenerLikelyOff' && opts.promptEnable) {
setEnableRequestConfirmOpen(true);
if (reach.status === 'listenerLikelyOff') {
if (opts.promptEnable) {
setEnableRequestConfirmOpen(true);
} else {
addToast(t('reticulumRemote.transfer.listenerLikelyOffToast'), 'error');
}
return false;
}
addToast(t('reticulumRemote.transfer.peerUnreachable'), 'error');
Expand Down
67 changes: 67 additions & 0 deletions src/renderer/components/remote/RncpEnableRequestModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ vi.mock('@/renderer/components/Toast', () => ({
useToast: () => ({ addToast }),
}));

vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({
probeReticulumPeer: vi.fn().mockResolvedValue({ ok: false }),
}));

describe('RncpEnableRequestModal', () => {
beforeEach(() => {
addToast.mockReset();
Expand Down Expand Up @@ -108,4 +112,67 @@ describe('RncpEnableRequestModal', () => {
});
expect(useRncpEnableRequestStore.getState().prompts).toHaveLength(0);
});

it('auto-dismisses a repeat enable-request while already listening', async () => {
vi.mocked(window.electronAPI.reticulum.rncp.getListener).mockResolvedValue({
enabled: true,
inbound_mode: 'ask',
allowed: [],
blocked: [],
});
const { rerender } = render(<RncpEnableRequestModal />);
await waitFor(() => {
expect(useRncpEnableRequestStore.getState().prompts).toHaveLength(0);
});

useRncpEnableRequestStore.getState().enqueue({
peerHash: 'a'.repeat(32),
peerLabel: 'Alice',
receivedAt: Date.now(),
});
rerender(<RncpEnableRequestModal />);
await waitFor(() => {
expect(useRncpEnableRequestStore.getState().prompts).toHaveLength(0);
});
expect(
vi.mocked(window.electronAPI.reticulum.proxyPost).mock.calls.length,
).toBeGreaterThanOrEqual(2);
});

it('enables Ask and still shares when Always allow cannot resolve identity', async () => {
vi.mocked(window.electronAPI.reticulum.rncp.getListener)
.mockResolvedValueOnce({
enabled: false,
inbound_mode: 'off',
allowed: [],
blocked: [],
})
.mockResolvedValue({
enabled: true,
inbound_mode: 'ask',
allowed: [],
blocked: [],
});
const user = userEvent.setup();
render(<RncpEnableRequestModal />);
await user.click(
screen.getByRole('button', {
name: 'Enable inbound file offers and allow this sender',
}),
);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith('File receiving is enabled.', 'success');
});
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('identity hash is not known yet'),
'info',
);
await waitFor(() => {
expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith('/api/v1/lxmf/send', {
destination_hash: 'a'.repeat(32),
text: expect.stringContaining(`${RNCP_RECEIVE_DEST_SHARE_PREFIX}${'c'.repeat(32)}`),
});
});
expect(useRncpEnableRequestStore.getState().prompts).toHaveLength(0);
});
});
54 changes: 39 additions & 15 deletions src/renderer/components/remote/RncpEnableRequestModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';

import { useToast } from '@/renderer/components/Toast';
import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString';
import { rememberRncpListenerDirs } from '@/renderer/lib/pushRncpListenerPolicy';
import { probeReticulumPeer } from '@/renderer/lib/reticulum/reticulumSidecarReads';
import { policiesToRncpLists } from '@/renderer/lib/rncpInboundPolicyLists';
import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore';
import { useReticulumInboundPolicyStore } from '@/renderer/stores/reticulumInboundPolicyStore';
import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore';
import { useRncpEnableRequestStore } from '@/renderer/stores/rncpEnableRequestStore';
import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore';
import { canonicalizeReticulumDestinationHash } from '@/shared/reticulumDestinationHash';
Expand All @@ -16,19 +18,40 @@ import { buildRncpReceiveDestShareBody } from '@/shared/rncpRequestEnable';
* Resolve a Reticulum **identity** hash for an LXMF delivery destination hash.
* rncp LinkIdentify gates on identity_hash, which is not the LXMF sender dest.
*/
async function resolveIdentityHashForLxmfPeer(peerDestHash: string): Promise<string | null> {
export async function resolveIdentityHashForLxmfPeer(peerDestHash: string): Promise<string | null> {
const dest = canonicalizeReticulumDestinationHash(peerDestHash);
if (!dest) return null;

const fromRows = (rows: { identity_hash?: string | null }[]): string | null => {
for (const row of rows) {
const id = row.identity_hash ? canonicalizeReticulumDestinationHash(row.identity_hash) : null;
if (id) return id;
}
return null;
};

const store = useReticulumIdentityActivityStore.getState();
let rows = store.getActivity(dest);
if (rows.length === 0) {
rows = await store.loadForDestination(dest);
}
for (const row of rows) {
const id = row.identity_hash ? canonicalizeReticulumDestinationHash(row.identity_hash) : null;
const fromActivity = fromRows(rows);
if (fromActivity) return fromActivity;

const saved = useReticulumRemoteAddressStore.getState().findByLxmfPeer(dest);
if (saved?.identity_hash) {
const id = canonicalizeReticulumDestinationHash(saved.identity_hash);
if (id) return id;
}
return null;

// Path/probe can surface announce identity for peers we have only chatted with.
try {
await probeReticulumPeer(dest);
} catch {
// catch-no-log-ok probe is best-effort before allow-list upsert
}
rows = await store.loadForDestination(dest);
return fromRows(rows);
}

/**
Expand Down Expand Up @@ -92,33 +115,33 @@ export function RncpEnableRequestModal() {
const setListener = useRncpTransferStore((s) => s.setListener);

const current = prompts[0] ?? null;
const autoSharedPeerRef = useRef<string | null>(null);

// If inbound rncp is already enabled, re-share our receive dest immediately so the
// requester does not stay empty when the peer thinks they are "already enabled".
// Already listening: re-share dest and clear the prompt so repeat enable-requests
// from the same peer do not leave a sticky modal (do not latch a one-shot peer ref).
useEffect(() => {
if (!current) return;
const peerHash = current.peerHash;
if (autoSharedPeerRef.current === peerHash) return;
let cancelled = false;
void (async () => {
try {
const status = await window.electronAPI.reticulum.rncp.getListener();
if (cancelled) return;
setListener(status);
if (!status?.enabled) return;
autoSharedPeerRef.current = peerHash;
const shareResult = await shareRncpReceiveDestWithPeer(
await shareRncpReceiveDestWithPeer(
peerHash,
t('reticulumRemote.enableRequest.lxmfShareBody'),
);
if (!cancelled && shareResult === 'shared') {
if (!cancelled) {
dismiss(peerHash, false);
}
} catch (e) {
console.debug(
'[RncpEnableRequestModal] already-enabled auto-share ' + errLikeToLogString(e),
);
if (!cancelled) {
dismiss(peerHash, false);
}
}
})();
return () => {
Expand All @@ -139,9 +162,7 @@ export function RncpEnableRequestModal() {
let identityHash: string | null = null;
if (allowIdentity) {
identityHash = await resolveIdentityHashForLxmfPeer(current.peerHash);
if (!identityHash) {
addToast(t('reticulumRemote.enableRequest.identityUnknown'), 'info');
} else {
if (identityHash) {
await upsertPolicy({
identity_hash: identityHash,
decision: 'allow',
Expand Down Expand Up @@ -177,6 +198,9 @@ export function RncpEnableRequestModal() {
const listener = await window.electronAPI.reticulum.rncp.getListener();
useRncpTransferStore.getState().setListener(listener);
addToast(t('reticulumRemote.enableRequest.enabled'), 'success');
if (allowIdentity && !identityHash) {
addToast(t('reticulumRemote.enableRequest.identityUnknown'), 'info');
}
const peerHash = current.peerHash;
dismiss(peerHash, false);
// Best-effort: tell the requester our rncp.receive dest so they can autofill.
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/lib/sendRncpRequestEnable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ describe('sendRncpRequestEnable', () => {
expect(window.electronAPI.reticulum.proxyPost).not.toHaveBeenCalled();
});

it('posts destination_hash and text (sidecar field name) with sentinel', async () => {
it('posts destination_hash and text with human body plus sentinel', async () => {
const hash = 'ab'.repeat(16);
await expect(sendRncpRequestEnable(hash)).resolves.toEqual({ ok: true });
expect(window.electronAPI.reticulum.proxyPost).toHaveBeenCalledWith('/api/v1/lxmf/send', {
destination_hash: hash,
text: expect.stringContaining(RNCP_REQUEST_ENABLE_SENTINEL),
text: `reticulumRemote.enableRequest.lxmfBody\n\n${RNCP_REQUEST_ENABLE_SENTINEL}`,
});
});

Expand Down
12 changes: 7 additions & 5 deletions src/renderer/locales/cs/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -4886,8 +4886,10 @@
"peerUnreachable": "Žádná cesta k tomuto cíli. Partner může být offline.",
"checkingReachability": "Kontrola dosažitelnosti…",
"listenerLikelyOffTitle": "Příjem souborů může být vypnutý",
"listenerLikelyOffBody": "Žádná cesta k cílovému umístění jejich souboru, ale hledají online chat. Požádejte je, aby povolili příjem souborů (Vzdálené → Nastavení → Nabídky příchozích souborů), a zkuste to znovu.",
"listenerLikelyOffConfirm": "Odeslat požadavek na povolení"
"listenerLikelyOffConfirm": "Odeslat požadavek na povolení",
"pendingOffersBadgeAriaCapped": "99+ čekajících nabídek příchozích souborů",
"listenerLikelyOffBody": "Žádná cesta k cílovému umístění jejich souboru, ale hledají online chat. Požádejte je, aby povolili příjem souborů rncp a odeslali svůj hash pro příjem rncp, pak to zkuste znovu.",
"listenerLikelyOffToast": "Žádná cesta k cíli příjmu jejich souboru; příjem souborů může být vypnutý. Požádejte je, aby povolili rncp přijímat a odesílat svůj hash, a zkuste to znovu."
},
"enableRequest": {
"title": "Povolit příjem souborů?",
Expand All @@ -4903,10 +4905,10 @@
"enabled": "Příjem souboru je povolen.",
"enableFailed": "Nelze povolit příjem souboru: {{error}}",
"saveDirRequired": "Chcete-li povolit příjem, vyberte složku pro uložení.",
"identityUnknown": "Hash identity tohoto peera se zatím nepodařilo zjistit — příchozí dotazování je povoleno, ale peer nebyl přidán na seznam povolených. Zkuste to znovu, až dorazí cesta nebo oznámení.",
"lxmfShareBody": "Příjem souborů je povolen. Zde je můj rncp receive destination (mesh-client jej pro vás uloží).",
"shareDestWarning": "Pokud povolíte, mesh-client odešle váš cíl příjmu rncp tomuto partnerovi, aby mohl automaticky vyplnit soubor.",
"lxmfBody": "Povolte příjem rncp (Vzdálené → Nastavení → Nabídky příchozích souborů) a pošlete mi svůj hash pro příjem rncp."
"lxmfBody": "Povolte příjem souboru rncp a odpovězte pomocí cílového hash pro příjem rncp.",
"lxmfShareBody": "Příjem souborů je povolen. Můj rncp přijímá cílový hash:",
"identityUnknown": "Příchozí dotaz je zapnutý, ale hash identity tohoto partnera zatím není znám, takže nebyl přidán do Vždy povolit. Před každým souborem budete stále dotázáni; zkuste Vždy znovu povolit po příchodu cesty nebo oznámení."
},
"saved": {
"labelPlaceholder": "Označení",
Expand Down
12 changes: 7 additions & 5 deletions src/renderer/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -4884,8 +4884,10 @@
"peerUnreachable": "Kein Weg zu diesem Ziel. Der Peer ist möglicherweise offline.",
"checkingReachability": "Erreichbarkeit prüfen…",
"listenerLikelyOffTitle": "Möglicherweise ist der Dateiempfang deaktiviert",
"listenerLikelyOffBody": "Kein Pfad zu ihrem Dateiempfangsziel, aber sie suchen online nach Chat. Bitten Sie sie, den Dateiempfang zu aktivieren (Remote → Einstellungen → Angebote für eingehende Dateien) und versuchen Sie es dann erneut.",
"listenerLikelyOffConfirm": "Aktivierungsanfrage senden"
"listenerLikelyOffConfirm": "Aktivierungsanfrage senden",
"pendingOffersBadgeAriaCapped": "Über 99 ausstehende Angebote für eingehende Dateien",
"listenerLikelyOffBody": "Kein Pfad zu ihrem Dateiempfangsziel, aber sie suchen online nach Chat. Bitten Sie sie, den Empfang von RNCP-Dateien zu aktivieren und ihren RNCP-Empfangs-Hash zu senden, und versuchen Sie es dann erneut.",
"listenerLikelyOffToast": "Kein Pfad zu ihrem Dateiempfangsziel; Der Dateiempfang ist möglicherweise deaktiviert. Bitten Sie sie, den RNCP-Empfang und das Senden ihres Hashs zu aktivieren, und versuchen Sie es dann erneut."
},
"enableRequest": {
"title": "Dateiempfang aktivieren?",
Expand All @@ -4901,10 +4903,10 @@
"enabled": "Der Dateiempfang ist aktiviert.",
"enableFailed": "Dateiempfang konnte nicht aktiviert werden: {{error}}",
"saveDirRequired": "Wählen Sie einen Speicherordner aus, um den Empfang zu aktivieren.",
"identityUnknown": "Der Identitäts-Hash dieses Peers konnte noch nicht aufgelöst werden — eingehendes Nachfragen ist aktiviert, aber der Peer wurde nicht zur Erlaubnisliste hinzugefügt. Versuche es erneut, sobald ein Pfad oder ein Announce eintrifft.",
"lxmfShareBody": "Dateiempfang ist aktiviert. Hier ist mein rncp-Empfangsziel (mesh-client speichert es für Sie).",
"shareDestWarning": "Wenn Sie diese Option aktivieren, sendet der Mesh-Client Ihr RNCP-Empfangsziel an diesen Peer, damit dieser den Dateiversand automatisch ausfüllen kann.",
"lxmfBody": "Bitte aktivieren Sie den rncp-Empfang (Remote → Einstellungen → Angebote für eingehende Dateien) und senden Sie mir Ihren rncp-Empfangs-Hash."
"lxmfBody": "Bitte aktivieren Sie den Empfang von RNCP-Dateien und antworten Sie mit Ihrem RNCP-Empfangsziel-Hash.",
"lxmfShareBody": "Der Dateiempfang ist aktiviert. Mein RNCP-Empfangsziel-Hash:",
"identityUnknown": "„Eingehende Anfragen“ ist aktiviert, aber der Identitäts-Hash dieses Peers ist noch nicht bekannt, sodass er nicht zu „Immer zulassen“ hinzugefügt wurde. Sie werden weiterhin vor jeder Datei gefragt; Versuchen Sie „Immer zulassen“ erneut, nachdem ein Pfad oder eine Ankündigung eintrifft."
},
"saved": {
"labelPlaceholder": "Etikett",
Expand Down
Loading