import { useState } from 'react'; import type { Contact, Channel, Conversation } from '../types'; import { getStateKey, type ConversationTimes } from '../utils/conversationState'; import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey'; import { ContactAvatar } from './ContactAvatar'; import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar'; import { Input } from './ui/input'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; type SortOrder = 'alpha' | 'recent'; interface SidebarProps { contacts: Contact[]; channels: Channel[]; activeConversation: Conversation | null; onSelectConversation: (conversation: Conversation) => void; onNewMessage: () => void; lastMessageTimes: ConversationTimes; unreadCounts: Record; showCracker: boolean; crackerRunning: boolean; onToggleCracker: () => void; } // Load sort preference from localStorage function loadSortOrder(): SortOrder { try { const stored = localStorage.getItem('remoteterm-sortOrder'); return stored === 'recent' ? 'recent' : 'alpha'; } catch { return 'alpha'; } } // Save sort preference to localStorage function saveSortOrder(order: SortOrder): void { try { localStorage.setItem('remoteterm-sortOrder', order); } catch { // localStorage might be full or disabled } } export function Sidebar({ contacts, channels, activeConversation, onSelectConversation, onNewMessage, lastMessageTimes, unreadCounts, showCracker, crackerRunning, onToggleCracker, }: SidebarProps) { const [sortOrder, setSortOrder] = useState(loadSortOrder); const [searchQuery, setSearchQuery] = useState(''); const handleSortToggle = () => { const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha'; setSortOrder(newOrder); saveSortOrder(newOrder); }; const handleSelectConversation = (conversation: Conversation) => { setSearchQuery(''); onSelectConversation(conversation); }; const isActive = (type: 'contact' | 'channel' | 'raw', 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; }; const getLastMessageTime = (type: 'channel' | 'contact', id: string) => { const key = getStateKey(type, id); return lastMessageTimes[key] || 0; }; // Deduplicate channels by name, keeping the first (lowest index) const uniqueChannels = channels.reduce((acc, channel) => { if (!acc.some((c) => c.name === channel.name)) { acc.push(channel); } return acc; }, []); // Deduplicate contacts by 12-char prefix, preferring ones with names // Also filter out any contacts with empty public keys const uniqueContacts = 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) => { const prefix = getPubkeyPrefix(contact.public_key); if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) { acc.push(contact); } return acc; }, []); // Sort channels based on sort order, with Public always first const sortedChannels = [...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 both have messages, sort by most recent first if (timeA && timeB) return timeB - timeA; // Items with messages come before items without if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; // Fall back to alpha for items without messages } return a.name.localeCompare(b.name); }); // Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha) const sortedContacts = [...uniqueContacts].sort((a, b) => { const aIsRepeater = a.type === CONTACT_TYPE_REPEATER; const bIsRepeater = b.type === CONTACT_TYPE_REPEATER; // Repeaters always go to the bottom if (aIsRepeater && !bIsRepeater) return 1; if (!aIsRepeater && bIsRepeater) return -1; // Both repeaters: always sort alphabetically if (aIsRepeater && bIsRepeater) { return (a.name || a.public_key).localeCompare(b.name || b.public_key); } // Both non-repeaters: use selected sort order if (sortOrder === 'recent') { const timeA = getLastMessageTime('contact', a.public_key); const timeB = getLastMessageTime('contact', b.public_key); // If both have messages, sort by most recent first if (timeA && timeB) return timeB - timeA; // Items with messages come before items without if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; // Fall back to alpha for items without messages } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }); // Filter by search query const query = searchQuery.toLowerCase().trim(); const filteredChannels = query ? sortedChannels.filter((c) => c.name.toLowerCase().includes(query)) : sortedChannels; const filteredContacts = query ? sortedContacts.filter((c) => (c.name?.toLowerCase().includes(query)) || c.public_key.toLowerCase().includes(query) ) : sortedContacts; return (
{/* Header */}

Conversations

{/* Search */}
setSearchQuery(e.target.value)} className="h-8 text-sm pr-8" /> {searchQuery && ( )}
{/* List */}
{/* Raw Packet Feed */} {!query && (
handleSelectConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed', }) } > 📡 Packet Feed
)} {/* Cracker Toggle */} {!query && (
🔓 {showCracker ? 'Hide' : 'Show'} Cracker ({crackerRunning ? 'running' : 'stopped'})
)} {/* Channels */} {filteredChannels.length > 0 && ( <>
Channels
{filteredChannels.map((channel) => { const unreadCount = getUnreadCount('channel', channel.key); return (
0 && "[&_.name]:font-bold [&_.name]:text-foreground" )} onClick={() => handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name, }) } > # {channel.name} {unreadCount > 0 && ( {unreadCount} )}
); })} )} {/* Contacts */} {filteredContacts.length > 0 && ( <>
Contacts {filteredChannels.length === 0 && ( )}
{filteredContacts.map((contact) => { const unreadCount = getUnreadCount('contact', contact.public_key); return (
0 && "[&_.name]:font-bold [&_.name]:text-foreground" )} onClick={() => handleSelectConversation({ type: 'contact', id: contact.public_key, name: getContactDisplayName(contact.name, contact.public_key), }) } > {getContactDisplayName(contact.name, contact.public_key)} {unreadCount > 0 && ( {unreadCount} )}
); })} )} {/* Empty state */} {filteredContacts.length === 0 && filteredChannels.length === 0 && (
{query ? 'No matches found' : 'No conversations yet'}
)}
); }