mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
769 lines
25 KiB
TypeScript
769 lines
25 KiB
TypeScript
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, type SortOrder } from '../utils/conversationState';
|
||
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;
|
||
contact?: Contact;
|
||
};
|
||
|
||
type CollapseState = {
|
||
tools: boolean;
|
||
favorites: boolean;
|
||
channels: boolean;
|
||
contacts: 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,
|
||
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<CollapseState>;
|
||
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,
|
||
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<string, number>;
|
||
/** Tracks which conversations have unread messages that mention the user */
|
||
mentions: Record<string, boolean>;
|
||
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 [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
|
||
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<CollapseState | null>(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' | 'search',
|
||
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<Channel[]>((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<Contact[]>((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 = {
|
||
tools: toolsCollapsed,
|
||
favorites: favoritesCollapsed,
|
||
channels: channelsCollapsed,
|
||
contacts: contactsCollapsed,
|
||
repeaters: repeatersCollapsed,
|
||
};
|
||
}
|
||
|
||
if (
|
||
toolsCollapsed ||
|
||
favoritesCollapsed ||
|
||
channelsCollapsed ||
|
||
contactsCollapsed ||
|
||
repeatersCollapsed
|
||
) {
|
||
setToolsCollapsed(false);
|
||
setFavoritesCollapsed(false);
|
||
setChannelsCollapsed(false);
|
||
setContactsCollapsed(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);
|
||
setRepeatersCollapsed(prev.repeaters);
|
||
}
|
||
}, [
|
||
isSearching,
|
||
toolsCollapsed,
|
||
favoritesCollapsed,
|
||
channelsCollapsed,
|
||
contactsCollapsed,
|
||
repeatersCollapsed,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (isSearching) return;
|
||
|
||
const state: CollapseState = {
|
||
tools: toolsCollapsed,
|
||
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,
|
||
toolsCollapsed,
|
||
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) => (
|
||
<div
|
||
key={row.key}
|
||
className={cn(
|
||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||
row.unreadCount > 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 && (
|
||
<ContactAvatar
|
||
name={row.contact.name}
|
||
publicKey={row.contact.public_key}
|
||
size={24}
|
||
contactType={row.contact.type}
|
||
/>
|
||
)}
|
||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||
{row.unreadCount > 0 && (
|
||
<span
|
||
className={cn(
|
||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||
row.isMention
|
||
? 'bg-badge-mention text-badge-mention-foreground'
|
||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||
)}
|
||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||
>
|
||
{row.unreadCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const renderSidebarActionRow = ({
|
||
key,
|
||
active = false,
|
||
icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
key: string;
|
||
active?: boolean;
|
||
icon: string;
|
||
label: React.ReactNode;
|
||
onClick: () => void;
|
||
}) => (
|
||
<div
|
||
key={key}
|
||
className={cn(
|
||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
active && 'bg-accent border-l-primary'
|
||
)}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-current={active ? 'page' : undefined}
|
||
onKeyDown={handleKeyboardActivate}
|
||
onClick={onClick}
|
||
>
|
||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||
{icon}
|
||
</span>
|
||
<span className="flex-1 truncate text-muted-foreground">{label}</span>
|
||
</div>
|
||
);
|
||
|
||
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 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-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'} Room Finder
|
||
<span
|
||
className={cn(
|
||
'ml-1 text-[11px]',
|
||
crackerRunning ? 'text-primary' : 'text-muted-foreground'
|
||
)}
|
||
>
|
||
({crackerRunning ? 'running' : 'idle'})
|
||
</span>
|
||
</>
|
||
),
|
||
onClick: onToggleCracker,
|
||
}),
|
||
]
|
||
: [];
|
||
|
||
const renderSectionHeader = (
|
||
title: string,
|
||
collapsed: boolean,
|
||
onToggle: () => void,
|
||
showSortToggle = false,
|
||
unreadCount = 0
|
||
) => {
|
||
const effectiveCollapsed = isSearching ? false : collapsed;
|
||
|
||
return (
|
||
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
|
||
<button
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
|
||
isSearching && 'cursor-default'
|
||
)}
|
||
aria-expanded={!effectiveCollapsed}
|
||
onClick={() => {
|
||
if (!isSearching) onToggle();
|
||
}}
|
||
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
|
||
>
|
||
<span className="text-[9px]" aria-hidden="true">
|
||
{effectiveCollapsed ? '▸' : '▾'}
|
||
</span>
|
||
<span>{title}</span>
|
||
</button>
|
||
{(showSortToggle || unreadCount > 0) && (
|
||
<div className="ml-auto flex items-center gap-1.5">
|
||
{showSortToggle && (
|
||
<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'}
|
||
>
|
||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||
</button>
|
||
)}
|
||
{unreadCount > 0 && (
|
||
<span
|
||
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
|
||
aria-label={`${unreadCount} unread`}
|
||
>
|
||
{unreadCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<nav
|
||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||
aria-label="Conversations"
|
||
>
|
||
{/* Header */}
|
||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||
Conversations
|
||
</h2>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onNewMessage}
|
||
title="New Message"
|
||
aria-label="New message"
|
||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
+
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="relative px-3 py-2">
|
||
<Input
|
||
type="text"
|
||
placeholder="Search..."
|
||
aria-label="Search conversations"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="h-7 text-[13px] pr-8 bg-background/50"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||
onClick={() => setSearchQuery('')}
|
||
title="Clear search"
|
||
aria-label="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* List */}
|
||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||
{/* Tools */}
|
||
{toolRows.length > 0 && (
|
||
<>
|
||
{renderSectionHeader('Tools', toolsCollapsed, () => setToolsCollapsed((prev) => !prev))}
|
||
{(isSearching || !toolsCollapsed) && toolRows}
|
||
</>
|
||
)}
|
||
|
||
{/* Mark All Read */}
|
||
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
|
||
<div
|
||
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={handleKeyboardActivate}
|
||
onClick={onMarkAllRead}
|
||
>
|
||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||
✓
|
||
</span>
|
||
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<div className="p-5 text-center text-muted-foreground">
|
||
{query ? 'No matches found' : 'No conversations yet'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|