Add favorites

This commit is contained in:
Jack Kingsman
2026-01-16 16:33:12 -08:00
parent 156b129af4
commit cfa7f53836
14 changed files with 844 additions and 586 deletions
+65 -21
View File
@@ -23,6 +23,7 @@ import { getStateKey } from './utils/conversationState';
import { formatTime } from './utils/messageParser';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
import { loadFavorites, toggleFavorite, isFavorite, type Favorite } from './utils/favorites';
import { cn } from '@/lib/utils';
import type {
AppSettings,
@@ -59,6 +60,7 @@ export function App() {
const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [favorites, setFavorites] = useState<Favorite[]>(loadFavorites);
// Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null);
@@ -415,6 +417,11 @@ export function App() {
setSidebarOpen(false);
}, []);
// Toggle favorite status for a conversation
const handleToggleFavorite = useCallback((type: 'channel' | 'contact', id: string) => {
setFavorites(toggleFavorite(type, id));
}, []);
// Delete channel handler
const handleDeleteChannel = useCallback(async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -548,6 +555,7 @@ export function App() {
crackerRunning={crackerRunning}
onToggleCracker={() => setShowCracker((prev) => !prev)}
onMarkAllRead={markAllRead}
favorites={favorites}
/>
);
@@ -580,7 +588,7 @@ export function App() {
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
Node Map
</div>
<div className="flex-1 overflow-hidden">
@@ -589,7 +597,7 @@ export function App() {
</>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
Raw Packet Feed
</div>
<div className="flex-1 overflow-hidden">
@@ -598,16 +606,16 @@ export function App() {
</>
) : (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium gap-2">
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2 min-w-0 flex-1">
<span className="truncate">
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0">
{activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#')
? '#'
: ''}
{activeConversation.name}
</span>
<span className="font-normal text-xs text-muted-foreground font-mono truncate">
<span className="font-normal text-sm text-muted-foreground font-mono truncate">
{activeConversation.id}
{activeConversation.type === 'contact' &&
(() => {
@@ -634,22 +642,58 @@ export function App() {
})()}
</span>
</span>
{!(
activeConversation.type === 'channel' && activeConversation.name === 'Public'
) && (
<button
className="py-1 px-3 bg-destructive/20 border border-destructive/30 text-destructive rounded text-xs cursor-pointer hover:bg-destructive/30 flex-shrink-0"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
} else {
handleDeleteContact(activeConversation.id);
<div className="flex items-center gap-1 flex-shrink-0">
{/* Favorite button */}
{(activeConversation.type === 'channel' ||
activeConversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
onClick={() =>
handleToggleFavorite(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
}
}}
>
Delete
</button>
)}
title={
isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
) ? (
<span className="text-yellow-500">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
)}
{/* Delete button */}
{!(
activeConversation.type === 'channel' && activeConversation.name === 'Public'
) && (
<button
className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
} else {
handleDeleteContact(activeConversation.id);
}
}}
title="Delete"
>
&#128465;
</button>
)}
</div>
</div>
<MessageList
messages={messages}
+143 -10
View File
@@ -4,6 +4,7 @@ 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 { isFavorite, type Favorite } from '../utils/favorites';
import { UNREAD_FETCH_LIMIT } from '../api';
import { Input } from './ui/input';
import { Button } from './ui/button';
@@ -25,6 +26,7 @@ interface SidebarProps {
crackerRunning: boolean;
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
}
/** Format unread count, showing "X+" if at the fetch limit (indicating there may be more) */
@@ -64,6 +66,7 @@ export function Sidebar({
crackerRunning,
onToggleCracker,
onMarkAllRead,
favorites,
}: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const [searchQuery, setSearchQuery] = useState('');
@@ -183,6 +186,43 @@ export function Sidebar({
)
: sortedContacts;
// Separate favorites from regular items
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favoriteContacts = filteredContacts.filter((c) =>
isFavorite(favorites, 'contact', c.public_key)
);
const nonFavoriteChannels = filteredChannels.filter(
(c) => !isFavorite(favorites, 'channel', c.key)
);
const nonFavoriteContacts = filteredContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
// Combine and sort favorites by most recent message (always recent order)
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
const favoriteItems: FavoriteItem[] = [
...favoriteChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favoriteContacts.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);
// Sort by most recent first
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to name comparison
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 (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
{/* Header */}
@@ -296,8 +336,99 @@ export function Sidebar({
</div>
)}
{/* Favorites */}
{favoriteItems.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Favorites</span>
</div>
{favoriteItems.map((item) => {
if (item.type === 'channel') {
const channel = item.channel;
const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key);
return (
<div
key={`fav-chan-${channel.key}`}
className={cn(
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('channel', channel.key) && 'bg-accent border-l-primary',
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
)}
onClick={() =>
handleSelectConversation({
type: 'channel',
id: channel.key,
name: channel.name,
})
}
>
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{formatUnreadCount(unreadCount)}
</span>
)}
</div>
);
} else {
const contact = item.contact;
const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key);
return (
<div
key={`fav-contact-${contact.public_key}`}
className={cn(
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('contact', contact.public_key) && 'bg-accent border-l-primary',
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
)}
onClick={() =>
handleSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
})
}
>
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={24}
contactType={contact.type}
/>
<span className="name flex-1 truncate">
{getContactDisplayName(contact.name, contact.public_key)}
</span>
{unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{formatUnreadCount(unreadCount)}
</span>
)}
</div>
);
}
})}
</>
)}
{/* Channels */}
{filteredChannels.length > 0 && (
{nonFavoriteChannels.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
@@ -309,7 +440,7 @@ export function Sidebar({
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
</div>
{filteredChannels.map((channel) => {
{nonFavoriteChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key);
return (
@@ -349,11 +480,11 @@ export function Sidebar({
)}
{/* Contacts */}
{filteredContacts.length > 0 && (
{nonFavoriteContacts.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
{filteredChannels.length === 0 && (
{nonFavoriteChannels.length === 0 && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
@@ -363,7 +494,7 @@ export function Sidebar({
</button>
)}
</div>
{filteredContacts.map((contact) => {
{nonFavoriteContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key);
return (
@@ -410,11 +541,13 @@ export function Sidebar({
)}
{/* Empty state */}
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}
{nonFavoriteContacts.length === 0 &&
nonFavoriteChannels.length === 0 &&
favoriteItems.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}
</div>
</div>
);
+82
View File
@@ -0,0 +1,82 @@
/**
* localStorage utilities for managing favorite conversations.
*
* Favorites are stored client-side and displayed in a dedicated section
* above channels in the sidebar, always sorted by most recent message.
*/
const FAVORITES_KEY = 'remoteterm-favorites';
export interface Favorite {
type: 'channel' | 'contact';
id: string; // channel key or contact public key
}
/**
* Load favorites from localStorage
*/
export function loadFavorites(): Favorite[] {
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Save favorites to localStorage
*/
function saveFavorites(favorites: Favorite[]): void {
try {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
} catch {
// localStorage might be full or disabled
}
}
/**
* Add a conversation to favorites
*/
export function addFavorite(type: 'channel' | 'contact', id: string): Favorite[] {
const favorites = loadFavorites();
// Check if already favorited
if (favorites.some((f) => f.type === type && f.id === id)) {
return favorites;
}
const updated = [...favorites, { type, id }];
saveFavorites(updated);
return updated;
}
/**
* Remove a conversation from favorites
*/
export function removeFavorite(type: 'channel' | 'contact', id: string): Favorite[] {
const favorites = loadFavorites();
const updated = favorites.filter((f) => !(f.type === type && f.id === id));
saveFavorites(updated);
return updated;
}
/**
* Check if a conversation is favorited
*/
export function isFavorite(
favorites: Favorite[],
type: 'channel' | 'contact',
id: string
): boolean {
return favorites.some((f) => f.type === type && f.id === id);
}
/**
* Toggle a conversation's favorite status
*/
export function toggleFavorite(type: 'channel' | 'contact', id: string): Favorite[] {
const favorites = loadFavorites();
if (favorites.some((f) => f.type === type && f.id === id)) {
return removeFavorite(type, id);
}
return addFavorite(type, id);
}