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
+22
View File
@@ -68,6 +68,7 @@ All application state lives in `App.tsx` using React hooks. No external state li
```typescript
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
@@ -76,6 +77,17 @@ const [activeConversation, setActiveConversation] = useState<Conversation | null
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
```
### App Settings
App settings are stored server-side and include:
- `favorites` - List of favorited conversations (channels/contacts)
- `sidebar_sort_order` - 'recent' or 'alpha'
- `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact
- `last_message_times` - Map of conversation keys to last message timestamps
**Migration**: On first load, localStorage preferences are migrated to the server.
The `preferences_migrated` flag prevents duplicate migrations.
### State Flow
1. **WebSocket** pushes real-time updates (health, contacts, channels, messages)
@@ -222,8 +234,18 @@ interface Conversation {
name: string;
}
interface Favorite {
type: 'channel' | 'contact';
id: string; // Channel key or contact public key
}
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;
}
// Repeater telemetry types
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
File diff suppressed because one or more lines are too long
+2 -2
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-Cjj7DnBW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-fUaa04.css">
<script type="module" crossorigin src="/assets/index-BVIx9g0k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CnRBRJ10.css">
</head>
<body>
<div id="root"></div>
+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 };