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

541
frontend/dist/assets/index-pfr560TU.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CG0iOYhX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZ67iE5i.css">
<script type="module" crossorigin src="/assets/index-pfr560TU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5j7uJOC.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,7 +1,6 @@
{
"name": "remoteterm-meshcore",
"short_name": "remoteterm-meshcore",
"description": "Web interface for serial MeshCore radios",
"name": "Remote Terminal for MeshCore",
"short_name": "RemoteTerm",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
@@ -16,9 +15,7 @@
"purpose": "maskable"
}
],
"theme_color": "#0a0a0a",
"background_color": "#0a0a0a",
"display": "standalone",
"start_url": "/",
"scope": "/"
}
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

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}

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>
);

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);
}