diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b6dab4d..9a04888 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -56,6 +56,10 @@ export function Sidebar({ }: SidebarProps) { const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); + const [favoritesCollapsed, setFavoritesCollapsed] = useState(false); + const [channelsCollapsed, setChannelsCollapsed] = useState(false); + const [contactsCollapsed, setContactsCollapsed] = useState(false); + const [repeatersCollapsed, setRepeatersCollapsed] = useState(false); const handleSortToggle = () => { const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha'; @@ -143,20 +147,9 @@ export function Sidebar({ [uniqueChannels, sortOrder, getLastMessageTime] ); - // Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha) - const sortedContacts = useMemo( - () => - [...uniqueContacts].sort((a, b) => { - const aIsRepeater = a.type === CONTACT_TYPE_REPEATER; - const bIsRepeater = b.type === CONTACT_TYPE_REPEATER; - - if (aIsRepeater && !bIsRepeater) return 1; - if (!aIsRepeater && bIsRepeater) return -1; - - if (aIsRepeater && bIsRepeater) { - return (a.name || a.public_key).localeCompare(b.name || b.public_key); - } - + 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); @@ -166,7 +159,18 @@ export function Sidebar({ } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }), - [uniqueContacts, sortOrder, getLastMessageTime] + [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 @@ -180,27 +184,42 @@ export function Sidebar({ : sortedChannels, [sortedChannels, query] ); - const filteredContacts = useMemo( + + const filteredNonRepeaterContacts = useMemo( () => query - ? sortedContacts.filter( + ? sortedNonRepeaterContacts.filter( (c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) ) - : sortedContacts, - [sortedContacts, 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] ); // Separate favorites from regular items, and build combined favorites list type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact }; - const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts } = useMemo(() => { + const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = useMemo(() => { const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key)); - const favContacts = filteredContacts.filter((c) => + const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) => isFavorite(favorites, 'contact', c.public_key) ); const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key)); - const nonFavContacts = filteredContacts.filter( + const nonFavContacts = filteredNonRepeaterContacts.filter( + (c) => !isFavorite(favorites, 'contact', c.public_key) + ); + const nonFavRepeaters = filteredRepeaters.filter( (c) => !isFavorite(favorites, 'contact', c.public_key) ); @@ -228,8 +247,42 @@ export function Sidebar({ favoriteItems: items, nonFavoriteChannels: nonFavChannels, nonFavoriteContacts: nonFavContacts, + nonFavoriteRepeaters: nonFavRepeaters, }; - }, [filteredChannels, filteredContacts, favorites, getLastMessageTime]); + }, [ + filteredChannels, + filteredNonRepeaterContacts, + filteredRepeaters, + favorites, + getLastMessageTime, + ]); + + const renderSectionHeader = ( + title: string, + collapsed: boolean, + onToggle: () => void, + showSortToggle = false + ) => ( +
+ + {showSortToggle && ( + + )} +
+ ); return (
@@ -367,17 +420,113 @@ export function Sidebar({ {/* Favorites */} {favoriteItems.length > 0 && ( <> -
- Favorites -
- {favoriteItems.map((item) => { - if (item.type === 'channel') { - const channel = item.channel; + {renderSectionHeader( + 'Favorites', + favoritesCollapsed, + () => setFavoritesCollapsed((prev) => !prev), + false + )} + {!favoritesCollapsed && + favoriteItems.map((item) => { + if (item.type === 'channel') { + const channel = item.channel; + const unreadCount = getUnreadCount('channel', channel.key); + const isMention = hasMention('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} + + )} +
+ ); + } else { + const contact = item.contact; + const unreadCount = getUnreadCount('contact', contact.public_key); + const isMention = hasMention('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} + + )} +
+ ); + } + })} + + )} + + {/* Channels */} + {nonFavoriteChannels.length > 0 && ( + <> + {renderSectionHeader( + 'Channels', + channelsCollapsed, + () => setChannelsCollapsed((prev) => !prev), + true + )} + {!channelsCollapsed && + nonFavoriteChannels.map((channel) => { const unreadCount = getUnreadCount('channel', channel.key); const isMention = hasMention('channel', channel.key); return (
); - } else { - const contact = item.contact; + })} + + )} + + {/* Contacts */} + {nonFavoriteContacts.length > 0 && ( + <> + {renderSectionHeader( + 'Contacts', + contactsCollapsed, + () => setContactsCollapsed((prev) => !prev), + true + )} + {!contactsCollapsed && + nonFavoriteContacts.map((contact) => { const unreadCount = getUnreadCount('contact', contact.public_key); const isMention = hasMention('contact', contact.public_key); return (
); - } - })} + })} )} - {/* Channels */} - {nonFavoriteChannels.length > 0 && ( + {/* Repeaters */} + {nonFavoriteRepeaters.length > 0 && ( <> -
- Channels - -
- {nonFavoriteChannels.map((channel) => { - const unreadCount = getUnreadCount('channel', channel.key); - const isMention = hasMention('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} + {renderSectionHeader( + 'Repeaters', + repeatersCollapsed, + () => setRepeatersCollapsed((prev) => !prev), + true + )} + {!repeatersCollapsed && + nonFavoriteRepeaters.map((contact) => { + const unreadCount = getUnreadCount('contact', contact.public_key); + const isMention = hasMention('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)} - )} -
- ); - })} - - )} - - {/* Contacts */} - {nonFavoriteContacts.length > 0 && ( - <> -
- Contacts - {nonFavoriteChannels.length === 0 && ( - - )} -
- {nonFavoriteContacts.map((contact) => { - const unreadCount = getUnreadCount('contact', contact.public_key); - const isMention = hasMention('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} - - )} -
- ); - })} + {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ ); + })} )} {/* Empty state */} {nonFavoriteContacts.length === 0 && nonFavoriteChannels.length === 0 && + nonFavoriteRepeaters.length === 0 && favoriteItems.length === 0 && (
{query ? 'No matches found' : 'No conversations yet'}