diff --git a/frontend/package.json b/frontend/package.json index 6aa0c79..602a64f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", + "framer-motion": "^12.34.0", "leaflet": "^1.9.4", "lucide-react": "^0.562.0", "meshcore-hashtag-cracker": "^1.7.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc000c9..a1e9b7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,6 +47,7 @@ import { loadLocalStorageFavorites, clearLocalStorageFavorites, } from './utils/favorites'; +import { Star, Trash2, Route, Hash } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { AppSettings, @@ -878,27 +879,32 @@ export function App() { ); const settingsSidebarContent = ( -
-
-

Settings

+
+
+

+ Settings +

-
+
{SETTINGS_SECTION_ORDER.map((section) => ( )} - {/* Favorite button */} {(activeConversation.type === 'channel' || activeConversation.type === 'contact') && ( )} - {/* Delete button */} {!( activeConversation.type === 'channel' && activeConversation.name === 'Public' ) && ( )}
@@ -1161,17 +1168,22 @@ export function App() { ) ) : ( -
- Select a conversation or start a new one +
+
+
πŸ“‘
+

+ Select a conversation or start a new one +

+
)}
{showSettings && (
-
- Radio & Settings - +
+ Radio & Settings + {SETTINGS_SECTION_LABELS[settingsSection]}
@@ -1199,10 +1211,10 @@ export function App() {
- {/* Global Cracker Panel - always rendered to maintain state */} + {/* Global Cracker Panel */}
diff --git a/frontend/src/components/ContactAvatar.tsx b/frontend/src/components/ContactAvatar.tsx index ee34264..0e80fd3 100644 --- a/frontend/src/components/ContactAvatar.tsx +++ b/frontend/src/components/ContactAvatar.tsx @@ -12,13 +12,13 @@ export function ContactAvatar({ name, publicKey, size = 28, contactType }: Conta return (
{avatar.text} diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index af46800..19479bc 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -8,23 +8,19 @@ import { type FormEvent, type KeyboardEvent, } from 'react'; -import { Input } from './ui/input'; -import { Button } from './ui/button'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Send, Lock } from 'lucide-react'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; // MeshCore message size limits (empirically determined from LoRa packet constraints) -// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth. -// Channels include "sender: " prefix in the encrypted payload. -// All limits are in bytes (UTF-8), not characters, since LoRa packets are byte-constrained. -const DM_HARD_LIMIT = 156; // Max bytes for direct delivery -const DM_WARNING_THRESHOLD = 140; // Conservative for multi-hop -const CHANNEL_HARD_LIMIT = 156; // Base byte limit before sender overhead -const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop -const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit +const DM_HARD_LIMIT = 156; +const DM_WARNING_THRESHOLD = 140; +const CHANNEL_HARD_LIMIT = 156; +const CHANNEL_WARNING_THRESHOLD = 120; +const CHANNEL_DANGER_BUFFER = 8; const textEncoder = new TextEncoder(); -/** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */ function byteLen(s: string): number { return textEncoder.encode(s).length; } @@ -33,11 +29,8 @@ interface MessageInputProps { onSend: (text: string) => Promise; disabled: boolean; placeholder?: string; - /** When true, input becomes password field for repeater telemetry */ isRepeaterMode?: boolean; - /** Conversation type for character limit calculation */ conversationType?: 'contact' | 'channel' | 'raw'; - /** Sender name (radio name) for channel message limit calculation */ senderName?: string; } @@ -58,21 +51,18 @@ export const MessageInput = forwardRef(fu useImperativeHandle(ref, () => ({ appendText: (appendedText: string) => { setText((prev) => prev + appendedText); - // Focus the input after appending inputRef.current?.focus(); }, })); - // Calculate character limits based on conversation type const limits = useMemo(() => { if (conversationType === 'contact') { return { warningAt: DM_WARNING_THRESHOLD, - dangerAt: DM_HARD_LIMIT, // Same as hard limit for DMs (no intermediate red zone) + dangerAt: DM_HARD_LIMIT, hardLimit: DM_HARD_LIMIT, }; } else if (conversationType === 'channel') { - // Channel hard limit = 156 bytes - senderName bytes - 2 (for ": " separator) const nameByteLen = senderName ? byteLen(senderName) : 10; const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2); return { @@ -81,13 +71,11 @@ export const MessageInput = forwardRef(fu hardLimit, }; } - return null; // Raw/other - no limits + return null; }, [conversationType, senderName]); - // UTF-8 byte length of the current text (LoRa packets are byte-constrained) const textByteLen = useMemo(() => byteLen(text), [text]); - // Determine current limit state const { limitState, warningMessage } = useMemo((): { limitState: LimitState; warningMessage: string | null; @@ -95,13 +83,13 @@ export const MessageInput = forwardRef(fu if (!limits) return { limitState: 'normal', warningMessage: null }; if (textByteLen >= limits.hardLimit) { - return { limitState: 'error', warningMessage: 'likely truncated by radio' }; + return { limitState: 'error', warningMessage: 'likely truncated' }; } if (textByteLen >= limits.dangerAt) { - return { limitState: 'danger', warningMessage: 'may impact multi-repeater hop delivery' }; + return { limitState: 'danger', warningMessage: 'multi-hop risk' }; } if (textByteLen >= limits.warningAt) { - return { limitState: 'warning', warningMessage: 'may impact multi-repeater hop delivery' }; + return { limitState: 'warning', warningMessage: 'multi-hop risk' }; } return { limitState: 'normal', warningMessage: null }; }, [textByteLen, limits]); @@ -113,7 +101,6 @@ export const MessageInput = forwardRef(fu e.preventDefault(); const trimmed = text.trim(); - // For repeater mode, empty password means guest login if (isRepeaterMode) { if (sending || disabled) return; setSending(true); @@ -129,7 +116,6 @@ export const MessageInput = forwardRef(fu } finally { setSending(false); } - // Refocus after React re-enables the input (now in CLI command mode) setTimeout(() => inputRef.current?.focus(), 0); } else { if (!trimmed || sending || disabled) return; @@ -146,7 +132,6 @@ export const MessageInput = forwardRef(fu } finally { setSending(false); } - // Refocus after React re-enables the input setTimeout(() => inputRef.current?.focus(), 0); } }, @@ -163,66 +148,111 @@ export const MessageInput = forwardRef(fu [handleSubmit] ); - // For repeater mode, always allow submit (empty = guest login) const canSubmit = isRepeaterMode ? true : text.trim().length > 0; - - // Show character counter for messages (not repeater mode or raw) - const showCharCounter = !isRepeaterMode && limits !== null; + const showCharCounter = !isRepeaterMode && limits !== null && textByteLen > 0; return ( -
-
- setText(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={ - placeholder || - (isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...') - } - disabled={disabled || sending} - className="flex-1 min-w-0" - /> - -
- {showCharCounter && ( -
- + +
+ {/* Input container with glow */} +
0 + ? 'bg-secondary/40 border-primary/25 shadow-glow-amber-sm' + : 'bg-secondary/30 border-border/50 focus-within:border-primary/30 focus-within:shadow-glow-amber-sm' )} > - {textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`} - - {warningMessage && ( - - β€” {warningMessage} - - )} + {isRepeaterMode && ( + + )} + setText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + placeholder || + (isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...') + } + disabled={disabled || sending} + className={cn( + 'w-full h-10 bg-transparent rounded-xl text-sm placeholder:text-muted-foreground/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40', + isRepeaterMode ? 'pl-9 pr-3' : 'px-4' + )} + /> +
+ + {/* Send button */} + + {sending ? ( +
+ ) : isRepeaterMode ? ( + <> + + {text.trim() ? 'Login' : 'Guest'} + + ) : ( + <> + + Send + + )} +
- )} - + + {/* Character counter */} + + {showCharCounter && ( + + + {textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`} + + {warningMessage && ( + + {warningMessage} + + )} + + )} + + +
); }); diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 5a54a2c..0c0d387 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -7,6 +7,8 @@ import { useState, type ReactNode, } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; import type { Contact, Message, MessagePath, RadioConfig } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types'; import { formatTime, parseSenderFromText } from '../utils/messageParser'; @@ -49,7 +51,7 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] { href={match[0]} target="_blank" rel="noopener noreferrer" - className="text-primary underline hover:text-primary/80" + className="text-accent underline decoration-accent/40 hover:decoration-accent hover:text-accent/80 transition-colors" > {match[0]} @@ -73,7 +75,6 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { let keyIndex = 0; while ((match = mentionPattern.exec(text)) !== null) { - // Add text before the match (with linkification) if (match.index > lastIndex) { parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`)); } @@ -85,18 +86,19 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { - @[{mentionedName}] + @{mentionedName} ); lastIndex = match.index + match[0].length; } - // Add remaining text after last match (with linkification) if (lastIndex < text.length) { parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`)); } @@ -104,7 +106,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode { return parts.length > 0 ? parts : text; } -// Clickable hop count badge that opens the path modal +// Clickable hop count badge interface HopCountBadgeProps { paths: MessagePath[]; onClick: () => void; @@ -113,16 +115,16 @@ interface HopCountBadgeProps { function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) { const hopInfo = formatHopCounts(paths); - const label = `(${hopInfo.display})`; - - const className = - variant === 'header' - ? 'font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline' - : 'text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline'; + const label = hopInfo.display; return ( { e.stopPropagation(); onClick(); @@ -154,45 +156,35 @@ export function MessageList({ senderInfo: SenderInfo; } | null>(null); - // Capture scroll state in the scroll handler BEFORE any state updates const scrollStateRef = useRef({ scrollTop: 0, scrollHeight: 0, clientHeight: 0, wasNearTop: false, - wasNearBottom: true, // Default to true so initial messages scroll to bottom + wasNearBottom: true, }); - // Track conversation key to detect when entire message set changes const prevConvKeyRef = useRef(null); - // Handle scroll position AFTER render useLayoutEffect(() => { if (!listRef.current) return; const list = listRef.current; const messagesAdded = messages.length - prevMessagesLengthRef.current; - // Detect if messages are from a different conversation (handles the case where - // the key prop remount consumes isInitialLoadRef on stale data from the previous - // conversation before the cache restore effect sets the correct messages) const convKey = messages.length > 0 ? messages[0].conversation_key : null; const conversationChanged = convKey !== null && convKey !== prevConvKeyRef.current; if (convKey !== null) prevConvKeyRef.current = convKey; if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) { - // Initial load or conversation switch - scroll to bottom list.scrollTop = list.scrollHeight; isInitialLoadRef.current = false; } else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) { - // Messages were added - use scroll state captured before the update const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight; if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) { - // User was near top (loading older) - preserve position by adding the height diff list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff; } else if (scrollStateRef.current.wasNearBottom) { - // User was near bottom - scroll to bottom for new messages (including sent) list.scrollTop = list.scrollHeight; } } @@ -200,7 +192,6 @@ export function MessageList({ prevMessagesLengthRef.current = messages.length; }, [messages]); - // Reset initial load flag when conversation changes (messages becomes empty then filled) useEffect(() => { if (messages.length === 0) { isInitialLoadRef.current = true; @@ -216,14 +207,12 @@ export function MessageList({ } }, [messages.length]); - // Handle scroll - capture state and detect when user is near top/bottom const handleScroll = useCallback(() => { if (!listRef.current) return; const { scrollTop, scrollHeight, clientHeight } = listRef.current; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - // Always capture current scroll state (needed for scroll preservation) scrollStateRef.current = { scrollTop, scrollHeight, @@ -232,44 +221,35 @@ export function MessageList({ wasNearBottom: distanceFromBottom < 100, }; - // Show scroll-to-bottom button when not near the bottom (more than 100px away) setShowScrollToBottom(distanceFromBottom > 100); if (!onLoadOlder || loadingOlder || !hasOlderMessages) return; - // Trigger load when within 100px of top if (scrollTop < 100) { onLoadOlder(); } }, [onLoadOlder, loadingOlder, hasOlderMessages]); - // Scroll to bottom handler const scrollToBottom = useCallback(() => { if (listRef.current) { listRef.current.scrollTop = listRef.current.scrollHeight; } }, []); - // Sort messages by received_at ascending (oldest first) - // Note: Deduplication is handled by useConversationMessages.addMessageIfNew() - // and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp) const sortedMessages = useMemo( () => [...messages].sort((a, b) => a.received_at - b.received_at), [messages] ); - // Look up contact by public key const getContact = (conversationKey: string | null): Contact | null => { if (!conversationKey) return null; return contacts.find((c) => c.public_key === conversationKey) || null; }; - // Look up contact by name (for channel messages where we parse sender from text) const getContactByName = (name: string): Contact | null => { return contacts.find((c) => c.name === name) || null; }; - // Build sender info for path modal const getSenderInfo = ( msg: Message, contact: Contact | null, @@ -283,7 +263,6 @@ export function MessageList({ lon: contact.lon, }; } - // For channel messages, try to find contact by parsed sender name if (parsedSender) { const senderContact = getContactByName(parsedSender); if (senderContact) { @@ -295,7 +274,6 @@ export function MessageList({ }; } } - // Fallback: unknown sender return { name: parsedSender || 'Unknown', publicKeyOrPrefix: msg.conversation_key || '', @@ -306,21 +284,26 @@ export function MessageList({ if (loading) { return ( -
- Loading messages... +
+
+
+ Loading messages... +
); } if (messages.length === 0) { return ( -
- No messages yet +
+
+
πŸ’¬
+ No messages yet +
); } - // Helper to get a unique sender key for grouping messages const getSenderKey = (msg: Message, sender: string | null): string => { if (msg.outgoing) return '__outgoing__'; if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key; @@ -330,26 +313,24 @@ export function MessageList({ return (
{loadingOlder && ( -
- Loading older messages... +
+
)} {!loadingOlder && hasOlderMessages && ( -
+
Scroll up for older messages
)} {sortedMessages.map((msg, index) => { - // For DMs, look up contact; for channel messages, use parsed sender const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null; const isRepeater = contact?.type === CONTACT_TYPE_REPEATER; - // Skip sender parsing for repeater messages (CLI responses often have colons) const { sender, content } = isRepeater ? { sender: null, content: msg.text } : parseSenderFromText(msg.text); @@ -359,7 +340,6 @@ export function MessageList({ const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown'; - // Determine if we should show avatar (first message in a chunk from same sender) const currentSenderKey = getSenderKey(msg, sender); const prevMsg = sortedMessages[index - 1]; const prevSenderKey = prevMsg @@ -368,16 +348,13 @@ export function MessageList({ const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey; const isFirstMessage = index === 0; - // Get avatar info for incoming messages let avatarName: string | null = null; let avatarKey: string = ''; if (!msg.outgoing) { if (msg.type === 'PRIV' && msg.conversation_key) { - // DM: use conversation_key (sender's public key) avatarName = contact?.name || null; avatarKey = msg.conversation_key; } else if (sender) { - // Channel message: try to find contact by name, or use sender name as pseudo-key const senderContact = getContactByName(sender); avatarName = sender; avatarKey = senderContact?.public_key || `name:${sender}`; @@ -390,36 +367,40 @@ export function MessageList({ className={cn( 'flex items-start max-w-[85%]', msg.outgoing && 'flex-row-reverse self-end', - showAvatar && !isFirstMessage && 'mt-3' + showAvatar && !isFirstMessage && 'mt-4' )} > {!msg.outgoing && ( -
+
{showAvatar && avatarKey && ( - + )}
)}
{showAvatar && ( -
- {canClickSender ? ( - onSenderClick(displaySender)} - title={`Mention ${displaySender}`} - > - {displaySender} - - ) : ( - displaySender - )} - +
+ + {canClickSender ? ( + onSenderClick(displaySender)} + title={`Mention ${displaySender}`} + > + {displaySender} + + ) : ( + {displaySender} + )} + + {formatTime(msg.sender_timestamp || msg.received_at)} {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( @@ -436,7 +417,7 @@ export function MessageList({ )}
)} -
+
{content.split('\n').map((line, i, arr) => ( {renderTextWithMentions(line, radioName)} @@ -445,7 +426,7 @@ export function MessageList({ ))} {!showAvatar && ( <> - + {formatTime(msg.sender_timestamp || msg.received_at)} {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( @@ -466,7 +447,7 @@ export function MessageList({ (msg.acked > 0 ? ( msg.paths && msg.paths.length > 0 ? ( { e.stopPropagation(); setSelectedPath({ @@ -480,12 +461,22 @@ export function MessageList({ }); }} title="View echo paths" - >{` βœ“${msg.acked > 1 ? msg.acked : ''}`} + > + {` βœ“${msg.acked > 1 ? msg.acked : ''}`} + ) : ( - ` βœ“${msg.acked > 1 ? msg.acked : ''}` + + {` βœ“${msg.acked > 1 ? msg.acked : ''}`} + ) ) : ( - ? + + {' '} + ? + ))}
@@ -495,28 +486,20 @@ export function MessageList({
{/* Scroll to bottom button */} - {showScrollToBottom && ( - - )} + + + )} + {/* Path modal */} {selectedPath && ( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index da30a7e..98f17aa 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,17 @@ import { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Radio, + Map, + Sparkles, + KeyRound, + CheckCheck, + Search, + X, + Star, + Hash, + User, +} from 'lucide-react'; import { CONTACT_TYPE_REPEATER, type Contact, @@ -10,8 +23,6 @@ import { getStateKey, type ConversationTimes } from '../utils/conversationState' import { getContactDisplayName } from '../utils/pubkey'; 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 SortOrder = 'alpha' | 'recent'; @@ -24,16 +35,13 @@ interface SidebarProps { onNewMessage: () => void; lastMessageTimes: ConversationTimes; unreadCounts: Record; - /** Tracks which conversations have unread messages that mention the user */ mentions: Record; 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; } @@ -70,13 +78,11 @@ export function Sidebar({ const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', 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; @@ -87,7 +93,7 @@ export function Sidebar({ return lastMessageTimes[key] || 0; }; - // Deduplicate channels by name, keeping the first (lowest index) + // Deduplicate channels by name const uniqueChannels = channels.reduce((acc, channel) => { if (!acc.some((c) => c.name === channel.name)) { acc.push(channel); @@ -95,12 +101,10 @@ export function Sidebar({ return acc; }, []); - // Deduplicate contacts by public key, preferring ones with names - // Also filter out any contacts with empty public keys + // Deduplicate contacts by public key const uniqueContacts = 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 || ''); @@ -112,54 +116,40 @@ export function Sidebar({ return acc; }, []); - // Sort channels based on sort order, with Public always first + // Sort channels const sortedChannels = [...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 both have messages, sort by most recent first if (timeA && timeB) return timeB - timeA; - // Items with messages come before items without if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; - // Fall back to alpha for items without messages } return a.name.localeCompare(b.name); }); - // Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha) + // Sort contacts const sortedContacts = [...uniqueContacts].sort((a, b) => { const aIsRepeater = a.type === CONTACT_TYPE_REPEATER; const bIsRepeater = b.type === CONTACT_TYPE_REPEATER; - - // Repeaters always go to the bottom if (aIsRepeater && !bIsRepeater) return 1; if (!aIsRepeater && bIsRepeater) return -1; - - // Both repeaters: always sort alphabetically if (aIsRepeater && bIsRepeater) { return (a.name || a.public_key).localeCompare(b.name || b.public_key); } - - // Both non-repeaters: use selected sort order if (sortOrder === 'recent') { const timeA = getLastMessageTime('contact', a.public_key); const timeB = getLastMessageTime('contact', b.public_key); - // If both have messages, sort by most recent first if (timeA && timeB) return timeB - timeA; - // Items with messages come before items without if (timeA && !timeB) return -1; if (!timeA && timeB) return 1; - // Fall back to alpha for items without messages } return (a.name || a.public_key).localeCompare(b.name || b.public_key); }); - // Filter by search query + // Filter by search const query = searchQuery.toLowerCase().trim(); const filteredChannels = query ? sortedChannels.filter( @@ -172,7 +162,7 @@ export function Sidebar({ ) : sortedContacts; - // Separate favorites from regular items + // Separate favorites const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key)); const favoriteContacts = filteredContacts.filter((c) => isFavorite(favorites, 'contact', c.public_key) @@ -184,7 +174,6 @@ export function Sidebar({ (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[] = [ @@ -199,168 +188,216 @@ export function Sidebar({ 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); }); + // Unread badge component + const UnreadBadge = ({ count, isMentionBadge }: { count: number; isMentionBadge: boolean }) => ( + + {count} + + ); + + // Conversation item component + const ConversationItem = ({ + active, + unreadCount, + isMentionItem, + onClick, + icon, + children, + }: { + active: boolean; + unreadCount?: number; + isMentionItem?: boolean; + onClick: () => void; + icon?: React.ReactNode; + children: React.ReactNode; + }) => ( +
0 && !active && 'bg-secondary/30' + )} + onClick={onClick} + > + {icon} + 0 && 'text-foreground font-semibold' + )} + > + {children} + + {(unreadCount ?? 0) > 0 && ( + + )} +
+ ); + return ( -
+
{/* Header */} -
-

Conversations

- +
{/* Search */} -
- setSearchQuery(e.target.value)} - className="h-8 text-sm pr-8" - /> - {searchQuery && ( - - )} +
+
+ + setSearchQuery(e.target.value)} + className="w-full h-8 pl-8 pr-8 text-sm bg-secondary/40 border border-border/50 rounded-lg placeholder:text-muted-foreground/40 focus:outline-none focus:border-primary/30 focus:bg-secondary/60 transition-all" + /> + + {searchQuery && ( + setSearchQuery('')} + title="Clear search" + > + + + )} + +
- {/* List */} -
- {/* Raw Packet Feed */} - {!query && ( -
- handleSelectConversation({ - type: 'raw', - id: 'raw', - name: 'Raw Packet Feed', - }) - } - > - πŸ“‘ - Packet Feed -
- )} + {/* Quick nav - special views */} + {!query && ( +
+ {[ + { + type: 'raw' as const, + id: 'raw', + name: 'Raw Packet Feed', + icon: Radio, + title: 'Packet Feed', + }, + { type: 'map' as const, id: 'map', name: 'Node Map', icon: Map, title: 'Node Map' }, + { + type: 'visualizer' as const, + id: 'visualizer', + name: 'Mesh Visualizer', + icon: Sparkles, + title: 'Visualizer', + }, + ].map((view) => ( + + ))} +
+ )} - {/* Node Map */} - {!query && ( -
+
- )} - - {/* Mesh Visualizer */} - {!query && ( -
- handleSelectConversation({ - type: 'visualizer', - id: 'visualizer', - name: 'Mesh Visualizer', - }) - } - > - ✨ - Mesh Visualizer -
- )} - - {/* Cracker Toggle */} - {!query && ( -
- πŸ”“ - - {showCracker ? 'Hide' : 'Show'} Room Finder - - ({crackerRunning ? 'running' : 'stopped'}) - + + + Finder + {crackerRunning && ( + + )} -
- )} - - {/* Mark All Read */} - {!query && Object.keys(unreadCounts).length > 0 && ( -
- βœ“ - Mark all as read -
- )} + + {Object.keys(unreadCounts).length > 0 && ( + + )} +
+ )} + {/* Conversation list */} +
{/* Favorites */} {favoriteItems.length > 0 && ( <> -
- Favorites +
+ + + Favorites +
{favoriteItems.map((item) => { if (item.type === 'channel') { const channel = item.channel; - const unreadCount = getUnreadCount('channel', channel.key); - const isMention = hasMention('channel', channel.key); + const count = getUnreadCount('channel', channel.key); + const mention = hasMention('channel', channel.key); return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} + active={isActive('channel', channel.key)} + unreadCount={count} + isMentionItem={mention} onClick={() => handleSelectConversation({ type: 'channel', @@ -368,34 +405,21 @@ export function Sidebar({ name: channel.name, }) } + icon={} > - {channel.name} - {unreadCount > 0 && ( - - {unreadCount} - - )} -
+ {channel.name} + ); } else { const contact = item.contact; - const unreadCount = getUnreadCount('contact', contact.public_key); - const isMention = hasMention('contact', contact.public_key); + const count = getUnreadCount('contact', contact.public_key); + const mention = hasMention('contact', contact.public_key); return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} + active={isActive('contact', contact.public_key)} + unreadCount={count} + isMentionItem={mention} onClick={() => handleSelectConversation({ type: 'contact', @@ -403,29 +427,17 @@ export function Sidebar({ name: getContactDisplayName(contact.name, contact.public_key), }) } + icon={ + + } > - - - {getContactDisplayName(contact.name, contact.public_key)} - - {unreadCount > 0 && ( - - {unreadCount} - - )} -
+ {getContactDisplayName(contact.name, contact.public_key)} + ); } })} @@ -435,27 +447,30 @@ export function Sidebar({ {/* Channels */} {nonFavoriteChannels.length > 0 && ( <> -
- Channels +
+
+ + + Channels + +
{nonFavoriteChannels.map((channel) => { - const unreadCount = getUnreadCount('channel', channel.key); - const isMention = hasMention('channel', channel.key); + const count = getUnreadCount('channel', channel.key); + const mention = hasMention('channel', channel.key); return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} + active={isActive('channel', channel.key)} + unreadCount={count} + isMentionItem={mention} onClick={() => handleSelectConversation({ type: 'channel', @@ -463,21 +478,10 @@ export function Sidebar({ name: channel.name, }) } + icon={} > - {channel.name} - {unreadCount > 0 && ( - - {unreadCount} - - )} -
+ {channel.name} + ); })} @@ -486,29 +490,32 @@ export function Sidebar({ {/* Contacts */} {nonFavoriteContacts.length > 0 && ( <> -
- Contacts +
+
+ + + Contacts + +
{nonFavoriteChannels.length === 0 && ( )}
{nonFavoriteContacts.map((contact) => { - const unreadCount = getUnreadCount('contact', contact.public_key); - const isMention = hasMention('contact', contact.public_key); + const count = getUnreadCount('contact', contact.public_key); + const mention = hasMention('contact', contact.public_key); return ( -
0 && '[&_.name]:font-bold [&_.name]:text-foreground' - )} + active={isActive('contact', contact.public_key)} + unreadCount={count} + isMentionItem={mention} onClick={() => handleSelectConversation({ type: 'contact', @@ -516,29 +523,17 @@ export function Sidebar({ name: getContactDisplayName(contact.name, contact.public_key), }) } + icon={ + + } > - - - {getContactDisplayName(contact.name, contact.public_key)} - - {unreadCount > 0 && ( - - {unreadCount} - - )} -
+ {getContactDisplayName(contact.name, contact.public_key)} + ); })} @@ -548,7 +543,7 @@ export function Sidebar({ {nonFavoriteContacts.length === 0 && nonFavoriteChannels.length === 0 && favoriteItems.length === 0 && ( -
+
{query ? 'No matches found' : 'No conversations yet'}
)} diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 36f7774..281c125 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { Menu } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Menu, Radio, Settings, MessageSquare, Wifi, WifiOff } from 'lucide-react'; import type { HealthStatus, RadioConfig } from '../types'; import { api } from '../api'; import { toast } from './ui/sonner'; @@ -39,61 +40,111 @@ export function StatusBar({ }; return ( -
- {/* Mobile menu button - only visible on small screens */} +
+ {/* Subtle gradient overlay */} +
+ + {/* Mobile menu button */} {onMenuClick && ( )} -

RemoteTerm

- -
-
- - {connected ? 'Connected' : 'Disconnected'} - + {/* Logo / Title */} +
+
+ + {connected && ( + + )} +
+

RemoteTerm

+ {/* Connection status */} +
+ + {connected ? ( + + + + Connected + + + ) : ( + + + + Offline + + + )} + +
+ + {/* Radio info */} {config && ( -
- {config.name || 'Unnamed'} +
+ {config.name || 'Unnamed'} { navigator.clipboard.writeText(config.public_key); toast.success('Public key copied!'); }} title="Click to copy public key" > - {config.public_key.toLowerCase()} + {config.public_key.toLowerCase().slice(0, 16)}...
)} - {!connected && ( + {/* Action buttons */} +
+ {!connected && ( + + {reconnecting ? 'Reconnecting...' : 'Reconnect'} + + )} - )} - +
); } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index af90aca..b11c2a6 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -5,21 +5,25 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 active:scale-[0.97]', { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', + default: + 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-glow-amber-sm hover:shadow-glow-amber rounded-lg font-semibold', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-lg', + outline: + 'border border-border bg-transparent hover:bg-secondary hover:border-primary/30 rounded-lg', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg', + ghost: 'hover:bg-secondary hover:text-foreground rounded-lg', link: 'text-primary underline-offset-4 hover:underline', + glow: 'bg-gradient-to-r from-amber-500 to-amber-600 text-primary-foreground hover:from-amber-400 hover:to-amber-500 shadow-glow-amber hover:shadow-glow-amber rounded-xl font-semibold', }, size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', + default: 'h-10 px-5 py-2', + sm: 'h-8 rounded-lg px-3 text-xs', + lg: 'h-12 rounded-xl px-8 text-base', icon: 'h-10 w-10', }, }, diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 58332bc..c8be903 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< >( (({ className, ...props }, ref) => ( { toastOptions={{ classNames: { toast: - 'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + 'group toast group-[.toaster]:bg-card/95 group-[.toaster]:backdrop-blur-xl group-[.toaster]:text-foreground group-[.toaster]:border-border/50 group-[.toaster]:shadow-2xl group-[.toaster]:rounded-xl', description: 'group-[.toast]:text-muted-foreground', actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', - // Muted error style - dark red-tinted background with readable text error: - 'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]', + 'group-[.toaster]:bg-[#1a0e10]/95 group-[.toaster]:text-red-300 group-[.toaster]:border-red-500/20 [&_[data-description]]:text-red-400/70', + success: + 'group-[.toaster]:bg-[#0e1a12]/95 group-[.toaster]:text-emerald-300 group-[.toaster]:border-emerald-500/20 [&_[data-description]]:text-emerald-400/70', }, }} {...props} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 7315537..e3c5478 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -14,7 +14,7 @@ const TabsList = React.forwardRef<