Files
Remote-Terminal-for-MeshCore/frontend/src/components/Sidebar.tsx

769 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}