mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-09-01 16:48:29 +00:00
Fix sidebar sort order. Closes #61
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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 = {
|
||||
|
||||
@@ -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<CollapseState | null>(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 (
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||||
@@ -630,16 +693,24 @@ export function Sidebar({
|
||||
)}
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{(showSortToggle || unreadCount > 0) && (
|
||||
{(sortSection || unreadCount > 0) && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{showSortToggle && (
|
||||
{sortSection && sectionSortOrder && (
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleSortToggle}
|
||||
aria-label={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
onClick={() => handleSortToggle(sortSection)}
|
||||
aria-label={
|
||||
sectionSortOrder === 'alpha'
|
||||
? `Sort ${title} by recent`
|
||||
: `Sort ${title} alphabetically`
|
||||
}
|
||||
title={
|
||||
sectionSortOrder === 'alpha'
|
||||
? `Sort ${title} by recent`
|
||||
: `Sort ${title} alphabetically`
|
||||
}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
{sectionSortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
)}
|
||||
{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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<Sidebar {...props} />);
|
||||
|
||||
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(<Sidebar {...props} />);
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getRepeatersOrder()).toEqual(['Zulu Relay', 'Alpha Relay']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number>;
|
||||
export type SortOrder = 'recent' | 'alpha';
|
||||
export type SidebarSortableSection = 'channels' | 'contacts' | 'repeaters';
|
||||
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
|
||||
|
||||
// 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<SidebarSectionSortOrders>;
|
||||
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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user