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
7 changes: 7 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ import { usePathHistoryStore } from './stores/pathHistoryStore';
import { usePositionHistoryStore } from './stores/positionHistoryStore';
import { useReticulumIdentityStore } from './stores/reticulumIdentityStore';
import { useReticulumPeerStore } from './stores/reticulumPeerStore';
import { useRncpTransferStore } from './stores/rncpTransferStore';
import { useRrcSessionStore } from './stores/rrcSessionStore';

// Tabs capability filtering lives in appTabMappings.ts (computeTabMappings).
Expand Down Expand Up @@ -1290,6 +1291,7 @@ function AppContent() {
void rrcSessionsByHub;
return useRrcSessionStore.getState().totalUnread();
}, [rrcUnreadByRoom, rrcUnreadByHub, rrcSessionsByHub]);
const remotePendingOffers = useRncpTransferStore((s) => s.pendingOffers.size);
const rrcMessageFlat = useMemo(() => {
const out: RrcChatMessage[] = [];
for (const list of rrcMessages.values()) out.push(...list);
Expand Down Expand Up @@ -2836,6 +2838,11 @@ function AppContent() {
chatUnread={chatUnread}
roomsUnread={roomsUnread}
rrcUnread={rrcUnread}
remotePendingOffers={
protocol === 'reticulum' && capabilities.hasReticulumRemotePanel
? remotePendingOffers
: 0
}
collapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
/>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ import { ChatPayloadText } from './ChatPayloadText';
import { HelpTooltip } from './HelpTooltip';
import { MessageStatusBadge } from './MessageStatusBadge';
import { ChatDmRncpControl } from './remote/ChatDmRncpControl';
import { ChatDmRncpOfferBanner } from './remote/ChatDmRncpOfferBanner';
import { ReticulumAttachmentLine } from './ReticulumAttachmentLine';
import {
ReticulumDmPathActions,
Expand Down Expand Up @@ -3035,6 +3036,12 @@ function ChatPanel({
/>

{/* Compose emoji picker — Linux only; macOS/Windows use native showEmojiPanel() */}
{protocol === 'reticulum' &&
hasRncpTransfer &&
isDmMode &&
reticulumDmDestinationHash != null && (
<ChatDmRncpOfferBanner lxmfPeerHash={reticulumDmDestinationHash} />
)}
<ChatComposer
className="mt-1"
protocol={protocol}
Expand Down
33 changes: 33 additions & 0 deletions src/renderer/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,39 @@ describe('Sidebar', () => {
expect(screen.getByRole('tab', { name: 'RRC, 7 unread' })).toBeInTheDocument();
});

it('shows Remote pending-offer badge when remotePendingOffers > 0', () => {
const onChange = vi.fn();
render(
<Sidebar
tabs={['Remote']}
tabSlotIds={['Remote']}
active={0}
onChange={onChange}
remotePendingOffers={2}
collapsed={false}
onToggle={vi.fn()}
/>,
);
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /2 pending inbound file offers/i })).toBeInTheDocument();
});

it('hides Remote badge when remotePendingOffers is 0', () => {
render(
<Sidebar
tabs={['Remote']}
tabSlotIds={['Remote']}
active={0}
onChange={vi.fn()}
remotePendingOffers={0}
collapsed={false}
onToggle={vi.fn()}
/>,
);
expect(screen.getByRole('tab', { name: 'Remote' })).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});

it('hides RRC unread badge when rrcUnread is 0', () => {
const onChange = vi.fn();
render(
Expand Down
28 changes: 21 additions & 7 deletions src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface SidebarProps {
roomsUnread?: number;
/** Unread RRC message count for RRC tab badge; 0 hides badge */
rrcUnread?: number;
/** Pending rncp inbound offers for Remote tab badge; 0 hides badge */
remotePendingOffers?: number;
/** Set of tab indices that are disabled (greyed out, non-clickable) */
disabledTabs?: Set<number>;
collapsed: boolean;
Expand All @@ -33,6 +35,7 @@ export default function Sidebar({
chatUnread = 0,
roomsUnread = 0,
rrcUnread = 0,
remotePendingOffers = 0,
disabledTabs,
collapsed,
onToggle,
Expand Down Expand Up @@ -64,19 +67,26 @@ export default function Sidebar({
const showChatBadge = slotId === 'Chat' && chatUnread > 0;
const showRoomsBadge = slotId === 'Rooms' && roomsUnread > 0;
const showRrcBadge = slotId === 'RRC' && rrcUnread > 0;
const showRemoteBadge = slotId === 'Remote' && remotePendingOffers > 0;
const badgeCount = showChatBadge
? chatUnread
: showRoomsBadge
? roomsUnread
: showRrcBadge
? rrcUnread
: 0;
const showBadge = showChatBadge || showRoomsBadge || showRrcBadge;
: showRemoteBadge
? remotePendingOffers
: 0;
const showBadge = showChatBadge || showRoomsBadge || showRrcBadge || showRemoteBadge;
const tabAriaLabel = showBadge
? t('aria.tabWithUnread', {
label: displayLabel,
count: badgeCount > 99 ? '99+' : badgeCount,
})
? showRemoteBadge
? t('reticulumRemote.transfer.pendingOffersBadgeAria', {
count: badgeCount > 99 ? 99 : badgeCount,
})
: t('aria.tabWithUnread', {
label: displayLabel,
count: badgeCount > 99 ? '99+' : badgeCount,
})
: displayLabel;

return (
Expand Down Expand Up @@ -108,7 +118,11 @@ export default function Sidebar({
<span className="relative shrink-0">
<TabIcon name={slotId} />
{showBadge && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
<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'
}`}
>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
Expand Down
114 changes: 113 additions & 1 deletion src/renderer/components/remote/ChatDmRncpControl.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,67 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ensureRncpDestinationReachable } from '@/renderer/lib/ensureRncpDestinationReachable';
import { sendRncpRequestEnable } from '@/renderer/lib/sendRncpRequestEnable';
import { useReticulumIdentityActivityStore } from '@/renderer/stores/reticulumIdentityActivityStore';
import { useReticulumRemoteAddressStore } from '@/renderer/stores/reticulumRemoteAddressStore';
import { useRncpTransferStore } from '@/renderer/stores/rncpTransferStore';

import { ChatDmRncpControl } from './ChatDmRncpControl';

vi.mock('@/renderer/lib/ensureRncpDestinationReachable', () => ({
ensureRncpDestinationReachable: vi.fn(),
}));

vi.mock('@/renderer/lib/sendRncpRequestEnable', () => ({
sendRncpRequestEnable: vi.fn(),
}));

const addToast = vi.fn();

vi.mock('@/renderer/components/Toast', () => ({
useToast: () => ({ addToast }),
}));

const PEER_HASH = 'a'.repeat(32);
const PEER_IDENTITY = 'd'.repeat(32);
const DEST_HASH = 'c'.repeat(32);

function seedSavedRncpAddress(): void {
useReticulumRemoteAddressStore.setState({
addresses: new Map([
[
'addr1',
{
id: 'addr1',
label: 'Alice',
service: 'rncp',
destination_hash: DEST_HASH,
lxmf_peer_hash: PEER_HASH,
created_at: 1,
updated_at: 1,
},
],
]),
hydrated: true,
hydrate: () => Promise.resolve(),
});
}

describe('ChatDmRncpControl', () => {
beforeEach(() => {
addToast.mockReset();
vi.mocked(ensureRncpDestinationReachable).mockReset();
vi.mocked(ensureRncpDestinationReachable).mockResolvedValue({ status: 'reachable', hops: 1 });
vi.mocked(sendRncpRequestEnable).mockReset();
vi.mocked(sendRncpRequestEnable).mockResolvedValue({ ok: true });
vi.mocked(window.electronAPI.reticulum.rncp.showOpenFileDialog).mockReset();
vi.mocked(window.electronAPI.reticulum.rncp.showOpenFileDialog).mockResolvedValue({
canceled: true,
path: null,
});
vi.mocked(window.electronAPI.reticulum.rncp.send).mockReset();
useRncpTransferStore.getState().clearAll();
useReticulumRemoteAddressStore.setState({
addresses: new Map(),
Expand Down Expand Up @@ -175,4 +224,67 @@ describe('ChatDmRncpControl', () => {
expect(window.electronAPI.reticulum.rncp.accept).toHaveBeenCalledWith({ transfer_id: 't1' });
expect(useRncpTransferStore.getState().pendingOffers.size).toBe(0);
});

it('hard-blocks send when the receive dest is peerUnreachable', async () => {
seedSavedRncpAddress();
vi.mocked(ensureRncpDestinationReachable).mockResolvedValue({ status: 'peerUnreachable' });
const user = userEvent.setup();
render(<ChatDmRncpControl lxmfPeerHash={PEER_HASH} peerLabel="Alice" sidecarRunning />);
await user.click(screen.getByRole('button', { name: 'Send file to Alice via rncp' }));
await user.click(screen.getByRole('button', { name: 'Send file' }));

await waitFor(() => {
expect(ensureRncpDestinationReachable).toHaveBeenCalledWith({
destinationHash: DEST_HASH,
lxmfPeerHash: PEER_HASH,
});
});
expect(window.electronAPI.reticulum.rncp.showOpenFileDialog).not.toHaveBeenCalled();
expect(window.electronAPI.reticulum.rncp.send).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(
'No path to that destination. The peer may be offline.',
'error',
);
});

it('opens enable-request confirm when listenerLikelyOff and confirm sends the request', async () => {
seedSavedRncpAddress();
vi.mocked(ensureRncpDestinationReachable).mockResolvedValue({ status: 'listenerLikelyOff' });
const user = userEvent.setup();
render(<ChatDmRncpControl lxmfPeerHash={PEER_HASH} peerLabel="Alice" sidecarRunning />);
await user.click(screen.getByRole('button', { name: 'Send file to Alice via rncp' }));
await user.click(screen.getByRole('button', { name: 'Send file' }));

expect(await screen.findByText('File receiving may be off')).toBeInTheDocument();
expect(window.electronAPI.reticulum.rncp.showOpenFileDialog).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'Send enable request' }));
await waitFor(() => {
expect(sendRncpRequestEnable).toHaveBeenCalledWith(PEER_HASH);
});
});

it('proceeds to the file picker when the receive dest is reachable', async () => {
seedSavedRncpAddress();
vi.mocked(ensureRncpDestinationReachable).mockResolvedValue({ status: 'reachable', hops: 2 });
vi.mocked(window.electronAPI.reticulum.rncp.showOpenFileDialog).mockResolvedValue({
canceled: false,
path: '/tmp/hello.txt',
});
vi.mocked(window.electronAPI.reticulum.rncp.send).mockResolvedValue({
ok: true,
transfer_id: 'xfer-1',
});
const user = userEvent.setup();
render(<ChatDmRncpControl lxmfPeerHash={PEER_HASH} peerLabel="Alice" sidecarRunning />);
await user.click(screen.getByRole('button', { name: 'Send file to Alice via rncp' }));
await user.click(screen.getByRole('button', { name: 'Send file' }));

await waitFor(() => {
expect(window.electronAPI.reticulum.rncp.send).toHaveBeenCalledWith({
destination_hash: DEST_HASH,
path: '/tmp/hello.txt',
});
});
});
});
40 changes: 37 additions & 3 deletions src/renderer/components/remote/ChatDmRncpControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Upload } from 'lucide-react-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { ConfirmModal } from '@/renderer/components/ConfirmModal';
import { RemotePathCapabilityChip } from '@/renderer/components/remote/RemotePathCapabilityChip';
import { useToast } from '@/renderer/components/Toast';
import { useRemotePathCapability } from '@/renderer/hooks/useRemotePathCapability';
import {
findLatestRncpReceiveDestShareInDmCandidates,
type RncpDmShareCandidate,
} from '@/renderer/lib/applyRncpReceiveDestShareFromChatHistory';
import { ensureRncpDestinationReachable } from '@/renderer/lib/ensureRncpDestinationReachable';
import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString';
import { parseReticulumDestinationInput } from '@/renderer/lib/reticulum/reticulumDestinationInput';
import { rncpOfferMatchesLxmfPeer } from '@/renderer/lib/rncpOfferPeerMatch';
Expand Down Expand Up @@ -84,6 +86,7 @@ export function ChatDmRncpControl({
const [destinationInput, setDestinationInput] = useState(savedAddress?.destination_hash ?? '');
const [rememberAddress, setRememberAddress] = useState(false);
const [sending, setSending] = useState(false);
const [enableRequestConfirmOpen, setEnableRequestConfirmOpen] = useState(false);
const [localTransferIds, setLocalTransferIds] = useState<string[]>([]);
const chatShareAppliedRef = useRef<string | null>(null);
const notifiedTerminalRef = useRef(new Set<string>());
Expand Down Expand Up @@ -191,10 +194,22 @@ export function ChatDmRncpControl({
addToast(t('reticulumRemote.errors.invalidAddress'), 'error');
return;
}
const picked = await window.electronAPI.reticulum.rncp.showOpenFileDialog();
if (picked.canceled || !picked.path) return;
setSending(true);
try {
const reach = await ensureRncpDestinationReachable({
destinationHash: parsedHash,
lxmfPeerHash,
});
if (reach.status === 'peerUnreachable') {
addToast(t('reticulumRemote.transfer.peerUnreachable'), 'error');
return;
}
if (reach.status === 'listenerLikelyOff') {
setEnableRequestConfirmOpen(true);
return;
}
const picked = await window.electronAPI.reticulum.rncp.showOpenFileDialog();
if (picked.canceled || !picked.path) return;
const res = await window.electronAPI.reticulum.rncp.send({
destination_hash: parsedHash,
path: picked.path,
Expand Down Expand Up @@ -294,6 +309,11 @@ export function ChatDmRncpControl({
}
}, [addToast, dmShareCandidates, lxmfPeerHash, t]);

const handleConfirmEnableRequest = useCallback(() => {
setEnableRequestConfirmOpen(false);
void handleRequestEnable();
}, [handleRequestEnable]);

const handleUseFromChat = useCallback(() => {
const fromDm = findLatestRncpReceiveDestShareInDmCandidates(dmShareCandidates);
if (fromDm) {
Expand Down Expand Up @@ -501,10 +521,13 @@ export function ChatDmRncpControl({
type="button"
disabled={!parsedHash || sending}
aria-label={t('reticulumRemote.transfer.sendAria')}
aria-busy={sending}
onClick={() => void handleSend()}
className="w-full rounded bg-blue-700/80 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{sending ? t('chatPanel.rncp.sending') : t('chatPanel.rncp.chooseAndSend')}
{sending
? t('reticulumRemote.transfer.checkingReachability')
: t('chatPanel.rncp.chooseAndSend')}
</button>
{!savedAddress && (
<button
Expand All @@ -530,6 +553,17 @@ export function ChatDmRncpControl({
</button>
</div>
)}
{enableRequestConfirmOpen && (
<ConfirmModal
title={t('reticulumRemote.transfer.listenerLikelyOffTitle')}
message={t('reticulumRemote.transfer.listenerLikelyOffBody')}
confirmLabel={t('reticulumRemote.transfer.listenerLikelyOffConfirm')}
onConfirm={handleConfirmEnableRequest}
onCancel={() => {
setEnableRequestConfirmOpen(false);
}}
/>
)}
</div>
);
}
Loading