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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+3 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CG0iOYhX.js"></script> <script type="module" crossorigin src="/assets/index-pfr560TU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZ67iE5i.css"> <link rel="stylesheet" crossorigin href="/assets/index-C5j7uJOC.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6 -9
View File
@@ -1,7 +1,6 @@
{ {
"name": "remoteterm-meshcore", "name": "Remote Terminal for MeshCore",
"short_name": "remoteterm-meshcore", "short_name": "RemoteTerm",
"description": "Web interface for serial MeshCore radios",
"icons": [ "icons": [
{ {
"src": "/web-app-manifest-192x192.png", "src": "/web-app-manifest-192x192.png",
@@ -16,9 +15,7 @@
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"theme_color": "#0a0a0a", "theme_color": "#ffffff",
"background_color": "#0a0a0a", "background_color": "#ffffff",
"display": "standalone", "display": "standalone"
"start_url": "/", }
"scope": "/"
}
+65 -21
View File
@@ -23,6 +23,7 @@ import { getStateKey } from './utils/conversationState';
import { formatTime } from './utils/messageParser'; import { formatTime } from './utils/messageParser';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey'; import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash } from './utils/urlHash'; import { parseHashConversation, updateUrlHash } from './utils/urlHash';
import { loadFavorites, toggleFavorite, isFavorite, type Favorite } from './utils/favorites';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { import type {
AppSettings, AppSettings,
@@ -59,6 +60,7 @@ export function App() {
const [undecryptedCount, setUndecryptedCount] = useState(0); const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false); const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false);
const [favorites, setFavorites] = useState<Favorite[]>(loadFavorites);
// Track previous health status to detect changes // Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null); const prevHealthRef = useRef<HealthStatus | null>(null);
@@ -415,6 +417,11 @@ export function App() {
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
// Toggle favorite status for a conversation
const handleToggleFavorite = useCallback((type: 'channel' | 'contact', id: string) => {
setFavorites(toggleFavorite(type, id));
}, []);
// Delete channel handler // Delete channel handler
const handleDeleteChannel = useCallback(async (key: string) => { const handleDeleteChannel = useCallback(async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return; if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -548,6 +555,7 @@ export function App() {
crackerRunning={crackerRunning} crackerRunning={crackerRunning}
onToggleCracker={() => setShowCracker((prev) => !prev)} onToggleCracker={() => setShowCracker((prev) => !prev)}
onMarkAllRead={markAllRead} onMarkAllRead={markAllRead}
favorites={favorites}
/> />
); );
@@ -580,7 +588,7 @@ export function App() {
{activeConversation ? ( {activeConversation ? (
activeConversation.type === 'map' ? ( 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 Node Map
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
@@ -589,7 +597,7 @@ export function App() {
</> </>
) : activeConversation.type === 'raw' ? ( ) : 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 Raw Packet Feed
</div> </div>
<div className="flex-1 overflow-hidden"> <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"> <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-col sm:flex-row sm:items-center sm:gap-2 min-w-0 flex-1"> <span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="truncate"> <span className="flex-shrink-0">
{activeConversation.type === 'channel' && {activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#') !activeConversation.name.startsWith('#')
? '#' ? '#'
: ''} : ''}
{activeConversation.name} {activeConversation.name}
</span> </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.id}
{activeConversation.type === 'contact' && {activeConversation.type === 'contact' &&
(() => { (() => {
@@ -634,22 +642,58 @@ export function App() {
})()} })()}
</span> </span>
</span> </span>
{!( <div className="flex items-center gap-1 flex-shrink-0">
activeConversation.type === 'channel' && activeConversation.name === 'Public' {/* Favorite button */}
) && ( {(activeConversation.type === 'channel' ||
<button activeConversation.type === 'contact') && (
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" <button
onClick={() => { className="p-1.5 rounded hover:bg-accent text-xl leading-none"
if (activeConversation.type === 'channel') { onClick={() =>
handleDeleteChannel(activeConversation.id); handleToggleFavorite(
} else { activeConversation.type as 'channel' | 'contact',
handleDeleteContact(activeConversation.id); activeConversation.id
)
} }
}} title={
> isFavorite(
Delete favorites,
</button> 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> </div>
<MessageList <MessageList
messages={messages} messages={messages}
+143 -10
View File
@@ -4,6 +4,7 @@ import { getStateKey, type ConversationTimes } from '../utils/conversationState'
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey'; import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar'; import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { isFavorite, type Favorite } from '../utils/favorites';
import { UNREAD_FETCH_LIMIT } from '../api'; import { UNREAD_FETCH_LIMIT } from '../api';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -25,6 +26,7 @@ interface SidebarProps {
crackerRunning: boolean; crackerRunning: boolean;
onToggleCracker: () => void; onToggleCracker: () => void;
onMarkAllRead: () => void; onMarkAllRead: () => void;
favorites: Favorite[];
} }
/** Format unread count, showing "X+" if at the fetch limit (indicating there may be more) */ /** Format unread count, showing "X+" if at the fetch limit (indicating there may be more) */
@@ -64,6 +66,7 @@ export function Sidebar({
crackerRunning, crackerRunning,
onToggleCracker, onToggleCracker,
onMarkAllRead, onMarkAllRead,
favorites,
}: SidebarProps) { }: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder); const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -183,6 +186,43 @@ export function Sidebar({
) )
: sortedContacts; : 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 ( return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"> <div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
{/* Header */} {/* Header */}
@@ -296,8 +336,99 @@ export function Sidebar({
</div> </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 */} {/* Channels */}
{filteredChannels.length > 0 && ( {nonFavoriteChannels.length > 0 && (
<> <>
<div className="flex justify-between items-center px-3 py-2 pt-3"> <div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Channels</span> <span className="text-[11px] uppercase text-muted-foreground">Channels</span>
@@ -309,7 +440,7 @@ export function Sidebar({
{sortOrder === 'alpha' ? 'A-Z' : '⏱'} {sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button> </button>
</div> </div>
{filteredChannels.map((channel) => { {nonFavoriteChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key); const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key); const isMention = hasMention('channel', channel.key);
return ( return (
@@ -349,11 +480,11 @@ export function Sidebar({
)} )}
{/* Contacts */} {/* Contacts */}
{filteredContacts.length > 0 && ( {nonFavoriteContacts.length > 0 && (
<> <>
<div className="flex justify-between items-center px-3 py-2 pt-3"> <div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span> <span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
{filteredChannels.length === 0 && ( {nonFavoriteChannels.length === 0 && (
<button <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" 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} onClick={handleSortToggle}
@@ -363,7 +494,7 @@ export function Sidebar({
</button> </button>
)} )}
</div> </div>
{filteredContacts.map((contact) => { {nonFavoriteContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key); const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key); const isMention = hasMention('contact', contact.public_key);
return ( return (
@@ -410,11 +541,13 @@ export function Sidebar({
)} )}
{/* Empty state */} {/* Empty state */}
{filteredContacts.length === 0 && filteredChannels.length === 0 && ( {nonFavoriteContacts.length === 0 &&
<div className="p-5 text-center text-muted-foreground"> nonFavoriteChannels.length === 0 &&
{query ? 'No matches found' : 'No conversations yet'} favoriteItems.length === 0 && (
</div> <div className="p-5 text-center text-muted-foreground">
)} {query ? 'No matches found' : 'No conversations yet'}
</div>
)}
</div> </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);
}