Move to server side preference and read indicator management

This commit is contained in:
Jack Kingsman
2026-01-18 23:44:56 -08:00
parent 43b7e94b0a
commit 9c071dbc53
24 changed files with 1339 additions and 703 deletions
+105 -6
View File
@@ -18,12 +18,22 @@ import { MapView } from './components/MapView';
import { CrackerPanel } from './components/CrackerPanel';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
import {
getStateKey,
initLastMessageTimes,
loadLocalStorageLastMessageTimes,
loadLocalStorageSortOrder,
clearLocalStorageConversationState,
} from './utils/conversationState';
import { formatTime } from './utils/messageParser';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash, getMapFocusHash } from './utils/urlHash';
import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils';
import { loadFavorites, toggleFavorite, isFavorite, type Favorite } from './utils/favorites';
import {
isFavorite,
loadLocalStorageFavorites,
clearLocalStorageFavorites,
} from './utils/favorites';
import { cn } from '@/lib/utils';
import type {
AppSettings,
@@ -31,6 +41,7 @@ import type {
Contact,
Channel,
Conversation,
Favorite,
HealthStatus,
Message,
MessagePath,
@@ -60,7 +71,9 @@ export function App() {
const [undecryptedCount, setUndecryptedCount] = useState(0);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [favorites, setFavorites] = useState<Favorite[]>(loadFavorites);
// Favorites are now stored server-side in appSettings
const favorites: Favorite[] = appSettings?.favorites ?? [];
// Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null);
@@ -251,6 +264,8 @@ export function App() {
try {
const data = await api.getSettings();
setAppSettings(data);
// Initialize in-memory cache with server data
initLastMessageTimes(data.last_message_times ?? {});
} catch (err) {
console.error('Failed to fetch app settings:', err);
}
@@ -273,6 +288,72 @@ export function App() {
fetchUndecryptedCount();
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
// One-time migration of localStorage preferences to server
const hasMigratedRef = useRef(false);
useEffect(() => {
// Only run once we have appSettings loaded
if (!appSettings || hasMigratedRef.current) return;
// Skip if already migrated on server
if (appSettings.preferences_migrated) {
// Just clear any leftover localStorage
clearLocalStorageFavorites();
clearLocalStorageConversationState();
hasMigratedRef.current = true;
return;
}
// Check if we have any localStorage data to migrate
const localFavorites = loadLocalStorageFavorites();
const localSortOrder = loadLocalStorageSortOrder();
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
const hasLocalData =
localFavorites.length > 0 ||
localSortOrder !== 'recent' ||
Object.keys(localLastMessageTimes).length > 0;
if (!hasLocalData) {
// No local data to migrate, just mark as done
hasMigratedRef.current = true;
return;
}
// Mark as migrating immediately to prevent duplicate calls
hasMigratedRef.current = true;
// Migrate localStorage to server
const migratePreferences = async () => {
try {
const result = await api.migratePreferences({
favorites: localFavorites,
sort_order: localSortOrder,
last_message_times: localLastMessageTimes,
});
if (result.migrated) {
toast.success('Preferences migrated', {
description: `Migrated ${localFavorites.length} favorites to server`,
});
}
// Update local state with migrated settings
setAppSettings(result.settings);
// Reinitialize cache with migrated data
initLastMessageTimes(result.settings.last_message_times ?? {});
// Clear localStorage after successful migration
clearLocalStorageFavorites();
clearLocalStorageConversationState();
} catch (err) {
console.error('Failed to migrate preferences:', err);
// Don't block the app on migration failure
}
};
migratePreferences();
}, [appSettings]);
// Resolve URL hash to a conversation
const resolveHashToConversation = useCallback((): Conversation | null => {
const hashConv = parseHashConversation();
@@ -432,9 +513,15 @@ export function App() {
setSidebarOpen(false);
}, []);
// Toggle favorite status for a conversation
const handleToggleFavorite = useCallback((type: 'channel' | 'contact', id: string) => {
setFavorites(toggleFavorite(type, id));
// Toggle favorite status for a conversation (via API)
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
toast.error('Failed to update favorite');
}
}, []);
// Delete channel handler
@@ -535,6 +622,16 @@ export function App() {
[fetchUndecryptedCount]
);
// Handle sort order change via API
const handleSortOrderChange = useCallback(async (order: 'recent' | 'alpha') => {
try {
const updatedSettings = await api.updateSettings({ sidebar_sort_order: order });
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to update sort order:', err);
}
}, []);
// Sidebar content (shared between desktop and mobile)
const sidebarContent = (
<Sidebar
@@ -554,6 +651,8 @@ export function App() {
onToggleCracker={() => setShowCracker((prev) => !prev)}
onMarkAllRead={markAllRead}
favorites={favorites}
sortOrder={appSettings?.sidebar_sort_order ?? 'recent'}
onSortOrderChange={handleSortOrderChange}
/>
);
+34
View File
@@ -4,9 +4,12 @@ import type {
Channel,
CommandResponse,
Contact,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
@@ -197,4 +200,35 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(settings),
}),
// Favorites
addFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
removeFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites', {
method: 'DELETE',
body: JSON.stringify({ type, id }),
}),
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
// Last message time tracking
updateLastMessageTime: (stateKey: string, timestamp: number) =>
fetchJson<{ status: string }>('/settings/last-message-time', {
method: 'POST',
body: JSON.stringify({ state_key: stateKey, timestamp }),
}),
// Preferences migration (one-time, from localStorage to database)
migratePreferences: (request: MigratePreferencesRequest) =>
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
method: 'POST',
body: JSON.stringify(request),
}),
};
+40
View File
@@ -96,6 +96,7 @@ export function SettingsModal({
// Database maintenance state
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
useEffect(() => {
if (config) {
@@ -113,6 +114,7 @@ export function SettingsModal({
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
}
}, [appSettings]);
@@ -314,6 +316,19 @@ export function SettingsModal({
}
};
const handleToggleAutoDecrypt = async () => {
const newValue = !autoDecryptOnAdvert;
setAutoDecryptOnAdvert(newValue); // Optimistic update
try {
await onSaveAppSettings({ auto_decrypt_dm_on_advert: newValue });
} catch (err) {
console.error('Failed to save auto-decrypt setting:', err);
setAutoDecryptOnAdvert(!newValue); // Revert on error
toast.error('Failed to save setting');
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
@@ -638,6 +653,31 @@ export function SettingsModal({
</Button>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<div
className="flex items-center gap-3 cursor-pointer"
onClick={handleToggleAutoDecrypt}
>
<input
type="checkbox"
checked={autoDecryptOnAdvert}
readOnly
className="w-4 h-4 rounded border-input accent-primary pointer-events-none"
/>
<span className="text-sm">
Auto-decrypt historical DMs when new contact advertises
</span>
</div>
<p className="text-xs text-muted-foreground">
When enabled, the server will automatically try to decrypt stored DM packets when
a new contact sends an advertisement. This may cause brief delays on large packet
backlogs.
</p>
</div>
</TabsContent>
{/* Advertise Tab */}
+10 -24
View File
@@ -1,10 +1,10 @@
import { useState } from 'react';
import type { Contact, Channel, Conversation } from '../types';
import type { Contact, Channel, Conversation, Favorite } from '../types';
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 { isFavorite } from '../utils/favorites';
import { UNREAD_FETCH_LIMIT } from '../api';
import { Input } from './ui/input';
import { Button } from './ui/button';
@@ -27,6 +27,10 @@ interface SidebarProps {
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
/** Sort order from server settings */
sortOrder?: SortOrder;
/** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void;
}
/** Format unread count, showing "X+" if at the fetch limit (indicating there may be more) */
@@ -34,25 +38,6 @@ function formatUnreadCount(count: number): string {
return count >= UNREAD_FETCH_LIMIT ? `${count}+` : `${count}`;
}
// Load sort preference from localStorage (default to 'recent')
function loadSortOrder(): SortOrder {
try {
const stored = localStorage.getItem('remoteterm-sortOrder');
return stored === 'alpha' ? 'alpha' : 'recent';
} catch {
return 'recent';
}
}
// Save sort preference to localStorage
function saveSortOrder(order: SortOrder): void {
try {
localStorage.setItem('remoteterm-sortOrder', order);
} catch {
// localStorage might be full or disabled
}
}
export function Sidebar({
contacts,
channels,
@@ -67,14 +52,15 @@ export function Sidebar({
onToggleCracker,
onMarkAllRead,
favorites,
sortOrder: sortOrderProp = 'recent',
onSortOrderChange,
}: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState('');
const handleSortToggle = () => {
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
setSortOrder(newOrder);
saveSortOrder(newOrder);
onSortOrderChange?.(newOrder);
};
const handleSelectConversation = (conversation: Conversation) => {
+23
View File
@@ -124,12 +124,35 @@ export interface RawPacket {
} | null;
}
export interface Favorite {
type: 'channel' | 'contact';
id: string; // channel key or contact public key
}
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
last_message_times: Record<string, number>;
preferences_migrated: boolean;
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
}
export interface MigratePreferencesRequest {
favorites: Favorite[];
sort_order: string;
last_message_times: Record<string, number>;
}
export interface MigratePreferencesResponse {
migrated: boolean;
settings: AppSettings;
}
/** Contact type constants */
+62 -28
View File
@@ -1,8 +1,9 @@
/**
* localStorage utilities for tracking conversation message times.
* Conversation state utilities.
*
* Stores when each conversation last received a message, used for
* sorting conversations by recency in the sidebar.
* Last message times are tracked in-memory and persisted server-side.
* This file provides helper functions for generating state keys
* and managing conversation times.
*
* Read state (last_read_at) is tracked server-side for consistency
* across devices - see useUnreadCounts hook.
@@ -11,45 +12,42 @@
import { getPubkeyPrefix } from './pubkey';
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
export type ConversationTimes = Record<string, number>;
export type SortOrder = 'recent' | 'alpha';
function loadTimes(key: string): ConversationTimes {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveTimes(key: string, times: ConversationTimes): void {
try {
localStorage.setItem(key, JSON.stringify(times));
} catch {
// localStorage might be full or disabled
}
// In-memory cache of last message times (loaded from server on init)
let lastMessageTimesCache: ConversationTimes = {};
/**
* Initialize the last message times cache from server data
*/
export function initLastMessageTimes(times: ConversationTimes): void {
lastMessageTimesCache = { ...times };
}
/**
* Get all last message times from the cache
*/
export function getLastMessageTimes(): ConversationTimes {
return loadTimes(LAST_MESSAGE_KEY);
return { ...lastMessageTimesCache };
}
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_MESSAGE_KEY);
// Only update if this is a newer message
if (!times[stateKey] || timestamp > times[stateKey]) {
times[stateKey] = timestamp;
saveTimes(LAST_MESSAGE_KEY, times);
}
return times;
/**
* Update a single message time in the cache and return the updated cache.
* Note: This does NOT persist to server - caller should sync if needed.
*/
export function setLastMessageTime(key: string, timestamp: number): ConversationTimes {
lastMessageTimesCache[key] = timestamp;
return { ...lastMessageTimesCache };
}
/**
* Generate a state tracking key for message times.
*
* This is NOT the same as Message.conversation_key (the database field).
* This creates prefixed keys for localStorage/state tracking:
* This creates prefixed keys for state tracking:
* - Channels: "channel-{channelKey}"
* - Contacts: "contact-{12-char-pubkey-prefix}"
*
@@ -63,3 +61,39 @@ export function getStateKey(type: 'channel' | 'contact', id: string): string {
// For contacts, use 12-char prefix for consistent matching
return `contact-${getPubkeyPrefix(id)}`;
}
/**
* Load last message times from localStorage (for migration only)
*/
export function loadLocalStorageLastMessageTimes(): ConversationTimes {
try {
const stored = localStorage.getItem(LAST_MESSAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
/**
* Load sort order from localStorage (for migration only)
*/
export function loadLocalStorageSortOrder(): SortOrder {
try {
const stored = localStorage.getItem(SORT_ORDER_KEY);
return stored === 'alpha' ? 'alpha' : 'recent';
} catch {
return 'recent';
}
}
/**
* Clear conversation state from localStorage (after migration)
*/
export function clearLocalStorageConversationState(): void {
try {
localStorage.removeItem(LAST_MESSAGE_KEY);
localStorage.removeItem(SORT_ORDER_KEY);
} catch {
// localStorage might be disabled
}
}
+32 -57
View File
@@ -1,21 +1,39 @@
/**
* localStorage utilities for managing favorite conversations.
* Favorites utilities.
*
* Favorites are stored client-side and displayed in a dedicated section
* above channels in the sidebar, always sorted by most recent message.
* Favorites are now stored server-side in the database.
* This file provides helper functions for checking favorites
* and loading legacy localStorage data for migration.
*/
import type { Favorite } from '../types';
import { pubkeysMatch } from './pubkey';
const FAVORITES_KEY = 'remoteterm-favorites';
export interface Favorite {
type: 'channel' | 'contact';
id: string; // channel key or contact public key
/**
* Check if a conversation is favorited (from provided favorites array)
*
* For contacts, uses prefix matching to handle full pubkeys vs 12-char prefixes.
*/
export function isFavorite(
favorites: Favorite[],
type: 'channel' | 'contact',
id: string
): boolean {
return favorites.some((f) => {
if (f.type !== type) return false;
// For contacts, use prefix matching (handles full keys vs prefixes)
if (type === 'contact') return pubkeysMatch(f.id, id);
// For channels, exact match
return f.id === id;
});
}
/**
* Load favorites from localStorage
* Load favorites from localStorage (for migration only)
*/
export function loadFavorites(): Favorite[] {
export function loadLocalStorageFavorites(): Favorite[] {
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
@@ -25,58 +43,15 @@ export function loadFavorites(): Favorite[] {
}
/**
* Save favorites to localStorage
* Clear favorites from localStorage (after migration)
*/
function saveFavorites(favorites: Favorite[]): void {
export function clearLocalStorageFavorites(): void {
try {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
localStorage.removeItem(FAVORITES_KEY);
} catch {
// localStorage might be full or disabled
// localStorage might be 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);
}
// Re-export the Favorite type for convenience
export type { Favorite };