diff --git a/frontend/src/components/ContactRoutingOverrideModal.tsx b/frontend/src/components/ContactRoutingOverrideModal.tsx new file mode 100644 index 0000000..dbb02e7 --- /dev/null +++ b/frontend/src/components/ContactRoutingOverrideModal.tsx @@ -0,0 +1,175 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { api } from '../api'; +import type { Contact } from '../types'; +import { + formatRouteLabel, + formatRoutingOverrideInput, + hasRoutingOverride, +} from '../utils/pathUtils'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; + +interface ContactRoutingOverrideModalProps { + open: boolean; + onClose: () => void; + contact: Contact; + onSaved: (message: string) => void; + onError: (message: string) => void; +} + +function summarizeLearnedRoute(contact: Contact): string { + return formatRouteLabel(contact.last_path_len, true); +} + +function summarizeForcedRoute(contact: Contact): string | null { + if (!hasRoutingOverride(contact)) { + return null; + } + const routeOverrideLen = contact.route_override_len; + return routeOverrideLen == null ? null : formatRouteLabel(routeOverrideLen, true); +} + +export function ContactRoutingOverrideModal({ + open, + onClose, + contact, + onSaved, + onError, +}: ContactRoutingOverrideModalProps) { + const [route, setRoute] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + return; + } + setRoute(formatRoutingOverrideInput(contact)); + setError(null); + }, [contact, open]); + + const forcedRouteSummary = useMemo(() => summarizeForcedRoute(contact), [contact]); + + const saveRoute = async (value: string) => { + setSaving(true); + setError(null); + try { + await api.setContactRoutingOverride(contact.public_key, value); + onSaved(value.trim() === '' ? 'Routing override cleared' : 'Routing override updated'); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update routing override'; + setError(message); + onError(message); + } finally { + setSaving(false); + } + }; + + return ( + !isOpen && onClose()}> + + + Routing Override + + Set a forced route for this contact. Leave the field blank to clear the override and + fall back to the learned route or flood until a new path is heard. + + + +
{ + event.preventDefault(); + void saveRoute(route); + }} + > +
+
{contact.name || contact.public_key.slice(0, 12)}
+
+ Current learned route: {summarizeLearnedRoute(contact)} +
+ {forcedRouteSummary && ( +
+ Current forced route: {forcedRouteSummary} +
+ )} +
+ +
+ + setRoute(event.target.value)} + placeholder='Examples: "ae,f1" or "ae92,f13e"' + autoFocus + disabled={saving} + /> +
+

Use comma-separated 1, 2, or 3 byte hop IDs for an explicit path.

+
+
+ +
+
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/ContactStatusInfo.tsx b/frontend/src/components/ContactStatusInfo.tsx index 4167af2..393be65 100644 --- a/frontend/src/components/ContactStatusInfo.tsx +++ b/frontend/src/components/ContactStatusInfo.tsx @@ -1,18 +1,17 @@ -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { toast } from './ui/sonner'; -import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { isValidLocation, calculateDistance, formatDistance, formatRouteLabel, - formatRoutingOverrideInput, getEffectiveContactRoute, } from '../utils/pathUtils'; import { getMapFocusHash } from '../utils/urlHash'; import { handleKeyboardActivate } from '../utils/a11y'; import type { Contact } from '../types'; +import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal'; interface ContactStatusInfoProps { contact: Contact; @@ -25,28 +24,10 @@ interface ContactStatusInfoProps { * shared between ChatHeader and RepeaterDashboard. */ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) { + const [routingModalOpen, setRoutingModalOpen] = useState(false); const parts: ReactNode[] = []; const effectiveRoute = getEffectiveContactRoute(contact); - const editRoutingOverride = () => { - const route = window.prompt( - 'Enter explicit path as comma-separated 1, 2, or 3 byte hops (for example "ae,f1" or "ae92,f13e").\nEnter 0 to force direct always.\nEnter -1 to force flooding always.\nLeave blank to clear the override and reset to flood until a new path is heard.', - formatRoutingOverrideInput(contact) - ); - if (route === null) { - return; - } - - api.setContactRoutingOverride(contact.public_key, route).then( - () => - toast.success( - route.trim() === '' ? 'Routing override cleared' : 'Routing override updated' - ), - (err: unknown) => - toast.error(err instanceof Error ? err.message : 'Failed to update routing override') - ); - }; - if (contact.last_seen) { parts.push(`Last heard: ${formatTime(contact.last_seen)}`); } @@ -54,13 +35,13 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo parts.push( { e.stopPropagation(); - editRoutingOverride(); + setRoutingModalOpen(true); }} title="Click to edit routing override" > @@ -101,15 +82,24 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo if (parts.length === 0) return null; return ( - - ( - {parts.map((part, i) => ( - - {i > 0 && ', '} - {part} - - ))} - ) - + <> + + ( + {parts.map((part, i) => ( + + {i > 0 && ', '} + {part} + + ))} + ) + + setRoutingModalOpen(false)} + contact={contact} + onSaved={(message) => toast.success(message)} + onError={(message) => toast.error(message)} + /> + ); } diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 86d9998..751c46c 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { RepeaterDashboard } from '../components/RepeaterDashboard'; import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard'; import type { Contact, Conversation, Favorite } from '../types'; @@ -465,7 +465,7 @@ describe('RepeaterDashboard', () => { expect(screen.getByText('1 hop')).toBeInTheDocument(); }); - it('direct path is clickable with routing override title', () => { + it('direct path is clickable, underlined, and marked as editable', () => { const directContacts: Contact[] = [ { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, ]; @@ -475,6 +475,7 @@ describe('RepeaterDashboard', () => { const directEl = screen.getByTitle('Click to edit routing override'); expect(directEl).toBeInTheDocument(); expect(directEl.textContent).toBe('direct'); + expect(directEl.className).toContain('underline'); }); it('shows forced decorator when a routing override is active', () => { @@ -495,13 +496,11 @@ describe('RepeaterDashboard', () => { expect(screen.getByText('(forced)')).toBeInTheDocument(); }); - it('clicking direct path opens prompt and updates routing override', async () => { + it('clicking direct path opens modal and can force direct routing', async () => { const directContacts: Contact[] = [ { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, ]; - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('0'); - const { api } = await import('../api'); const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({ status: 'ok', @@ -511,21 +510,21 @@ describe('RepeaterDashboard', () => { render(); fireEvent.click(screen.getByTitle('Click to edit routing override')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Force Direct' })); - expect(promptSpy).toHaveBeenCalled(); - expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0'); + await waitFor(() => { + expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0'); + }); - promptSpy.mockRestore(); overrideSpy.mockRestore(); }); - it('clicking path does not call API when prompt is cancelled', async () => { + it('closing the routing override modal does not call the API', async () => { const directContacts: Contact[] = [ { ...contacts[0], last_path_len: 0, last_seen: 1700000000 }, ]; - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(null); - const { api } = await import('../api'); const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({ status: 'ok', @@ -535,11 +534,11 @@ describe('RepeaterDashboard', () => { render(); fireEvent.click(screen.getByTitle('Click to edit routing override')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - expect(promptSpy).toHaveBeenCalled(); expect(overrideSpy).not.toHaveBeenCalled(); - promptSpy.mockRestore(); overrideSpy.mockRestore(); }); });