import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bell, Cable, ChartNetwork, CheckCheck, ChevronDown, ChevronRight, LockOpen, Logs, Map, Search as SearchIcon, SquarePen, X, } from 'lucide-react'; import { CONTACT_TYPE_ROOM, CONTACT_TYPE_REPEATER, type Contact, type Channel, type Conversation, type Favorite, } from '../types'; import { buildSidebarSectionSortOrders, getStateKey, loadLegacyLocalStorageSortOrder, loadLocalStorageSidebarSectionSortOrders, saveLocalStorageSidebarSectionSortOrders, type ConversationTimes, type SidebarSectionSortOrders, type SidebarSortableSection, type SortOrder, } from '../utils/conversationState'; import { isPublicChannelKey } from '../utils/publicChannel'; import { getContactDisplayName } from '../utils/pubkey'; import { handleKeyboardActivate } from '../utils/a11y'; import { ContactAvatar } from './ContactAvatar'; import { isFavorite } from '../utils/favorites'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact }; type ConversationRow = { key: string; type: 'channel' | 'contact'; id: string; name: string; unreadCount: number; isMention: boolean; notificationsEnabled: boolean; contact?: Contact; }; type CollapseState = { tools: boolean; favorites: boolean; channels: boolean; contacts: boolean; rooms: boolean; repeaters: boolean; }; const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state'; const DEFAULT_COLLAPSE_STATE: CollapseState = { tools: false, favorites: false, channels: false, contacts: false, rooms: false, repeaters: false, }; function loadCollapsedState(): CollapseState { try { const raw = localStorage.getItem(SIDEBAR_COLLAPSE_STATE_KEY); if (!raw) return DEFAULT_COLLAPSE_STATE; const parsed = JSON.parse(raw) as Partial; return { tools: parsed.tools ?? DEFAULT_COLLAPSE_STATE.tools, favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites, channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels, contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts, rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms, repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters, }; } catch { return DEFAULT_COLLAPSE_STATE; } } interface SidebarProps { contacts: Contact[]; channels: Channel[]; activeConversation: Conversation | null; onSelectConversation: (conversation: Conversation) => void; onNewMessage: (event?: React.MouseEvent) => void; lastMessageTimes: ConversationTimes; unreadCounts: Record; /** Tracks which conversations have unread messages that mention the user */ mentions: Record; showCracker: boolean; crackerRunning: boolean; onToggleCracker: () => void; onMarkAllRead: () => void; favorites: Favorite[]; /** Legacy global sort order, used only to seed per-section local preferences. */ legacySortOrder?: SortOrder; isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; blockedKeys?: string[]; blockedNames?: string[]; } 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, activeConversation, onSelectConversation, onNewMessage, lastMessageTimes, unreadCounts, mentions, showCracker, crackerRunning, onToggleCracker, onMarkAllRead, favorites, legacySortOrder, isConversationNotificationsEnabled, blockedKeys = [], blockedNames = [], }: SidebarProps) { const isContactBlocked = useCallback( (c: Contact) => blockedKeys.includes(c.public_key.toLowerCase()) || (c.name != null && blockedNames.includes(c.name)), [blockedKeys, blockedNames] ); 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); const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels); const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms); const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const collapseSnapshotRef = useRef(null); const sectionSortSourceRef = useRef(initialSectionSortState.source); 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) => { setSearchQuery(''); onSelectConversation(conversation); }; const isActive = ( type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace', id: string ) => activeConversation?.type === type && activeConversation?.id === id; // Get unread count for a conversation const getUnreadCount = (type: 'channel' | 'contact', id: string): number => { const key = getStateKey(type, id); return unreadCounts[key] || 0; }; // Check if a conversation has a mention const hasMention = (type: 'channel' | 'contact', id: string): boolean => { const key = getStateKey(type, id); return mentions[key] || false; }; const getLastMessageTime = useCallback( (type: 'channel' | 'contact', id: string) => { const key = getStateKey(type, id); return lastMessageTimes[key] || 0; }, [lastMessageTimes] ); const getContactHeardTime = useCallback((contact: Contact): number => { return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0); }, []); const getContactRecentTime = useCallback( (contact: Contact): number => { if (contact.type === CONTACT_TYPE_REPEATER) { return getContactHeardTime(contact); } return getLastMessageTime('contact', contact.public_key) || getContactHeardTime(contact); }, [getContactHeardTime, getLastMessageTime] ); // Deduplicate channels by key only. // Channel names are not unique; distinct keys must remain visible. const uniqueChannels = useMemo( () => channels.reduce((acc, channel) => { if (!acc.some((c) => c.key === channel.key)) { acc.push(channel); } return acc; }, []), [channels] ); // Deduplicate contacts by public key, preferring ones with names // Also filter out any contacts with empty public keys const uniqueContacts = useMemo( () => contacts .filter((c) => c.public_key && c.public_key.length > 0) .sort((a, b) => { // Sort contacts with names first if (a.name && !b.name) return -1; if (!a.name && b.name) return 1; return (a.name || '').localeCompare(b.name || ''); }) .reduce((acc, contact) => { if (!acc.some((c) => c.public_key === contact.public_key)) { acc.push(contact); } return acc; }, []), [contacts] ); // Sort channels based on sort order, with Public always first const sortedChannels = useMemo( () => [...uniqueChannels].sort((a, b) => { // Public channel always sorts to the top if (isPublicChannelKey(a.key)) return -1; if (isPublicChannelKey(b.key)) return 1; if (sectionSortOrders.channels === 'recent') { const timeA = getLastMessageTime('channel', a.key); const timeB = getLastMessageTime('channel', b.key); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; } return a.name.localeCompare(b.name); }), [uniqueChannels, sectionSortOrders.channels, getLastMessageTime] ); const sortContactsByOrder = useCallback( (items: Contact[], order: SortOrder) => [...items].sort((a, b) => { if (order === 'recent') { const timeA = getContactRecentTime(a); const timeB = getContactRecentTime(b); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), [getContactRecentTime] ); const sortRepeatersByOrder = useCallback( (items: Contact[], order: SortOrder) => [...items].sort((a, b) => { if (order === 'recent') { const timeA = getContactHeardTime(a); const timeB = getContactHeardTime(b); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), [getContactHeardTime] ); const getFavoriteItemName = useCallback( (item: FavoriteItem) => item.type === 'channel' ? item.channel.name : getContactDisplayName( item.contact.name, item.contact.public_key, item.contact.last_advert ), [] ); const sortFavoriteItemsByOrder = useCallback( (items: FavoriteItem[], order: SortOrder) => [...items].sort((a, b) => { if (order === 'recent') { const timeA = a.type === 'channel' ? getLastMessageTime('channel', a.channel.key) : getContactRecentTime(a.contact); const timeB = b.type === 'channel' ? getLastMessageTime('channel', b.channel.key) : getContactRecentTime(b.contact); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; } return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b)); }), [getContactRecentTime, getFavoriteItemName, getLastMessageTime] ); // Split non-repeater contacts and repeater contacts into separate sorted lists const sortedNonRepeaterContacts = useMemo( () => sortContactsByOrder( uniqueContacts.filter( (c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM ), sectionSortOrders.contacts ), [uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder] ); const sortedRooms = useMemo( () => sortContactsByOrder( uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM), sectionSortOrders.rooms ), [uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder] ); const sortedRepeaters = useMemo( () => sortRepeatersByOrder( uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER), sectionSortOrders.repeaters ), [uniqueContacts, sectionSortOrders.repeaters, sortRepeatersByOrder] ); // Filter by search query const query = searchQuery.toLowerCase().trim(); const isSearching = query.length > 0; const filteredChannels = useMemo( () => query ? sortedChannels.filter( (c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query) ) : sortedChannels, [sortedChannels, query] ); const filteredNonRepeaterContacts = useMemo(() => { const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c)); return query ? visible.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) : visible; }, [sortedNonRepeaterContacts, query, isContactBlocked]); const filteredRooms = useMemo(() => { const visible = sortedRooms.filter((c) => !isContactBlocked(c)); return query ? visible.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) : visible; }, [sortedRooms, query, isContactBlocked]); const filteredRepeaters = useMemo(() => { const visible = sortedRepeaters.filter((c) => !isContactBlocked(c)); return query ? visible.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) : visible; }, [sortedRepeaters, query, isContactBlocked]); // Expand sections while searching; restore prior collapse state when search ends. useEffect(() => { if (isSearching) { if (!collapseSnapshotRef.current) { collapseSnapshotRef.current = { tools: toolsCollapsed, favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, rooms: roomsCollapsed, repeaters: repeatersCollapsed, }; } if ( toolsCollapsed || favoritesCollapsed || channelsCollapsed || contactsCollapsed || roomsCollapsed || repeatersCollapsed ) { setToolsCollapsed(false); setFavoritesCollapsed(false); setChannelsCollapsed(false); setContactsCollapsed(false); setRoomsCollapsed(false); setRepeatersCollapsed(false); } return; } if (collapseSnapshotRef.current) { const prev = collapseSnapshotRef.current; collapseSnapshotRef.current = null; setToolsCollapsed(prev.tools); setFavoritesCollapsed(prev.favorites); setChannelsCollapsed(prev.channels); setContactsCollapsed(prev.contacts); setRoomsCollapsed(prev.rooms); setRepeatersCollapsed(prev.repeaters); } }, [ isSearching, toolsCollapsed, favoritesCollapsed, channelsCollapsed, contactsCollapsed, roomsCollapsed, repeatersCollapsed, ]); useEffect(() => { if (isSearching) return; const state: CollapseState = { tools: toolsCollapsed, favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, rooms: roomsCollapsed, repeaters: repeatersCollapsed, }; try { localStorage.setItem(SIDEBAR_COLLAPSE_STATE_KEY, JSON.stringify(state)); } catch { // Ignore localStorage write failures (e.g., disabled storage) } }, [ isSearching, toolsCollapsed, favoritesCollapsed, channelsCollapsed, contactsCollapsed, roomsCollapsed, repeatersCollapsed, ]); // Separate favorites from regular items, and build combined favorites list const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRooms, nonFavoriteRepeaters, } = useMemo(() => { const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key)); const favContacts = [ ...filteredNonRepeaterContacts, ...filteredRooms, ...filteredRepeaters, ].filter((c) => isFavorite(favorites, 'contact', c.public_key)); const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key)); const nonFavContacts = filteredNonRepeaterContacts.filter( (c) => !isFavorite(favorites, 'contact', c.public_key) ); const nonFavRooms = filteredRooms.filter( (c) => !isFavorite(favorites, 'contact', c.public_key) ); const nonFavRepeaters = filteredRepeaters.filter( (c) => !isFavorite(favorites, 'contact', c.public_key) ); const items: FavoriteItem[] = [ ...favChannels.map((channel) => ({ type: 'channel' as const, channel })), ...favContacts.map((contact) => ({ type: 'contact' as const, contact })), ]; return { favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites), nonFavoriteChannels: nonFavChannels, nonFavoriteContacts: nonFavContacts, nonFavoriteRooms: nonFavRooms, nonFavoriteRepeaters: nonFavRepeaters, }; }, [ filteredChannels, filteredNonRepeaterContacts, filteredRooms, filteredRepeaters, favorites, sectionSortOrders.favorites, sortFavoriteItemsByOrder, ]); const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({ key: `${keyPrefix}-${channel.key}`, type: 'channel', id: channel.key, name: channel.name, unreadCount: getUnreadCount('channel', channel.key), isMention: hasMention('channel', channel.key), notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false, }); const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({ key: `${keyPrefix}-${contact.public_key}`, type: 'contact', id: contact.public_key, name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert), unreadCount: getUnreadCount('contact', contact.public_key), isMention: hasMention('contact', contact.public_key), notificationsEnabled: isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false, contact, }); const renderConversationRow = (row: ConversationRow) => { const highlightUnread = row.isMention || (row.type === 'contact' && row.contact?.type !== CONTACT_TYPE_REPEATER && row.unreadCount > 0); return (
0 && '[&_.name]:font-semibold [&_.name]:text-foreground' )} role="button" tabIndex={0} aria-current={isActive(row.type, row.id) ? 'page' : undefined} onKeyDown={handleKeyboardActivate} onClick={() => handleSelectConversation({ type: row.type, id: row.id, name: row.name, }) } > {row.type === 'contact' && row.contact && ( )} {row.name} {row.notificationsEnabled && ( )} {row.unreadCount > 0 && ( {row.unreadCount} )}
); }; const renderSidebarActionRow = ({ key, active = false, icon, label, onClick, }: { key: string; active?: boolean; icon: React.ReactNode; label: React.ReactNode; onClick: () => void; }) => (
{label}
); const getSectionUnreadCount = (rows: ConversationRow[]): number => rows.reduce((total, row) => total + row.unreadCount, 0); const sectionHasMention = (rows: ConversationRow[]): boolean => rows.some((row) => row.isMention); const favoriteRows = favoriteItems.map((item) => item.type === 'channel' ? buildChannelRow(item.channel, 'fav-chan') : buildContactRow(item.contact, 'fav-contact') ); const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan')); const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact')); const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room')); const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater')); const favoritesUnreadCount = getSectionUnreadCount(favoriteRows); const channelsUnreadCount = getSectionUnreadCount(channelRows); const contactsUnreadCount = getSectionUnreadCount(contactRows); const roomsUnreadCount = getSectionUnreadCount(roomRows); const repeatersUnreadCount = getSectionUnreadCount(repeaterRows); const favoritesHasMention = sectionHasMention(favoriteRows); const channelsHasMention = sectionHasMention(channelRows); const toolRows = !query ? [ renderSidebarActionRow({ key: 'tool-raw', active: isActive('raw', 'raw'), icon: , label: 'Packet Feed', onClick: () => handleSelectConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed', }), }), renderSidebarActionRow({ key: 'tool-map', active: isActive('map', 'map'), icon: , label: 'Node Map', onClick: () => handleSelectConversation({ type: 'map', id: 'map', name: 'Node Map', }), }), renderSidebarActionRow({ key: 'tool-visualizer', active: isActive('visualizer', 'visualizer'), icon: , label: 'Mesh Visualizer', onClick: () => handleSelectConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer', }), }), renderSidebarActionRow({ key: 'tool-trace', active: isActive('trace', 'trace'), icon: , label: 'Trace', onClick: () => handleSelectConversation({ type: 'trace', id: 'trace', name: 'Trace', }), }), renderSidebarActionRow({ key: 'tool-search', active: isActive('search', 'search'), icon: , label: 'Message Search', onClick: () => handleSelectConversation({ type: 'search', id: 'search', name: 'Message Search', }), }), renderSidebarActionRow({ key: 'tool-cracker', active: showCracker, icon: , label: ( <> {showCracker ? 'Hide' : 'Show'} Channel Finder ({crackerRunning ? 'running' : 'idle'}) ), onClick: onToggleCracker, }), ] : []; const renderSectionHeader = ( title: string, collapsed: boolean, onToggle: () => void, sortSection: SidebarSortableSection | null = null, unreadCount = 0, highlightUnread = false ) => { const effectiveCollapsed = isSearching ? false : collapsed; const sectionSortOrder = sortSection ? sectionSortOrders[sortSection] : null; return (
{(sortSection || unreadCount > 0) && (
{sortSection && sectionSortOrder && ( )} {unreadCount > 0 && ( {unreadCount} )}
)}
); }; return ( ); }