import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CONTACT_TYPE_REPEATER, type Contact, type Channel, type Conversation, type Favorite, } from '../types'; import { getStateKey, type ConversationTimes } from '../utils/conversationState'; import { getContactDisplayName } from '../utils/pubkey'; 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 SortOrder = 'alpha' | 'recent'; 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; contact?: Contact; }; type CollapseState = { favorites: boolean; channels: boolean; contacts: boolean; repeaters: boolean; }; const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state'; const DEFAULT_COLLAPSE_STATE: CollapseState = { favorites: false, channels: false, contacts: 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 { favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites, channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels, contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts, 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: () => 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[]; /** Sort order from server settings */ sortOrder?: SortOrder; /** Callback when sort order changes */ onSortOrderChange?: (order: SortOrder) => void; } export function Sidebar({ contacts, channels, activeConversation, onSelectConversation, onNewMessage, lastMessageTimes, unreadCounts, mentions, showCracker, crackerRunning, onToggleCracker, onMarkAllRead, favorites, sortOrder: sortOrderProp = 'recent', onSortOrderChange, }: SidebarProps) { const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); const initialCollapsedState = useMemo(loadCollapsedState, []); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels); const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const collapseSnapshotRef = useRef(null); const handleSortToggle = () => { const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha'; onSortOrderChange?.(newOrder); }; const handleSelectConversation = (conversation: Conversation) => { setSearchQuery(''); onSelectConversation(conversation); }; const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', 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] ); // 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 (a.name === 'Public') return -1; if (b.name === 'Public') return 1; if (sortOrder === '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, sortOrder, getLastMessageTime] ); const sortContactsByOrder = useCallback( (items: Contact[]) => [...items].sort((a, b) => { if (sortOrder === 'recent') { const timeA = getLastMessageTime('contact', a.public_key); const timeB = getLastMessageTime('contact', b.public_key); 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); }), [sortOrder, 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] ); const sortedRepeaters = useMemo( () => sortContactsByOrder(uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER)), [uniqueContacts, sortContactsByOrder] ); // 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( () => query ? sortedNonRepeaterContacts.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) : sortedNonRepeaterContacts, [sortedNonRepeaterContacts, query] ); const filteredRepeaters = useMemo( () => query ? sortedRepeaters.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) : sortedRepeaters, [sortedRepeaters, query] ); // Expand sections while searching; restore prior collapse state when search ends. useEffect(() => { if (isSearching) { if (!collapseSnapshotRef.current) { collapseSnapshotRef.current = { favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, repeaters: repeatersCollapsed, }; } if (favoritesCollapsed || channelsCollapsed || contactsCollapsed || repeatersCollapsed) { setFavoritesCollapsed(false); setChannelsCollapsed(false); setContactsCollapsed(false); setRepeatersCollapsed(false); } return; } if (collapseSnapshotRef.current) { const prev = collapseSnapshotRef.current; collapseSnapshotRef.current = null; setFavoritesCollapsed(prev.favorites); setChannelsCollapsed(prev.channels); setContactsCollapsed(prev.contacts); setRepeatersCollapsed(prev.repeaters); } }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); useEffect(() => { if (isSearching) return; const state: CollapseState = { favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, repeaters: repeatersCollapsed, }; try { localStorage.setItem(SIDEBAR_COLLAPSE_STATE_KEY, JSON.stringify(state)); } catch { // Ignore localStorage write failures (e.g., disabled storage) } }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); // Separate favorites from regular items, and build combined favorites list const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = useMemo(() => { const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key)); const favContacts = [...filteredNonRepeaterContacts, ...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 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 })), ].sort((a, b) => { const timeA = a.type === 'channel' ? getLastMessageTime('channel', a.channel.key) : getLastMessageTime('contact', a.contact.public_key); const timeB = b.type === 'channel' ? getLastMessageTime('channel', b.channel.key) : getLastMessageTime('contact', b.contact.public_key); if (timeA && timeB) return timeB - timeA; if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; const nameA = a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key; const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key; return nameA.localeCompare(nameB); }); return { favoriteItems: items, nonFavoriteChannels: nonFavChannels, nonFavoriteContacts: nonFavContacts, nonFavoriteRepeaters: nonFavRepeaters, }; }, [ filteredChannels, filteredNonRepeaterContacts, filteredRepeaters, favorites, getLastMessageTime, ]); 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), }); 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), unreadCount: getUnreadCount('contact', contact.public_key), isMention: hasMention('contact', contact.public_key), contact, }); const renderConversationRow = (row: ConversationRow) => (
0 && '[&_.name]:font-semibold [&_.name]:text-foreground' )} onClick={() => handleSelectConversation({ type: row.type, id: row.id, name: row.name, }) } > {row.type === 'contact' && row.contact && ( )} {row.name} {row.unreadCount > 0 && ( {row.unreadCount} )}
); const getSectionUnreadCount = (rows: ConversationRow[]): number => rows.reduce((total, row) => total + row.unreadCount, 0); 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 repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater')); const favoritesUnreadCount = getSectionUnreadCount(favoriteRows); const channelsUnreadCount = getSectionUnreadCount(channelRows); const contactsUnreadCount = getSectionUnreadCount(contactRows); const repeatersUnreadCount = getSectionUnreadCount(repeaterRows); const renderSectionHeader = ( title: string, collapsed: boolean, onToggle: () => void, showSortToggle = false, unreadCount = 0 ) => { const effectiveCollapsed = isSearching ? false : collapsed; return (
{(showSortToggle || unreadCount > 0) && (
{showSortToggle && ( )} {unreadCount > 0 && ( {unreadCount} )}
)}
); }; return (
{/* Header */}

Conversations

{/* Search */}
setSearchQuery(e.target.value)} className="h-7 text-[13px] pr-8 bg-background/50" /> {searchQuery && ( )}
{/* List */}
{/* Raw Packet Feed */} {!query && (
handleSelectConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed', }) } > πŸ“‘ Packet Feed
)} {/* Node Map */} {!query && (
handleSelectConversation({ type: 'map', id: 'map', name: 'Node Map', }) } > πŸ—ΊοΈ Node Map
)} {/* Mesh Visualizer */} {!query && (
handleSelectConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer', }) } > ✨ Mesh Visualizer
)} {/* Cracker Toggle */} {!query && (
πŸ”“ {showCracker ? 'Hide' : 'Show'} Room Finder ({crackerRunning ? 'running' : 'idle'})
)} {/* Mark All Read */} {!query && Object.keys(unreadCounts).length > 0 && (
βœ“ Mark all as read
)} {/* Favorites */} {favoriteItems.length > 0 && ( <> {renderSectionHeader( 'Favorites', favoritesCollapsed, () => setFavoritesCollapsed((prev) => !prev), false, favoritesUnreadCount )} {(isSearching || !favoritesCollapsed) && favoriteRows.map((row) => renderConversationRow(row))} )} {/* Channels */} {nonFavoriteChannels.length > 0 && ( <> {renderSectionHeader( 'Channels', channelsCollapsed, () => setChannelsCollapsed((prev) => !prev), true, channelsUnreadCount )} {(isSearching || !channelsCollapsed) && channelRows.map((row) => renderConversationRow(row))} )} {/* Contacts */} {nonFavoriteContacts.length > 0 && ( <> {renderSectionHeader( 'Contacts', contactsCollapsed, () => setContactsCollapsed((prev) => !prev), true, contactsUnreadCount )} {(isSearching || !contactsCollapsed) && contactRows.map((row) => renderConversationRow(row))} )} {/* Repeaters */} {nonFavoriteRepeaters.length > 0 && ( <> {renderSectionHeader( 'Repeaters', repeatersCollapsed, () => setRepeatersCollapsed((prev) => !prev), true, repeatersUnreadCount )} {(isSearching || !repeatersCollapsed) && repeaterRows.map((row) => renderConversationRow(row))} )} {/* Empty state */} {nonFavoriteContacts.length === 0 && nonFavoriteChannels.length === 0 && nonFavoriteRepeaters.length === 0 && favoriteItems.length === 0 && (
{query ? 'No matches found' : 'No conversations yet'}
)}
); }