Compare commits

..

9 Commits

Author SHA1 Message Date
Jack Kingsman a02c3cae9e Updating changelog + build for 3.9.0 2026-04-06 22:10:06 -07:00
Jack Kingsman ca7349a1a8 Add autofocus to text boxes 2026-04-06 21:59:46 -07:00
Jack Kingsman eeaa11b8b0 Fix lint bugs 2026-04-06 20:36:47 -07:00
Jack Kingsman 08eaf090b2 Be more guarded in the radio validity checks (and get outta here, you random repeaters I never favorited!) 2026-04-06 20:34:16 -07:00
Jack Kingsman 2f43420235 Add command palette 2026-04-06 20:27:55 -07:00
Jack Kingsman af74663518 Add guard for favorites sync 2026-04-06 20:12:58 -07:00
Jack Kingsman b7981c0450 Getting all Cal Raleigh up in here 2026-04-06 19:09:48 -07:00
Jack Kingsman 0f4976b9ee Merge pull request #167 from jkingsman/migrate-favorites
Add favorites as contact field (dug)
2026-04-05 22:19:01 -07:00
Jack Kingsman 1991f2515b Support relative URLs. Closes #165. 2026-04-05 22:11:12 -07:00
37 changed files with 1111 additions and 168 deletions
+21
View File
@@ -1,3 +1,24 @@
## [3.9.0] - 2026-04-06
* Feature: Add hop counts to hop-width selection options
* Feature: Show cached repeater telemetry inline in settings
* Feature: Retain recent traces and make them click-to-re-run
* Feature: Autofocus channel/DM textbox on desktop
* Feature: Favorites on the radio are now imported as favorites
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
* Bugfix: Don't use sender timestamp for message sequence display
* Bugfix: Function on subdomains happily
* Misc: Be gentler, room s/cracker/finder/
* Misc: Test and frontend correctness & test fixes
* Misc: Don't repeat clock sync failure logs
* Misc: Make warning in readme clearer about taking over the radio
* Misc: Improve readme phrasings
* Misc: Better y-axis selection for battery read-out
* Misc: Provide clearer warning on docker setup without docker installed
* Misc: Default visualizer stale pruning to on/5 minutes
* Misc: Migrate favorites to better storage pattern
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
## [3.8.0] - 2026-04-03
* Feature: Per-channel hop width override
+31
View File
@@ -1188,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
</details>
### cmdk (1.1.1) — MIT
<details>
<summary>Full license text</summary>
```
MIT License
Copyright (c) 2022 Paco Coursey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
</details>
### d3-force (3.0.0) — ISC
<details>
+9
View File
@@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
## Sub-Path Reverse Proxy
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
**Requirements:**
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
## HTTPS
WebGPU channel-finding requires a secure context when you are not on `localhost`.
+4 -1
View File
@@ -299,8 +299,11 @@ def parse_advertisement(
timestamp = int.from_bytes(payload[32:36], byteorder="little")
flags = payload[100]
# Parse flags
# Parse flags — clamp device_role to valid range (0-4); corrupted
# advertisements can have junk in the lower nibble.
device_role = flags & 0x0F
if device_role > 4:
device_role = 0
has_location = bool(flags & 0x10)
has_feature1 = bool(flags & 0x20)
has_feature2 = bool(flags & 0x40)
+31 -11
View File
@@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
def _resolve_request_base(request: Request) -> str:
"""Resolve the external base URL, honoring common reverse-proxy headers.
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
so callers can append paths directly.
Recognized headers:
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
by the proxy (e.g. ``/meshcore``).
"""
forwarded_proto = request.headers.get("x-forwarded-proto")
forwarded_host = request.headers.get("x-forwarded-host")
@@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str:
proto = forwarded_proto.split(",")[0].strip()
host = forwarded_host.split(",")[0].strip()
if proto and host:
return f"{proto}://{host}"
origin = f"{proto}://{host}"
else:
origin = str(request.base_url).rstrip("/")
else:
origin = str(request.base_url).rstrip("/")
return str(request.base_url).rstrip("/")
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
prefix = (
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
.strip()
.rstrip("/")
)
return f"{origin}{prefix}/"
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
@@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
"""Serve a dynamic web manifest using the active request origin."""
origin = _resolve_request_origin(request)
"""Serve a dynamic web manifest using the active request base URL."""
base = _resolve_request_base(request)
manifest = {
"name": "RemoteTerm for MeshCore",
"short_name": "RemoteTerm",
"id": f"{origin}/",
"start_url": f"{origin}/",
"scope": f"{origin}/",
"id": base,
"start_url": base,
"scope": base,
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
"theme_color": "#111419",
"background_color": "#111419",
"icons": [
{
"src": f"{origin}/web-app-manifest-192x192.png",
"src": f"{base}web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable",
},
{
"src": f"{origin}/web-app-manifest-512x512.png",
"src": f"{base}web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
+21 -3
View File
@@ -4,6 +4,10 @@ from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route, normalize_route_override
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
# Corrupted radio data can produce values outside this range.
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
class ContactRoute(BaseModel):
"""A normalized contact route."""
@@ -59,16 +63,30 @@ class ContactUpsert(BaseModel):
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
),
)
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
# can produce values like 111 or 240 that break downstream branching.
raw_type = radio_data.get("type", 0)
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
# Null out impossible coordinates — the contact is still ingested,
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
lat = radio_data.get("adv_lat")
lon = radio_data.get("adv_lon")
if lat is not None and not (-90 <= lat <= 90):
lat = None
if lon is not None and not (-180 <= lon <= 180):
lon = None
return cls(
public_key=public_key,
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
type=contact_type,
flags=radio_data.get("flags", 0),
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
lat=radio_data.get("adv_lat"),
lon=radio_data.get("adv_lon"),
lat=lat,
lon=lon,
last_advert=radio_data.get("last_advert"),
on_radio=on_radio,
)
+9 -3
View File
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings
from app.event_handlers import cleanup_expired_acks, on_contact_message
from app.models import Contact, ContactUpsert
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
from app.radio import RadioOperationBusyError
from app.repository import (
AmbiguousPublicKeyPrefixError,
@@ -1070,8 +1070,14 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
logger.debug("Synced %d contacts from radio snapshot", synced)
# Import radio-favorited contacts into app favorites
radio_fav_keys = [pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01]
# Import radio-favorited contacts into app favorites.
# Only trust the favorite bit on contacts with a valid type (0-4);
# garbled radio data can have junk flags with bit 0 set.
radio_fav_keys = [
pk
for pk, data in contacts.items()
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
]
if radio_fav_keys:
try:
imported = 0
+12 -12
View File
@@ -9,11 +9,11 @@
<meta name="theme-color" content="#111419" />
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
<title>RemoteTerm for MeshCore</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="./favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
<link rel="manifest" href="./site.webmanifest" />
<script>
// Start critical data fetches before React/Vite JS loads.
// Must be in <head> BEFORE the module script so the browser queues these
@@ -42,17 +42,17 @@
});
};
window.__prefetch = {
config: fetchJsonOrThrow('/api/radio/config'),
settings: fetchJsonOrThrow('/api/settings'),
channels: fetchJsonOrThrow('/api/channels'),
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
config: fetchJsonOrThrow('./api/radio/config'),
settings: fetchJsonOrThrow('./api/settings'),
channels: fetchJsonOrThrow('./api/channels'),
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
};
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+19 -2
View File
@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -20,6 +20,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",
@@ -3687,6 +3688,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.8.0",
"version": "3.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -28,6 +28,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",
+33 -1
View File
@@ -25,7 +25,8 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker {
channelId: string;
@@ -88,6 +89,7 @@ export function App() {
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -295,6 +297,21 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
// Auto-focus the message input on conversation change (desktop only by default)
useEffect(() => {
if (!activeConversation) return;
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
// Repeaters show a login form, not a message input
if (activeConversation.type === 'contact') {
const contact = contacts.find((c) => c.public_key === activeConversation.id);
if (contact?.type === CONTACT_TYPE_REPEATER) return;
}
if (!shouldAutoFocusInput()) return;
// Defer to let the input mount/render first
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
return () => cancelAnimationFrame(raf);
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order.
@@ -457,6 +474,18 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleRepeaterAutoLogin = useCallback(
(publicKey: string, displayName: string) => {
handleSelectConversationWithTargetReset({
type: 'contact',
id: publicKey,
name: displayName,
});
setRepeaterAutoLoginKey(publicKey);
},
[handleSelectConversationWithTargetReset]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
@@ -587,6 +616,8 @@ export function App() {
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
};
const searchProps = {
contacts,
@@ -720,6 +751,7 @@ export function App() {
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
</DistanceUnitProvider>
);
+1 -1
View File
@@ -39,7 +39,7 @@ import type {
UnreadCounts,
} from './types';
const API_BASE = '/api';
const API_BASE = './api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const hasBody = options?.body !== undefined;
+19 -1
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar';
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { CommandPalette } from './CommandPalette';
import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -71,6 +72,7 @@ interface AppShellProps {
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
export function AppShell({
@@ -100,6 +102,7 @@ export function AppShell({
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
onRepeaterAutoLogin,
}: AppShellProps) {
const swipeHandlers = useSwipeable({
onSwipedRight: ({ initial }) => {
@@ -119,6 +122,14 @@ export function AppShell({
preventScrollOnSwipe: false,
});
const handleOpenSettings = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
if (!showSettings) onToggleSettingsView();
},
[onSettingsSectionChange, onToggleSettingsView, showSettings]
);
const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true;
@@ -323,6 +334,13 @@ export function AppShell({
onClose={onCloseBulkAddResults}
/>
<CommandPalette
contacts={sidebarProps.contacts}
channels={sidebarProps.channels}
onSelectConversation={sidebarProps.onSelectConversation}
onOpenSettings={handleOpenSettings}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />
+436
View File
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Hash,
Map,
MessageSquare,
Network,
Radio,
Route,
Search,
Sparkles,
User,
Waypoints,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { getContactDisplayName } from '../utils/pubkey';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_ICONS,
type SettingsSection,
} from './settings/settingsConstants';
import type { Channel, Contact, Conversation } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
const MAX_PER_GROUP = 8;
interface CommandPaletteProps {
contacts: Contact[];
channels: Channel[];
onSelectConversation: (conv: Conversation) => void;
onOpenSettings: (section: SettingsSection) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
interface Searchable {
searchText: string;
}
interface SearchableContact extends Searchable {
contact: Contact;
displayName: string;
}
interface SearchableChannel extends Searchable {
channel: Channel;
}
interface ToolItem extends Searchable {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
}
interface SettingItem extends Searchable {
section: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const TOOL_ITEMS: ToolItem[] = [
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
{
id: 'visualizer',
name: 'Network Visualizer',
icon: Network,
type: 'visualizer',
searchText: 'network visualizer',
},
{
id: 'search',
name: 'Message Search',
icon: Search,
type: 'search',
searchText: 'message search',
},
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
];
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
section,
label: SETTINGS_SECTION_LABELS[section],
icon: SETTINGS_SECTION_ICONS[section],
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
}));
function fuzzyMatch(text: string, query: string): boolean {
let qi = 0;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) qi++;
}
return qi === query.length;
}
function filterList<T extends Searchable>(items: T[], query: string): T[] {
if (!query) return items.slice(0, MAX_PER_GROUP);
const results: T[] = [];
for (const item of items) {
if (fuzzyMatch(item.searchText, query)) {
results.push(item);
if (results.length >= MAX_PER_GROUP) break;
}
}
return results;
}
export function CommandPalette({
contacts,
channels,
onSelectConversation,
onOpenSettings,
onRepeaterAutoLogin,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const select = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
const {
favContacts,
favRepeaters,
regularContacts,
repeaters,
rooms,
favChannels,
regularChannels,
} = useMemo(() => {
const fc: SearchableContact[] = [];
const fr: SearchableContact[] = [];
const rc: SearchableContact[] = [];
const rp: SearchableContact[] = [];
const rm: SearchableContact[] = [];
for (const c of contacts) {
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
const entry: SearchableContact = {
contact: c,
displayName,
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
};
if (c.type === CONTACT_TYPE_REPEATER) {
(c.favorite ? fr : rp).push(entry);
} else if (c.type === CONTACT_TYPE_ROOM) {
rm.push(entry);
} else {
(c.favorite ? fc : rc).push(entry);
}
}
const fch: SearchableChannel[] = [];
const rch: SearchableChannel[] = [];
for (const ch of channels) {
const entry: SearchableChannel = {
channel: ch,
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
};
(ch.favorite ? fch : rch).push(entry);
}
return {
favContacts: fc,
favRepeaters: fr,
regularContacts: rc,
repeaters: rp,
rooms: rm,
favChannels: fch,
regularChannels: rch,
};
}, [contacts, channels]);
const lq = query.toLowerCase();
const fTools = filterList(TOOL_ITEMS, lq);
const fSettings = filterList(SETTING_ITEMS, lq);
const fFavContacts = filterList(favContacts, lq);
const fFavRepeaters = filterList(favRepeaters, lq);
const fFavChannels = filterList(favChannels, lq);
const fContacts = filterList(regularContacts, lq);
const fRepeaters = filterList(repeaters, lq);
const fRooms = filterList(rooms, lq);
const fChannels = filterList(regularChannels, lq);
const totalResults =
fTools.length +
fSettings.length +
fFavContacts.length +
fFavRepeaters.length +
fFavChannels.length +
fContacts.length +
fRepeaters.length +
fRooms.length +
fChannels.length;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
}}
>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command shouldFilter={false}>
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
<CommandList>
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
{fTools.length > 0 && (
<CommandGroup heading="Tools">
{fTools.map((tool) => (
<CommandItem
key={tool.id}
onSelect={() =>
select(() =>
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
)
}
>
<tool.icon className="text-muted-foreground" />
<span>{tool.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fSettings.length > 0 && (
<CommandGroup heading="Settings">
{fSettings.map((item) => (
<CommandItem
key={item.section}
onSelect={() => select(() => onOpenSettings(item.section))}
>
<item.icon className="text-muted-foreground" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fFavContacts.length > 0 && (
<ContactGroup
heading="Favorite Contacts"
items={fFavContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
showStar
/>
)}
{fFavRepeaters.length > 0 && (
<RepeaterGroup
heading="Favorite Repeaters"
items={fFavRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
showStar
/>
)}
{fFavChannels.length > 0 && (
<CommandGroup heading="Favorite Channels">
{fFavChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
<Sparkles className="ml-auto h-3 w-3 text-yellow-500" />
</CommandItem>
))}
</CommandGroup>
)}
{fContacts.length > 0 && (
<ContactGroup
heading="Contacts"
items={fContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fRepeaters.length > 0 && (
<RepeaterGroup
heading="Repeaters"
items={fRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
)}
{fRooms.length > 0 && (
<ContactGroup
heading="Rooms"
items={fRooms}
icon={MessageSquare}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fChannels.length > 0 && (
<CommandGroup heading="Channels">
{fChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function ContactGroup({
heading,
items,
icon: Icon,
showStar,
onSelect,
onSelectConversation,
}: {
heading: string;
items: SearchableContact[];
icon: React.ComponentType<{ className?: string }>;
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.map(({ contact: c, displayName }) => (
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Icon className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
</CommandItem>
))}
</CommandGroup>
);
}
function RepeaterGroup({
heading,
items,
showStar,
onSelect,
onSelectConversation,
onRepeaterAutoLogin,
}: {
heading: string;
items: SearchableContact[];
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.flatMap(({ contact: c, displayName }) => [
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Waypoints className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
</CommandItem>,
<CommandItem
key={`${c.public_key}-acl`}
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
>
<Waypoints className="text-muted-foreground" />
<span>
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
</span>
</CommandItem>,
])}
</CommandGroup>
);
}
@@ -79,6 +79,8 @@ interface ConversationPaneProps {
onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
onClearRepeaterAutoLogin: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -149,6 +151,8 @@ export function ConversationPane({
onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
@@ -248,6 +252,8 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
onAutoLoginConsumed={onClearRepeaterAutoLogin}
/>
</Suspense>
);
+4
View File
@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
export interface MessageInputHandle {
appendText: (text: string) => void;
focus: () => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
// Focus the input after appending
inputRef.current?.focus();
},
focus: () => {
inputRef.current?.focus();
},
}));
// Calculate character limits based on conversation type
@@ -48,6 +48,8 @@ interface RepeaterDashboardProps {
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
autoLoginAndLoadAll?: boolean;
onAutoLoginConsumed?: () => void;
}
export function RepeaterDashboard({
@@ -67,6 +69,8 @@ export function RepeaterDashboard({
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
autoLoginAndLoadAll,
onAutoLoginConsumed,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -125,6 +129,15 @@ export function RepeaterDashboard({
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
// Command palette "ACL login + load all" auto-action
const autoLoginConsumedRef = useRef(false);
useEffect(() => {
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
autoLoginConsumedRef.current = true;
onAutoLoginConsumed?.();
void loginAsGuest().then(() => loadAll());
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => {
+2 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
interface RepeaterLoginProps {
repeaterName: string;
@@ -64,7 +65,7 @@ export function RepeaterLogin({
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
autoFocus={shouldAutoFocusInput()}
/>
<label
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
<div className="text-center">
<a
href="/api/debug"
href="./api/debug"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary hover:underline"
@@ -27,6 +27,7 @@ import {
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -48,6 +49,7 @@ export function SettingsLocalSection({
});
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -129,85 +131,6 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
@@ -233,33 +156,128 @@ export function SettingsLocalSection({
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<div className="space-y-3">
<Label>UI Tweaks</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -4,7 +4,7 @@ import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
const NOTIFICATION_ICON_PATH = './favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
+1 -1
View File
@@ -5,7 +5,7 @@ import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore';
const UNREAD_APP_TITLE = 'RemoteTerm';
const BASE_FAVICON_PATH = '/favicon.svg';
const BASE_FAVICON_PATH = './favicon.svg';
const GREEN_BADGE_FILL = '#16a34a';
const RED_BADGE_FILL = '#dc2626';
const BADGE_CENTER = 750;
+11 -11
View File
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts?limit=100&offset=0');
expect(url).toBe('./api/contacts?limit=100&offset=0');
});
it('builds repeater advert path endpoint query', async () => {
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12');
});
});
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendDirectMessage('abc123', 'hello');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages/direct');
expect(url).toBe('./api/messages/direct');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({
destination: 'abc123',
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
await api.updateRadioConfig({ name: 'NewName' });
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/config');
expect(url).toBe('./api/radio/config');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
});
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
await api.setPrivateKey('my-secret-key');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/private-key');
expect(url).toBe('./api/radio/private-key');
expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
});
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
await api.discoverMesh('repeaters');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/discover');
expect(url).toBe('./api/radio/discover');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
});
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
await api.deleteContact('pubkey123');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/pubkey123');
expect(url).toBe('./api/contacts/pubkey123');
expect(options.method).toBe('DELETE');
});
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement();
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(url).toBe('./api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
});
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(url).toBe('./api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
});
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
});
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('/api/messages?');
expect(url).toContain('./api/messages?');
expect(url).toContain('limit=50');
expect(url).toContain('offset=10');
expect(url).toContain('type=PRIV');
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
await api.getMessages();
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/messages');
expect(url).toBe('./api/messages');
});
});
});
@@ -158,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
repeaterAutoLoginKey: null,
onClearRepeaterAutoLogin: vi.fn(),
...overrides,
};
}
@@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => {
);
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
expect(link).toHaveAttribute('href', '/api/debug');
expect(link).toHaveAttribute('href', './api/debug');
expect(link).toHaveAttribute('target', '_blank');
});
});
+1 -1
View File
@@ -749,7 +749,7 @@ describe('SettingsModal', () => {
fireEvent.click(screen.getByRole('button', { name: /Statistics/i }));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith('/api/statistics', expect.any(Object));
expect(fetchSpy).toHaveBeenCalledWith('./api/statistics', expect.any(Object));
});
await waitFor(() => {
@@ -89,7 +89,7 @@ describe('useBrowserNotifications', () => {
);
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: '/favicon-256x256.png',
icon: './favicon-256x256.png',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
});
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
@@ -122,7 +122,7 @@ describe('useBrowserNotifications', () => {
expect(window.Notification).toHaveBeenCalledTimes(2);
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
body: 'hello room',
icon: '/favicon-256x256.png',
icon: './favicon-256x256.png',
tag: 'meshcore-message-42',
});
});
@@ -65,7 +65,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
setContacts: vi.fn(),
setChannels: vi.fn(),
observeMessage: vi.fn(() => ({ added: true, activeConversation: true })),
messageInputRef: { current: { appendText: vi.fn() } },
messageInputRef: { current: { appendText: vi.fn(), focus: vi.fn() } },
...overrides,
};
}
+4 -4
View File
@@ -197,8 +197,8 @@ describe('useFaviconBadge', () => {
);
await waitFor(() => {
expect(getIconHref('icon')).toBe('/favicon.svg');
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
expect(getIconHref('icon')).toBe('./favicon.svg');
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
});
rerender({
@@ -234,8 +234,8 @@ describe('useFaviconBadge', () => {
});
await waitFor(() => {
expect(getIconHref('icon')).toBe('/favicon.svg');
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
expect(getIconHref('icon')).toBe('./favicon.svg');
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
});
expect(fetchMock).toHaveBeenCalledTimes(1);
+3 -1
View File
@@ -54,7 +54,9 @@ export function useWebSocket(options: UseWebSocketOptions) {
const connect = useCallback(() => {
// Determine WebSocket URL based on current location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
// Resolve relative to the page so sub-path reverse proxies work
const base = new URL('./api/ws', window.location.href);
const wsUrl = `${protocol}//${base.host}${base.pathname}`;
const ws = new WebSocket(wsUrl);
+31
View File
@@ -0,0 +1,31 @@
const KEY = 'remoteterm-auto-focus-input';
export function getAutoFocusInputEnabled(): boolean {
try {
const raw = localStorage.getItem(KEY);
return raw === null || raw !== 'false';
} catch {
return true;
}
}
export function setAutoFocusInputEnabled(enabled: boolean): void {
try {
if (enabled) {
localStorage.removeItem(KEY);
} else {
localStorage.setItem(KEY, 'false');
}
} catch {
// localStorage may be unavailable
}
}
/**
* Returns true when auto-focus should fire: the setting is enabled
* AND the viewport is wide enough that focusing won't summon a
* mobile keyboard (matches the md: Tailwind breakpoint).
*/
export function shouldAutoFocusInput(): boolean {
return getAutoFocusInputEnabled() && window.innerWidth >= 768;
}
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: './',
plugins: [react()],
resolve: {
alias: {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.8.0"
version = "3.9.0"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Dump the REST OpenAPI spec and WebSocket event schemas to JSON files.
These artifacts are generated programmatically from the running codebase so
they stay in sync with the actual API and WS contracts. They're intended for
consumption by external integrations (e.g., Home Assistant) that need a stable
reference without reading our source.
Usage:
PYTHONPATH=. uv run python3 scripts/build/dump_api_specs.py [output_dir]
Output (default: references/ha/):
openapi.json Full OpenAPI 3.x spec for all REST endpoints
ws_events.json JSON Schema for each WebSocket event type
"""
import json
import sys
from pathlib import Path
def dump_openapi(output_dir: Path) -> None:
from app.main import app
schema = app.openapi()
out = output_dir / "openapi.json"
out.write_text(json.dumps(schema, indent=2) + "\n")
print(f" openapi.json: {len(schema['paths'])} paths, "
f"{len(schema.get('components', {}).get('schemas', {}))} schemas")
def dump_ws_events(output_dir: Path) -> None:
from app.events import _PAYLOAD_ADAPTERS
events: dict = {}
for event_type, adapter in _PAYLOAD_ADAPTERS.items():
schema = adapter.json_schema()
events[event_type] = {
"description": _event_descriptions().get(event_type, ""),
"payload_schema": schema,
}
wrapper = {
"$comment": (
"Auto-generated from app/events.py. "
"Each WebSocket message is a JSON object: {\"type\": \"<event_type>\", \"data\": <payload>}. "
"The client also sends \"ping\" as plain text; the server replies {\"type\": \"pong\"}."
),
"events": events,
}
out = output_dir / "ws_events.json"
out.write_text(json.dumps(wrapper, indent=2) + "\n")
print(f" ws_events.json: {len(events)} event types")
def _event_descriptions() -> dict[str, str]:
return {
"health": "Radio connection status. Sent on WS connect and on every state change.",
"message": "New or incoming message (DM or channel). Includes outgoing messages sent by this radio.",
"contact": "Contact created or updated (from advertisements, radio sync, or API).",
"contact_resolved": "A prefix-only placeholder contact was resolved to a full public key.",
"channel": "Channel created or updated.",
"contact_deleted": "A contact was removed from the database.",
"channel_deleted": "A channel was removed from the database.",
"raw_packet": "Every incoming RF packet (pre-decryption). Use observation_id as the dedup key, not id.",
"message_acked": "An existing message received an ACK or echo/repeat update.",
"error": "Toast-level error notification (e.g., radio setup failure, missing private key).",
"success": "Toast-level success notification (e.g., historical decrypt complete).",
}
def main() -> None:
output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("references/ha")
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Dumping API specs to {output_dir}/")
dump_openapi(output_dir)
dump_ws_events(output_dir)
print("Done.")
if __name__ == "__main__":
main()
+28
View File
@@ -127,6 +127,34 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
assert data["id"] == "https://mesh.example.com:8443/"
def test_webmanifest_includes_forwarded_prefix(tmp_path):
app = FastAPI()
dist_dir = tmp_path / "frontend" / "dist"
dist_dir.mkdir(parents=True)
(dist_dir / "index.html").write_text("<html><body>index page</body></html>")
registered = register_frontend_static_routes(app, dist_dir)
assert registered is True
with TestClient(app) as client:
response = client.get(
"/site.webmanifest",
headers={
"x-forwarded-proto": "https",
"x-forwarded-host": "homeassistant.local:8123",
"x-forwarded-prefix": "/api/hassio_ingress/abc123",
},
)
assert response.status_code == 200
data = response.json()
expected_base = "https://homeassistant.local:8123/api/hassio_ingress/abc123/"
assert data["start_url"] == expected_base
assert data["scope"] == expected_base
assert data["id"] == expected_base
assert data["icons"][0]["src"] == f"{expected_base}web-app-manifest-192x192.png"
def test_first_available_prefers_dist_over_prebuilt(tmp_path):
app = FastAPI()
frontend_dir = tmp_path / "frontend"
Generated
+1 -1
View File
@@ -983,7 +983,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.8.0"
version = "3.9.0"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },