diff --git a/AGENTS.md b/AGENTS.md index 67aa1ec9..aa53cef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -444,7 +444,7 @@ mc.subscribe(EventType.ACK, handler) | `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift | | `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE | -**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`. +**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`. Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. diff --git a/app/AGENTS.md b/app/AGENTS.md index f0c6b824..77ce4dbe 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -272,6 +272,8 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc - `flood_scope` - `blocked_keys`, `blocked_names` +Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode. + Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38). ## Security Posture (intentional) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 02bf7899..9da72a29 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -318,6 +318,8 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid - `flood_scope` - `blocked_keys`, `blocked_names` +The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting. + Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`. `HealthStatus` includes `fanout_statuses: Record` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`. diff --git a/frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js b/frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js similarity index 99% rename from frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js rename to frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js index 5ff3baec..6b9d79a5 100644 Binary files a/frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js and b/frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js differ diff --git a/frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js.map b/frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js.map similarity index 99% rename from frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js.map rename to frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js.map index 16dde522..b9331451 100644 Binary files a/frontend/prebuilt/assets/BotCodeEditor-DUWnXYXA.js.map and b/frontend/prebuilt/assets/BotCodeEditor-ycUmdsPQ.js.map differ diff --git a/frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js b/frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js similarity index 99% rename from frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js rename to frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js index 99ec8083..4ecec405 100644 Binary files a/frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js and b/frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js differ diff --git a/frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js.map b/frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js.map similarity index 99% rename from frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js.map rename to frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js.map index 6381038d..b84fd1a2 100644 Binary files a/frontend/prebuilt/assets/CrackerPanel-CqHBN9pz.js.map and b/frontend/prebuilt/assets/CrackerPanel-_94u2vQ6.js.map differ diff --git a/frontend/prebuilt/assets/MapView-CgY0ZIub.js b/frontend/prebuilt/assets/MapView-Bv8f-A5r.js similarity index 92% rename from frontend/prebuilt/assets/MapView-CgY0ZIub.js rename to frontend/prebuilt/assets/MapView-Bv8f-A5r.js index c49cd9ae..84edba74 100644 Binary files a/frontend/prebuilt/assets/MapView-CgY0ZIub.js and b/frontend/prebuilt/assets/MapView-Bv8f-A5r.js differ diff --git a/frontend/prebuilt/assets/MapView-CgY0ZIub.js.map b/frontend/prebuilt/assets/MapView-Bv8f-A5r.js.map similarity index 99% rename from frontend/prebuilt/assets/MapView-CgY0ZIub.js.map rename to frontend/prebuilt/assets/MapView-Bv8f-A5r.js.map index 662552a5..5b9c1dc1 100644 Binary files a/frontend/prebuilt/assets/MapView-CgY0ZIub.js.map and b/frontend/prebuilt/assets/MapView-Bv8f-A5r.js.map differ diff --git a/frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js b/frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js similarity index 85% rename from frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js rename to frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js index 89497c85..99a54702 100644 Binary files a/frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js and b/frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js differ diff --git a/frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js.map b/frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js.map similarity index 97% rename from frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js.map rename to frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js.map index c00ecd11..f25d9a25 100644 Binary files a/frontend/prebuilt/assets/NeighborsMiniMap-nhWnbDjS.js.map and b/frontend/prebuilt/assets/NeighborsMiniMap-DOCpkCDv.js.map differ diff --git a/frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js b/frontend/prebuilt/assets/PathRouteMap-COU38t0B.js similarity index 95% rename from frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js rename to frontend/prebuilt/assets/PathRouteMap-COU38t0B.js index 143fc342..be963ee8 100644 Binary files a/frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js and b/frontend/prebuilt/assets/PathRouteMap-COU38t0B.js differ diff --git a/frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js.map b/frontend/prebuilt/assets/PathRouteMap-COU38t0B.js.map similarity index 99% rename from frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js.map rename to frontend/prebuilt/assets/PathRouteMap-COU38t0B.js.map index df9a9478..48416a70 100644 Binary files a/frontend/prebuilt/assets/PathRouteMap-BgANOQRu.js.map and b/frontend/prebuilt/assets/PathRouteMap-COU38t0B.js.map differ diff --git a/frontend/prebuilt/assets/Popup-65Kx45OI.js b/frontend/prebuilt/assets/Popup-CZeo1EzJ.js similarity index 81% rename from frontend/prebuilt/assets/Popup-65Kx45OI.js rename to frontend/prebuilt/assets/Popup-CZeo1EzJ.js index 4c3c696b..08e839ed 100644 Binary files a/frontend/prebuilt/assets/Popup-65Kx45OI.js and b/frontend/prebuilt/assets/Popup-CZeo1EzJ.js differ diff --git a/frontend/prebuilt/assets/Popup-65Kx45OI.js.map b/frontend/prebuilt/assets/Popup-CZeo1EzJ.js.map similarity index 97% rename from frontend/prebuilt/assets/Popup-65Kx45OI.js.map rename to frontend/prebuilt/assets/Popup-CZeo1EzJ.js.map index 89e0d0df..852cbabd 100644 Binary files a/frontend/prebuilt/assets/Popup-65Kx45OI.js.map and b/frontend/prebuilt/assets/Popup-CZeo1EzJ.js.map differ diff --git a/frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js b/frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js similarity index 98% rename from frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js rename to frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js index 3e59a4cc..32e68039 100644 Binary files a/frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js and b/frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js differ diff --git a/frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js.map b/frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js.map similarity index 99% rename from frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js.map rename to frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js.map index 15b861a0..357ea400 100644 Binary files a/frontend/prebuilt/assets/RepeaterDashboard-B86OTQtd.js.map and b/frontend/prebuilt/assets/RepeaterDashboard-CCs38SPq.js.map differ diff --git a/frontend/prebuilt/assets/SearchView-BFMnGDyJ.js b/frontend/prebuilt/assets/SearchView-8mOuDvjT.js similarity index 97% rename from frontend/prebuilt/assets/SearchView-BFMnGDyJ.js rename to frontend/prebuilt/assets/SearchView-8mOuDvjT.js index e083e5ec..0b174763 100644 Binary files a/frontend/prebuilt/assets/SearchView-BFMnGDyJ.js and b/frontend/prebuilt/assets/SearchView-8mOuDvjT.js differ diff --git a/frontend/prebuilt/assets/SearchView-BFMnGDyJ.js.map b/frontend/prebuilt/assets/SearchView-8mOuDvjT.js.map similarity index 99% rename from frontend/prebuilt/assets/SearchView-BFMnGDyJ.js.map rename to frontend/prebuilt/assets/SearchView-8mOuDvjT.js.map index a8831582..5bcf0716 100644 Binary files a/frontend/prebuilt/assets/SearchView-BFMnGDyJ.js.map and b/frontend/prebuilt/assets/SearchView-8mOuDvjT.js.map differ diff --git a/frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js b/frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js similarity index 99% rename from frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js rename to frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js index 10553f24..a62dfef9 100644 Binary files a/frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js and b/frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js differ diff --git a/frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js.map b/frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js.map similarity index 99% rename from frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js.map rename to frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js.map index 3979c5f4..537e54a0 100644 Binary files a/frontend/prebuilt/assets/SettingsModal-CmnPdiWo.js.map and b/frontend/prebuilt/assets/SettingsModal-D8B2Xdsr.js.map differ diff --git a/frontend/prebuilt/assets/VisualizerView-BX96-zH4.js b/frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js similarity index 99% rename from frontend/prebuilt/assets/VisualizerView-BX96-zH4.js rename to frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js index fb827d23..ba6ec87c 100644 Binary files a/frontend/prebuilt/assets/VisualizerView-BX96-zH4.js and b/frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js differ diff --git a/frontend/prebuilt/assets/VisualizerView-BX96-zH4.js.map b/frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js.map similarity index 99% rename from frontend/prebuilt/assets/VisualizerView-BX96-zH4.js.map rename to frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js.map index 41609cb1..568f5bea 100644 Binary files a/frontend/prebuilt/assets/VisualizerView-BX96-zH4.js.map and b/frontend/prebuilt/assets/VisualizerView-DHGA0_mI.js.map differ diff --git a/frontend/prebuilt/assets/hooks-BCaVWsWl.js b/frontend/prebuilt/assets/hooks-BCaVWsWl.js new file mode 100644 index 00000000..43d01373 Binary files /dev/null and b/frontend/prebuilt/assets/hooks-BCaVWsWl.js differ diff --git a/frontend/prebuilt/assets/hooks-BHXvHkvm.js.map b/frontend/prebuilt/assets/hooks-BCaVWsWl.js.map similarity index 90% rename from frontend/prebuilt/assets/hooks-BHXvHkvm.js.map rename to frontend/prebuilt/assets/hooks-BCaVWsWl.js.map index cfe07d5d..ee830e5b 100644 Binary files a/frontend/prebuilt/assets/hooks-BHXvHkvm.js.map and b/frontend/prebuilt/assets/hooks-BCaVWsWl.js.map differ diff --git a/frontend/prebuilt/assets/hooks-BHXvHkvm.js b/frontend/prebuilt/assets/hooks-BHXvHkvm.js deleted file mode 100644 index ae98588f..00000000 Binary files a/frontend/prebuilt/assets/hooks-BHXvHkvm.js and /dev/null differ diff --git a/frontend/prebuilt/assets/index-D8to1fXL.js b/frontend/prebuilt/assets/index-D8to1fXL.js deleted file mode 100644 index f92fcd8c..00000000 Binary files a/frontend/prebuilt/assets/index-D8to1fXL.js and /dev/null differ diff --git a/frontend/prebuilt/assets/index-D8to1fXL.js.map b/frontend/prebuilt/assets/index-D8to1fXL.js.map deleted file mode 100644 index 41dc715c..00000000 Binary files a/frontend/prebuilt/assets/index-D8to1fXL.js.map and /dev/null differ diff --git a/frontend/prebuilt/assets/index-pnVde5Cl.js b/frontend/prebuilt/assets/index-pnVde5Cl.js new file mode 100644 index 00000000..269c07c5 Binary files /dev/null and b/frontend/prebuilt/assets/index-pnVde5Cl.js differ diff --git a/frontend/prebuilt/assets/index-pnVde5Cl.js.map b/frontend/prebuilt/assets/index-pnVde5Cl.js.map new file mode 100644 index 00000000..1d55c813 Binary files /dev/null and b/frontend/prebuilt/assets/index-pnVde5Cl.js.map differ diff --git a/frontend/prebuilt/assets/leaflet-CQVZ2keI.js b/frontend/prebuilt/assets/leaflet-By4Srz79.js similarity index 99% rename from frontend/prebuilt/assets/leaflet-CQVZ2keI.js rename to frontend/prebuilt/assets/leaflet-By4Srz79.js index 6017e0d9..5168290c 100644 Binary files a/frontend/prebuilt/assets/leaflet-CQVZ2keI.js and b/frontend/prebuilt/assets/leaflet-By4Srz79.js differ diff --git a/frontend/prebuilt/assets/leaflet-CQVZ2keI.js.map b/frontend/prebuilt/assets/leaflet-By4Srz79.js.map similarity index 99% rename from frontend/prebuilt/assets/leaflet-CQVZ2keI.js.map rename to frontend/prebuilt/assets/leaflet-By4Srz79.js.map index e25fb696..6c07c40c 100644 Binary files a/frontend/prebuilt/assets/leaflet-CQVZ2keI.js.map and b/frontend/prebuilt/assets/leaflet-By4Srz79.js.map differ diff --git a/frontend/prebuilt/assets/separator-CqKVy-fj.js b/frontend/prebuilt/assets/separator-CcV99KWD.js similarity index 89% rename from frontend/prebuilt/assets/separator-CqKVy-fj.js rename to frontend/prebuilt/assets/separator-CcV99KWD.js index 311aeda0..d93cdbae 100644 Binary files a/frontend/prebuilt/assets/separator-CqKVy-fj.js and b/frontend/prebuilt/assets/separator-CcV99KWD.js differ diff --git a/frontend/prebuilt/assets/separator-CqKVy-fj.js.map b/frontend/prebuilt/assets/separator-CcV99KWD.js.map similarity index 98% rename from frontend/prebuilt/assets/separator-CqKVy-fj.js.map rename to frontend/prebuilt/assets/separator-CcV99KWD.js.map index 0774ff3b..e1af8a79 100644 Binary files a/frontend/prebuilt/assets/separator-CqKVy-fj.js.map and b/frontend/prebuilt/assets/separator-CcV99KWD.js.map differ diff --git a/frontend/prebuilt/index.html b/frontend/prebuilt/index.html index 9526f158..a3f1ccfa 100644 Binary files a/frontend/prebuilt/index.html and b/frontend/prebuilt/index.html differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a41d93f6..0751b657 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -135,7 +135,6 @@ export function App() { favorites, fetchAppSettings, handleSaveAppSettings, - handleSortOrderChange, handleToggleFavorite, handleToggleBlockedKey, handleToggleBlockedName, @@ -401,10 +400,7 @@ export function App() { void markAllRead(); }, favorites, - sortOrder: appSettings?.sidebar_sort_order ?? 'recent', - onSortOrderChange: (sortOrder: 'recent' | 'alpha') => { - void handleSortOrderChange(sortOrder); - }, + legacySortOrder: appSettings?.sidebar_sort_order, isConversationNotificationsEnabled, }; const conversationPaneProps = { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 21fb56de..69de4ea1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -19,7 +19,17 @@ import { type Conversation, type Favorite, } from '../types'; -import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState'; +import { + buildSidebarSectionSortOrders, + getStateKey, + loadLegacyLocalStorageSortOrder, + loadLocalStorageSidebarSectionSortOrders, + saveLocalStorageSidebarSectionSortOrders, + type ConversationTimes, + type SidebarSectionSortOrders, + type SidebarSortableSection, + type SortOrder, +} from '../utils/conversationState'; import { getContactDisplayName } from '../utils/pubkey'; import { handleKeyboardActivate } from '../utils/a11y'; import { ContactAvatar } from './ContactAvatar'; @@ -91,13 +101,36 @@ interface SidebarProps { onToggleCracker: () => void; onMarkAllRead: () => void; favorites: Favorite[]; - /** Sort order from server settings */ - sortOrder?: SortOrder; - /** Callback when sort order changes */ - onSortOrderChange?: (order: SortOrder) => void; + /** Legacy global sort order, used only to seed per-section local preferences. */ + legacySortOrder?: SortOrder; isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; } +type InitialSectionSortState = { + orders: SidebarSectionSortOrders; + source: 'section' | 'legacy' | 'none'; +}; + +function loadInitialSectionSortOrders(): InitialSectionSortState { + const storedOrders = loadLocalStorageSidebarSectionSortOrders(); + if (storedOrders) { + return { orders: storedOrders, source: 'section' }; + } + + const legacyOrder = loadLegacyLocalStorageSortOrder(); + if (legacyOrder) { + return { + orders: buildSidebarSectionSortOrders(legacyOrder), + source: 'legacy', + }; + } + + return { + orders: buildSidebarSectionSortOrders(), + source: 'none', + }; +} + export function Sidebar({ contacts, channels, @@ -112,12 +145,12 @@ export function Sidebar({ onToggleCracker, onMarkAllRead, favorites, - sortOrder: sortOrderProp = 'recent', - onSortOrderChange, + legacySortOrder, isConversationNotificationsEnabled, }: SidebarProps) { - const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); + const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []); + const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders); const initialCollapsedState = useMemo(loadCollapsedState, []); const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); @@ -125,10 +158,31 @@ export function Sidebar({ const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const collapseSnapshotRef = useRef(null); + const sectionSortSourceRef = useRef(initialSectionSortState.source); - const handleSortToggle = () => { - const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha'; - onSortOrderChange?.(newOrder); + useEffect(() => { + if (sectionSortSourceRef.current === 'legacy') { + saveLocalStorageSidebarSectionSortOrders(sectionSortOrders); + sectionSortSourceRef.current = 'section'; + return; + } + + if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return; + + const seededOrders = buildSidebarSectionSortOrders(legacySortOrder); + setSectionSortOrders(seededOrders); + saveLocalStorageSidebarSectionSortOrders(seededOrders); + sectionSortSourceRef.current = 'section'; + }, [legacySortOrder, sectionSortOrders]); + + const handleSortToggle = (section: SidebarSortableSection) => { + setSectionSortOrders((prev) => { + const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha'; + const updated = { ...prev, [section]: nextOrder }; + saveLocalStorageSidebarSectionSortOrders(updated); + sectionSortSourceRef.current = 'section'; + return updated; + }); }; const handleSelectConversation = (conversation: Conversation) => { @@ -203,7 +257,7 @@ export function Sidebar({ if (a.name === 'Public') return -1; if (b.name === 'Public') return 1; - if (sortOrder === 'recent') { + if (sectionSortOrders.channels === 'recent') { const timeA = getLastMessageTime('channel', a.key); const timeB = getLastMessageTime('channel', b.key); if (timeA && timeB) return timeB - timeA; @@ -212,13 +266,13 @@ export function Sidebar({ } return a.name.localeCompare(b.name); }), - [uniqueChannels, sortOrder, getLastMessageTime] + [uniqueChannels, sectionSortOrders.channels, getLastMessageTime] ); const sortContactsByOrder = useCallback( - (items: Contact[]) => + (items: Contact[], order: SortOrder) => [...items].sort((a, b) => { - if (sortOrder === 'recent') { + if (order === 'recent') { const timeA = getLastMessageTime('contact', a.public_key); const timeB = getLastMessageTime('contact', b.public_key); if (timeA && timeB) return timeB - timeA; @@ -227,18 +281,26 @@ export function Sidebar({ } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), - [sortOrder, getLastMessageTime] + [getLastMessageTime] ); // Split non-repeater contacts and repeater contacts into separate sorted lists const sortedNonRepeaterContacts = useMemo( - () => sortContactsByOrder(uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER)), - [uniqueContacts, sortContactsByOrder] + () => + sortContactsByOrder( + uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER), + sectionSortOrders.contacts + ), + [uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder] ); const sortedRepeaters = useMemo( - () => sortContactsByOrder(uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER)), - [uniqueContacts, sortContactsByOrder] + () => + sortContactsByOrder( + uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER), + sectionSortOrders.repeaters + ), + [uniqueContacts, sectionSortOrders.repeaters, sortContactsByOrder] ); // Filter by search query @@ -604,11 +666,12 @@ export function Sidebar({ title: string, collapsed: boolean, onToggle: () => void, - showSortToggle = false, + sortSection: SidebarSortableSection | null = null, unreadCount = 0, highlightUnread = false ) => { const effectiveCollapsed = isSearching ? false : collapsed; + const sectionSortOrder = sortSection ? sectionSortOrders[sortSection] : null; return (
@@ -630,16 +693,24 @@ export function Sidebar({ )} {title} - {(showSortToggle || unreadCount > 0) && ( + {(sortSection || unreadCount > 0) && (
- {showSortToggle && ( + {sortSection && sectionSortOrder && ( )} {unreadCount > 0 && ( @@ -731,7 +802,7 @@ export function Sidebar({ 'Favorites', favoritesCollapsed, () => setFavoritesCollapsed((prev) => !prev), - false, + null, favoritesUnreadCount, favoritesHasMention )} @@ -747,7 +818,7 @@ export function Sidebar({ 'Channels', channelsCollapsed, () => setChannelsCollapsed((prev) => !prev), - true, + 'channels', channelsUnreadCount, channelsHasMention )} @@ -763,7 +834,7 @@ export function Sidebar({ 'Contacts', contactsCollapsed, () => setContactsCollapsed((prev) => !prev), - true, + 'contacts', contactsUnreadCount, contactsUnreadCount > 0 )} @@ -779,7 +850,7 @@ export function Sidebar({ 'Repeaters', repeatersCollapsed, () => setRepeatersCollapsed((prev) => !prev), - true, + 'repeaters', repeatersUnreadCount )} {(isSearching || !repeatersCollapsed) && diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index a9118bec..f20ce5b6 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -43,25 +43,6 @@ export function useAppSettings() { [fetchAppSettings] ); - const handleSortOrderChange = useCallback( - async (order: 'recent' | 'alpha') => { - const previousOrder = appSettings?.sidebar_sort_order ?? 'recent'; - - // Optimistic update for responsive UI - setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev)); - - try { - const updatedSettings = await api.updateSettings({ sidebar_sort_order: order }); - setAppSettings(updatedSettings); - } catch (err) { - console.error('Failed to update sort order:', err); - setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev)); - toast.error('Failed to save sort preference'); - } - }, - [appSettings?.sidebar_sort_order] - ); - const handleToggleBlockedKey = useCallback(async (key: string) => { const normalizedKey = key.toLowerCase(); setAppSettings((prev) => { @@ -198,7 +179,6 @@ export function useAppSettings() { favorites, fetchAppSettings, handleSaveAppSettings, - handleSortOrderChange, handleToggleFavorite, handleToggleBlockedKey, handleToggleBlockedName, diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index b2b919bf..d5c348fe 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -75,8 +75,7 @@ function renderSidebar(overrides?: { onToggleCracker={vi.fn()} onMarkAllRead={vi.fn()} favorites={favorites} - sortOrder="recent" - onSortOrderChange={vi.fn()} + legacySortOrder="recent" isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled} /> ); @@ -85,7 +84,7 @@ function renderSidebar(overrides?: { } function getSectionHeaderContainer(title: string): HTMLElement { - const btn = screen.getByRole('button', { name: new RegExp(title, 'i') }); + const btn = screen.getByRole('button', { name: title }); const container = btn.closest('div'); if (!container) throw new Error(`Missing header container for section ${title}`); return container; @@ -142,9 +141,9 @@ describe('Sidebar section summaries', () => { it('expands collapsed sections during search and restores collapse state after clearing search', async () => { const { opsChannel, aliceName } = renderSidebar(); - fireEvent.click(screen.getByRole('button', { name: /Tools/i })); - fireEvent.click(screen.getByRole('button', { name: /Channels/i })); - fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + fireEvent.click(screen.getByRole('button', { name: 'Tools' })); + fireEvent.click(screen.getByRole('button', { name: 'Channels' })); + fireEvent.click(screen.getByRole('button', { name: 'Contacts' })); expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); @@ -169,9 +168,9 @@ describe('Sidebar section summaries', () => { it('persists collapsed section state across unmount and remount', () => { const { opsChannel, aliceName, unmount } = renderSidebar(); - fireEvent.click(screen.getByRole('button', { name: /Tools/i })); - fireEvent.click(screen.getByRole('button', { name: /Channels/i })); - fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + fireEvent.click(screen.getByRole('button', { name: 'Tools' })); + fireEvent.click(screen.getByRole('button', { name: 'Channels' })); + fireEvent.click(screen.getByRole('button', { name: 'Contacts' })); expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); @@ -206,8 +205,7 @@ describe('Sidebar section summaries', () => { onToggleCracker={vi.fn()} onMarkAllRead={vi.fn()} favorites={[]} - sortOrder="recent" - onSortOrderChange={vi.fn()} + legacySortOrder="recent" /> ); @@ -253,4 +251,69 @@ describe('Sidebar section summaries', () => { const unread = within(aliceRow).getByText('3'); expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + + it('sorts each section independently and persists per-section sort preferences', () => { + const publicChannel = makeChannel('AA'.repeat(16), 'Public'); + const zebraChannel = makeChannel('BB'.repeat(16), '#zebra'); + const alphaChannel = makeChannel('CC'.repeat(16), '#alpha'); + const zed = makeContact('11'.repeat(32), 'Zed'); + const amy = makeContact('22'.repeat(32), 'Amy'); + const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER); + const relayAlpha = makeContact('44'.repeat(32), 'Alpha Relay', CONTACT_TYPE_REPEATER); + + const props = { + contacts: [zed, amy, relayZulu, relayAlpha], + channels: [publicChannel, zebraChannel, alphaChannel], + activeConversation: null, + onSelectConversation: vi.fn(), + onNewMessage: vi.fn(), + lastMessageTimes: { + [getStateKey('channel', zebraChannel.key)]: 300, + [getStateKey('channel', alphaChannel.key)]: 100, + [getStateKey('contact', zed.public_key)]: 200, + [getStateKey('contact', amy.public_key)]: 100, + [getStateKey('contact', relayZulu.public_key)]: 300, + [getStateKey('contact', relayAlpha.public_key)]: 100, + }, + unreadCounts: {}, + mentions: {}, + showCracker: false, + crackerRunning: false, + onToggleCracker: vi.fn(), + onMarkAllRead: vi.fn(), + favorites: [], + legacySortOrder: 'recent' as const, + }; + + const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent); + const getContactsOrder = () => + screen + .getAllByText(/^(Amy|Zed)$/) + .map((node) => node.textContent) + .filter((text): text is string => Boolean(text)); + const getRepeatersOrder = () => + screen + .getAllByText(/Relay$/) + .map((node) => node.textContent) + .filter((text): text is string => Boolean(text)); + + const { unmount } = render(); + + expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']); + expect(getContactsOrder()).toEqual(['Zed', 'Amy']); + expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + + fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' })); + + expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); + expect(getContactsOrder()).toEqual(['Zed', 'Amy']); + expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + + unmount(); + render(); + + expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']); + expect(getContactsOrder()).toEqual(['Zed', 'Amy']); + expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']); + }); }); diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index d85860b3..6a42156b 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -11,9 +11,12 @@ const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime'; const SORT_ORDER_KEY = 'remoteterm-sortOrder'; +const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'; export type ConversationTimes = Record; export type SortOrder = 'recent' | 'alpha'; +export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters'; +export type SidebarSectionSortOrders = Record; // In-memory cache of last message times (loaded from server on init) let lastMessageTimesCache: ConversationTimes = {}; @@ -93,6 +96,56 @@ export function loadLocalStorageSortOrder(): SortOrder { } } +/** + * Load the legacy single sidebar sort order from localStorage, if present. + */ +export function loadLegacyLocalStorageSortOrder(): SortOrder | null { + try { + const stored = localStorage.getItem(SORT_ORDER_KEY); + if (!stored) return null; + return stored === 'alpha' ? 'alpha' : 'recent'; + } catch { + return null; + } +} + +export function buildSidebarSectionSortOrders( + defaultOrder: SortOrder = 'recent' +): SidebarSectionSortOrders { + return { + channels: defaultOrder, + contacts: defaultOrder, + repeaters: defaultOrder, + }; +} + +/** + * Load per-section sidebar sort orders from localStorage. + */ +export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOrders | null { + try { + const stored = localStorage.getItem(SIDEBAR_SECTION_SORT_ORDERS_KEY); + if (!stored) return null; + + const parsed = JSON.parse(stored) as Partial; + return { + channels: parsed.channels === 'alpha' ? 'alpha' : 'recent', + contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent', + repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent', + }; + } catch { + return null; + } +} + +export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionSortOrders): void { + try { + localStorage.setItem(SIDEBAR_SECTION_SORT_ORDERS_KEY, JSON.stringify(orders)); + } catch { + // localStorage might be disabled + } +} + /** * Clear conversation state from localStorage (after migration) */