diff --git a/app/routers/read_state.py b/app/routers/read_state.py index 365b695..1f3ee32 100644 --- a/app/routers/read_state.py +++ b/app/routers/read_state.py @@ -3,9 +3,10 @@ import logging import time -from fastapi import APIRouter, Query +from fastapi import APIRouter from app.models import UnreadCounts +from app.radio import radio_manager from app.repository import ChannelRepository, ContactRepository, MessageRepository logger = logging.getLogger(__name__) @@ -13,14 +14,18 @@ router = APIRouter(prefix="/read-state", tags=["read-state"]) @router.get("/unreads", response_model=UnreadCounts) -async def get_unreads( - name: str | None = Query(default=None, description="User's name for @mention detection"), -) -> UnreadCounts: +async def get_unreads() -> UnreadCounts: """Get unread counts, mention flags, and last message times for all conversations. Computes unread counts server-side using last_read_at timestamps on channels and contacts, avoiding the need to fetch bulk messages. + The radio's own name is sourced directly from the connected radio + for @mention detection. """ + name: str | None = None + mc = radio_manager.meshcore + if mc and mc.self_info: + name = mc.self_info.get("name") or None data = await MessageRepository.get_unread_counts(name) return UnreadCounts(**data) diff --git a/frontend/index.html b/frontend/index.html index 0f400a0..7cffd1b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,6 +17,13 @@
+ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99e075d..b2f1d94 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,19 +23,23 @@ import { MessageList } from './components/MessageList'; import { MessageInput, type MessageInputHandle } from './components/MessageInput'; import { NewMessageModal } from './components/NewMessageModal'; import { - SettingsModal, SETTINGS_SECTION_LABELS, SETTINGS_SECTION_ORDER, type SettingsSection, -} from './components/SettingsModal'; +} from './components/settingsConstants'; import { RawPacketList } from './components/RawPacketList'; -import { CrackerPanel } from './components/CrackerPanel'; -// Lazy-load heavy components (Leaflet, force-directed graph) to reduce initial bundle +// Lazy-load heavy components to reduce initial bundle const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView }))); const VisualizerView = lazy(() => import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView })) ); +const SettingsModal = lazy(() => + import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal })) +); +const CrackerPanel = lazy(() => + import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel })) +); import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet'; import { Toaster, toast } from './components/ui/sonner'; import { @@ -103,6 +107,10 @@ export function App() { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); + // Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state) + const crackerMounted = useRef(false); + if (showCracker) crackerMounted.current = true; + // Favorites are now stored server-side in appSettings. // Stable empty array prevents a new reference every render when there are none. const emptyFavorites = useRef([]).current; @@ -150,7 +158,7 @@ export function App() { incrementUnread, markAllRead, trackNewMessage, - } = useUnreadCounts(channels, contacts, activeConversation, config?.name); + } = useUnreadCounts(channels, contacts, activeConversation); const { repeaterLoggedIn, @@ -1217,52 +1225,70 @@ export function App() {
- + + Loading settings... +
+ } + > + + )} - {/* Global Cracker Panel - always rendered to maintain state */} + {/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
- { - const created = await api.createChannel(name, key); - const data = await api.getChannels(); - setChannels(data); - await api.decryptHistoricalPackets({ - key_type: 'channel', - channel_key: created.key, - }); - fetchUndecryptedCount(); - }} - onRunningChange={setCrackerRunning} - /> + {crackerMounted.current && ( + + Loading cracker... +
+ } + > + { + const created = await api.createChannel(name, key); + const data = await api.getChannels(); + setChannels(data); + await api.decryptHistoricalPackets({ + key_type: 'channel', + channel_key: created.key, + }); + fetchUndecryptedCount(); + }} + onRunningChange={setCrackerRunning} + /> + + )} { - const params = name ? `?name=${encodeURIComponent(name)}` : ''; - return fetchJson(`/read-state/unreads${params}`); - }, + getUnreads: () => fetchJson('/read-state/unreads'), markAllRead: () => fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', { method: 'POST', diff --git a/frontend/src/components/PacketVisualizer.tsx b/frontend/src/components/PacketVisualizer.tsx index 8de7316..12e86bb 100644 --- a/frontend/src/components/PacketVisualizer.tsx +++ b/frontend/src/components/PacketVisualizer.tsx @@ -1598,12 +1598,14 @@ export function PacketVisualizer({
= { - radio: '📻 Radio', - identity: '🪪 Identity', - connectivity: '📡 Connectivity', - database: '🗄️ Database', - bot: '🤖 Bot', -}; +// Import for local use + re-export so existing imports from this file still work +import { + SETTINGS_SECTION_ORDER, + SETTINGS_SECTION_LABELS, + type SettingsSection, +} from './settingsConstants'; +export { SETTINGS_SECTION_ORDER, SETTINGS_SECTION_LABELS, type SettingsSection }; interface SettingsModalBaseProps { open: boolean; diff --git a/frontend/src/components/settingsConstants.ts b/frontend/src/components/settingsConstants.ts new file mode 100644 index 0000000..b3bfdf4 --- /dev/null +++ b/frontend/src/components/settingsConstants.ts @@ -0,0 +1,17 @@ +export type SettingsSection = 'radio' | 'identity' | 'connectivity' | 'database' | 'bot'; + +export const SETTINGS_SECTION_ORDER: SettingsSection[] = [ + 'radio', + 'identity', + 'connectivity', + 'database', + 'bot', +]; + +export const SETTINGS_SECTION_LABELS: Record = { + radio: '📻 Radio', + identity: '🪪 Identity', + connectivity: '📡 Connectivity', + database: '🗄️ Database', + bot: '🤖 Bot', +}; diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index c3ef424..efb3c96 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -6,7 +6,13 @@ import { getStateKey, type ConversationTimes, } from '../utils/conversationState'; -import type { Channel, Contact, Conversation, Message } from '../types'; +import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types'; + +// Consume the prefetched unreads promise started in index.html (if available). +// This lets the fetch run while React JS is still downloading/parsing. +const prefetchedUnreads: Promise | undefined = ( + window as unknown as { __prefetch?: { unreads?: Promise } } +).__prefetch?.unreads; export interface UseUnreadCountsResult { unreadCounts: Record; @@ -21,47 +27,53 @@ export interface UseUnreadCountsResult { export function useUnreadCounts( channels: Channel[], contacts: Contact[], - activeConversation: Conversation | null, - myName: string | null = null + activeConversation: Conversation | null ): UseUnreadCountsResult { const [unreadCounts, setUnreadCounts] = useState>({}); const [mentions, setMentions] = useState>({}); const [lastMessageTimes, setLastMessageTimes] = useState(getLastMessageTimes); - // Keep myName in a ref so callbacks always have current value - const myNameRef = useRef(myName); - useEffect(() => { - myNameRef.current = myName; - }, [myName]); + // Apply unreads data to state + const applyUnreads = useCallback((data: UnreadCounts) => { + setUnreadCounts(data.counts); + setMentions(data.mentions); + + if (Object.keys(data.last_message_times).length > 0) { + for (const [key, ts] of Object.entries(data.last_message_times)) { + setLastMessageTime(key, ts); + } + setLastMessageTimes(getLastMessageTimes()); + } + }, []); // Fetch unreads from the server-side endpoint const fetchUnreads = useCallback(async () => { try { - const data = await api.getUnreads(myNameRef.current ?? undefined); - - // Replace (not merge) — server counts are authoritative - setUnreadCounts(data.counts); - setMentions(data.mentions); - - if (Object.keys(data.last_message_times).length > 0) { - // Update in-memory cache and state - for (const [key, ts] of Object.entries(data.last_message_times)) { - setLastMessageTime(key, ts); - } - setLastMessageTimes(getLastMessageTimes()); - } + applyUnreads(await api.getUnreads()); } catch (err) { console.error('Failed to fetch unreads:', err); } - }, []); + }, [applyUnreads]); - // Fetch when the number of channels/contacts changes (e.g. initial load, - // sync, create/delete). Using .length avoids refiring on every WebSocket - // contact-update that merely mutates an existing entry's fields. + // On mount, consume the prefetched promise (started in index.html before + // React loaded) or fall back to a fresh fetch. + // Re-fetch when channel/contact count changes mid-session (new sync, cracker + // channel created, etc.) but skip the initial 0→N load to avoid double calls. const channelsLen = channels.length; const contactsLen = contacts.length; + const prevLens = useRef({ channels: 0, contacts: 0 }); useEffect(() => { - if (channelsLen === 0 && contactsLen === 0) return; + if (prefetchedUnreads) { + prefetchedUnreads.then(applyUnreads).catch(() => fetchUnreads()); + } else { + fetchUnreads(); + } + }, [fetchUnreads, applyUnreads]); + useEffect(() => { + const prev = prevLens.current; + prevLens.current = { channels: channelsLen, contacts: contactsLen }; + // Skip the initial load (0→N); only refetch on mid-session count changes + if (prev.channels === 0 && prev.contacts === 0) return; fetchUnreads(); }, [channelsLen, contactsLen, fetchUnreads]); diff --git a/tests/test_api.py b/tests/test_api.py index 2e0e8e4..06c83aa 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -500,7 +500,7 @@ class TestReadStateEndpoints: @pytest.mark.asyncio async def test_get_unreads_no_name_skips_mentions(self, test_db): - """GET /unreads without name param returns counts but no mention flags.""" + """Unreads without a radio name returns counts but no mention flags.""" chan_key = "CHAN1KEY1CHAN1KEY1CHAN1KEY1CHAN1KEY1" await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 0) @@ -518,6 +518,58 @@ class TestReadStateEndpoints: assert result["counts"][f"channel-{chan_key}"] == 1 assert len(result["mentions"]) == 0 + @pytest.mark.asyncio + async def test_unreads_endpoint_sources_name_from_radio(self, test_db, client): + """GET /unreads sources the user's name from the radio for mention detection.""" + chan_key = "MENTIONENDPOINT1MENTIONENDPOINT1" + await ChannelRepository.upsert(key=chan_key, name="Public") + await ChannelRepository.update_last_read_at(chan_key, 0) + + await MessageRepository.create( + msg_type="CHAN", + text="hey @[RadioUser] check this", + received_at=1001, + conversation_key=chan_key, + sender_timestamp=1001, + ) + + # Mock radio_manager.meshcore to return a name + mock_mc = MagicMock() + mock_mc.self_info = {"name": "RadioUser"} + with patch("app.routers.read_state.radio_manager") as mock_rm: + mock_rm.meshcore = mock_mc + response = await client.get("/api/read-state/unreads") + + assert response.status_code == 200 + data = response.json() + assert data["counts"][f"channel-{chan_key}"] == 1 + assert data["mentions"][f"channel-{chan_key}"] is True + + @pytest.mark.asyncio + async def test_unreads_endpoint_no_radio_skips_mentions(self, test_db, client): + """GET /unreads with no radio connected still returns counts without mentions.""" + chan_key = "NORADIOENDPOINT1NORADIOENDPOINT1" + await ChannelRepository.upsert(key=chan_key, name="Public") + await ChannelRepository.update_last_read_at(chan_key, 0) + + await MessageRepository.create( + msg_type="CHAN", + text="hey @[Someone] check this", + received_at=1001, + conversation_key=chan_key, + sender_timestamp=1001, + ) + + # Mock radio_manager.meshcore as None (disconnected) + with patch("app.routers.read_state.radio_manager") as mock_rm: + mock_rm.meshcore = None + response = await client.get("/api/read-state/unreads") + + assert response.status_code == 200 + data = response.json() + assert data["counts"][f"channel-{chan_key}"] == 1 + assert len(data["mentions"]) == 0 + @pytest.mark.asyncio async def test_unreads_reset_after_mark_read(self, test_db): """Marking a conversation as read zeroes its unread count; new messages after count again."""