Add favorites
BIN
frontend/dist/apple-touch-icon.png
vendored
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
541
frontend/dist/assets/index-CG0iOYhX.js
vendored
1
frontend/dist/assets/index-CG0iOYhX.js.map
vendored
541
frontend/dist/assets/index-pfr560TU.js
vendored
Normal file
1
frontend/dist/assets/index-pfr560TU.js.map
vendored
Normal file
BIN
frontend/dist/favicon-96x96.png
vendored
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/dist/favicon.ico
vendored
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
4
frontend/dist/favicon.svg
vendored
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
4
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
15
frontend/dist/site.webmanifest
vendored
@@ -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"
|
||||
}
|
||||
@@ -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">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</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"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
|
||||
@@ -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
frontend/src/utils/favorites.ts
Normal 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);
|
||||
}
|
||||