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
+24 -3
View File
@@ -47,12 +47,18 @@ app/
All database operations go through repository classes in `repository.py`:
```python
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository
from app.repository import ContactRepository, ChannelRepository, MessageRepository, RawPacketRepository, AppSettingsRepository
# Examples
contact = await ContactRepository.get_by_key_prefix("abc123")
await MessageRepository.create(msg_type="PRIV", text="Hello", received_at=timestamp)
await RawPacketRepository.mark_decrypted(packet_id, message_id)
# App settings (single-row pattern)
settings = await AppSettingsRepository.get()
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
await AppSettingsRepository.add_favorite("contact", public_key)
await AppSettingsRepository.update_last_message_time("channel-KEY", timestamp)
```
### Radio Connection
@@ -206,6 +212,16 @@ raw_packets (
last_attempt INTEGER,
FOREIGN KEY (message_id) REFERENCES messages(id)
)
app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Single-row pattern
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]', -- JSON array of {type, id}
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent', -- 'recent' or 'alpha'
last_message_times TEXT DEFAULT '{}', -- JSON object of state_key -> timestamp
preferences_migrated INTEGER DEFAULT 0 -- One-time migration flag
)
```
## Database Migrations (`migrations.py`)
@@ -453,8 +469,13 @@ All endpoints are prefixed with `/api`.
- `POST /api/packets/decrypt/historical` - Try decrypting old packets with new key
### Settings
- `GET /api/settings` - Get app settings (max_radio_contacts)
- `PATCH /api/settings` - Update app settings
- `GET /api/settings` - Get all app settings
- `PATCH /api/settings` - Update settings (max_radio_contacts, auto_decrypt_dm_on_advert, sidebar_sort_order)
- `POST /api/settings/favorites` - Add a favorite
- `DELETE /api/settings/favorites` - Remove a favorite
- `POST /api/settings/favorites/toggle` - Toggle favorite status
- `POST /api/settings/last-message-time` - Update last message time for a conversation
- `POST /api/settings/migrate` - One-time migration from frontend localStorage
### WebSocket
- `WS /api/ws` - Real-time updates (health, contacts, channels, messages, raw packets)
+47
View File
@@ -93,6 +93,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 8)
applied += 1
# Migration 9: Create app_settings table for persistent preferences
if version < 9:
logger.info("Applying migration 9: create app_settings table")
await _migrate_009_create_app_settings_table(conn)
await set_version(conn, 9)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -582,3 +589,43 @@ async def _migrate_008_convert_path_to_paths_array(conn: aiosqlite.Connection) -
raise
await conn.commit()
async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) -> None:
"""
Create app_settings table for persistent application preferences.
This table stores:
- max_radio_contacts: Max non-repeater contacts to keep on radio for DM ACKs
- favorites: JSON array of favorite conversations [{type, id}, ...]
- auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact
- sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting
- last_message_times: JSON object mapping conversation keys to timestamps
- preferences_migrated: Flag to track if localStorage has been migrated
The table uses a single-row pattern (id=1) for simplicity.
"""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0
)
"""
)
# Initialize with default row
await conn.execute(
"""
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
"""
)
await conn.commit()
logger.debug("Created app_settings table with default values")
+37
View File
@@ -1,3 +1,5 @@
from typing import Literal
from pydantic import BaseModel, Field
@@ -218,3 +220,38 @@ class CommandResponse(BaseModel):
sender_timestamp: int | None = Field(
default=None, description="Timestamp from the repeater's response"
)
class Favorite(BaseModel):
"""A favorite conversation."""
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class AppSettings(BaseModel):
"""Application settings stored in the database."""
max_radio_contacts: int = Field(
default=200,
description="Maximum non-repeater contacts to keep on radio for DM ACKs",
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=False,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] = Field(
default="recent",
description="Sidebar sort order: 'recent' or 'alpha'",
)
last_message_times: dict[str, int] = Field(
default_factory=dict,
description="Map of conversation state keys to last message timestamps",
)
preferences_migrated: bool = Field(
default=False,
description="Whether preferences have been migrated from localStorage",
)
+7 -2
View File
@@ -659,9 +659,14 @@ async def _process_advertisement(
},
)
# For new contacts, attempt to decrypt any historical DMs we may have stored
# For new contacts, optionally attempt to decrypt any historical DMs we may have stored
# This is controlled by the auto_decrypt_dm_on_advert setting
if existing is None:
await start_historical_dm_decryption(None, advert.public_key, advert.name)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if settings.auto_decrypt_dm_on_advert:
await start_historical_dm_decryption(None, advert.public_key, advert.name)
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
+191 -2
View File
@@ -3,11 +3,11 @@ import logging
import sqlite3
import time
from hashlib import sha256
from typing import Any
from typing import Any, Literal
from app.database import db
from app.decoder import PayloadType, extract_payload, get_packet_payload_type
from app.models import Channel, Contact, Message, MessagePath, RawPacket
from app.models import AppSettings, Channel, Contact, Favorite, Message, MessagePath, RawPacket
logger = logging.getLogger(__name__)
@@ -703,3 +703,192 @@ class RawPacketRepository:
result.append((row["id"], data, row["timestamp"]))
return result
class AppSettingsRepository:
"""Repository for app_settings table (single-row pattern)."""
@staticmethod
async def get() -> AppSettings:
"""Get the current app settings.
Always returns settings - creates default row if needed (migration handles initial row).
"""
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated
FROM app_settings WHERE id = 1
"""
)
row = await cursor.fetchone()
if not row:
# Should not happen after migration, but handle gracefully
return AppSettings()
# Parse favorites JSON
favorites = []
if row["favorites"]:
try:
favorites_data = json.loads(row["favorites"])
favorites = [Favorite(**f) for f in favorites_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
e,
row["favorites"][:100] if row["favorites"] else None,
)
favorites = []
# Parse last_message_times JSON
last_message_times: dict[str, int] = {}
if row["last_message_times"]:
try:
last_message_times = json.loads(row["last_message_times"])
except (json.JSONDecodeError, TypeError) as e:
logger.warning(
"Failed to parse last_message_times JSON, using empty dict: %s",
e,
)
last_message_times = {}
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
sort_order = "recent"
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
sidebar_sort_order=sort_order,
last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
)
@staticmethod
async def update(
max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
sidebar_sort_order: str | None = None,
last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
params: list[Any] = []
if max_radio_contacts is not None:
updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts)
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
params.append(favorites_json)
if auto_decrypt_dm_on_advert is not None:
updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0)
if sidebar_sort_order is not None:
updates.append("sidebar_sort_order = ?")
params.append(sidebar_sort_order)
if last_message_times is not None:
updates.append("last_message_times = ?")
params.append(json.dumps(last_message_times))
if preferences_migrated is not None:
updates.append("preferences_migrated = ?")
params.append(1 if preferences_migrated else 0)
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
await db.conn.commit()
return await AppSettingsRepository.get()
@staticmethod
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Add a favorite, avoiding duplicates."""
settings = await AppSettingsRepository.get()
# Check if already favorited
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
return settings
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Remove a favorite."""
settings = await AppSettingsRepository.get()
new_favorites = [
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def update_last_message_time(state_key: str, timestamp: int) -> None:
"""Update the last message time for a conversation atomically.
Only updates if the new timestamp is greater than the existing one.
Uses SQLite's json_set for atomic update to avoid race conditions.
"""
# Use COALESCE to handle NULL or missing keys, json_set for atomic update
# Only update if new timestamp > existing (or key doesn't exist)
await db.conn.execute(
"""
UPDATE app_settings
SET last_message_times = json_set(
COALESCE(last_message_times, '{}'),
'$.' || ?,
?
)
WHERE id = 1
AND (
json_extract(last_message_times, '$.' || ?) IS NULL
OR json_extract(last_message_times, '$.' || ?) < ?
)
""",
(state_key, timestamp, state_key, state_key, timestamp),
)
await db.conn.commit()
@staticmethod
async def migrate_preferences_from_frontend(
favorites: list[dict],
sort_order: str,
last_message_times: dict[str, int],
) -> tuple[AppSettings, bool]:
"""Migrate all preferences from frontend localStorage.
This is a one-time migration. If already migrated, returns current settings
without overwriting. Returns (settings, did_migrate) tuple.
"""
settings = await AppSettingsRepository.get()
if settings.preferences_migrated:
# Already migrated, don't overwrite
return settings, False
# Convert frontend favorites format to Favorite objects
new_favorites = []
for f in favorites:
if f.get("type") in ("channel", "contact") and f.get("id"):
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
# Update with migrated preferences and mark as migrated
settings = await AppSettingsRepository.update(
favorites=new_favorites,
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
last_message_times=last_message_times,
preferences_migrated=True,
)
return settings, True
+144 -27
View File
@@ -1,20 +1,16 @@
import logging
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.config import settings
from app.models import AppSettings
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
class AppSettingsResponse(BaseModel):
max_radio_contacts: int = Field(
description="Maximum non-repeater contacts to keep on radio for DM ACKs"
)
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
@@ -22,32 +18,153 @@ class AppSettingsUpdate(BaseModel):
le=1000,
description="Maximum non-repeater contacts to keep on radio (1-1000)",
)
@router.get("", response_model=AppSettingsResponse)
async def get_settings() -> AppSettingsResponse:
"""Get current application settings."""
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
auto_decrypt_dm_on_advert: bool | None = Field(
default=None,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
@router.patch("", response_model=AppSettingsResponse)
async def update_settings(update: AppSettingsUpdate) -> AppSettingsResponse:
class FavoriteRequest(BaseModel):
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class LastMessageTimeUpdate(BaseModel):
state_key: str = Field(
description="Conversation state key (e.g., 'channel-KEY' or 'contact-PREFIX')"
)
timestamp: int = Field(description="Unix timestamp of the last message")
class MigratePreferencesRequest(BaseModel):
favorites: list[FavoriteRequest] = Field(
default_factory=list,
description="List of favorites from localStorage",
)
sort_order: str = Field(
default="recent",
description="Sort order preference from localStorage",
)
last_message_times: dict[str, int] = Field(
default_factory=dict,
description="Map of conversation state keys to timestamps from localStorage",
)
class MigratePreferencesResponse(BaseModel):
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
settings: AppSettings = Field(description="Current settings after migration attempt")
@router.get("", response_model=AppSettings)
async def get_settings() -> AppSettings:
"""Get current application settings."""
return await AppSettingsRepository.get()
@router.patch("", response_model=AppSettings)
async def update_settings(update: AppSettingsUpdate) -> AppSettings:
"""Update application settings.
Note: Changes are applied immediately but not persisted across restarts.
Set MESHCORE_MAX_RADIO_CONTACTS environment variable for persistent changes.
Settings are persisted to the database and survive restarts.
"""
kwargs = {}
if update.max_radio_contacts is not None:
logger.info(
"Updating max_radio_contacts from %d to %d",
settings.max_radio_contacts,
update.max_radio_contacts,
)
# Pydantic settings are mutable, we can update them directly
object.__setattr__(settings, "max_radio_contacts", update.max_radio_contacts)
logger.info("Updating max_radio_contacts to %d", update.max_radio_contacts)
kwargs["max_radio_contacts"] = update.max_radio_contacts
return AppSettingsResponse(
max_radio_contacts=settings.max_radio_contacts,
if update.auto_decrypt_dm_on_advert is not None:
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
if update.sidebar_sort_order is not None:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if kwargs:
return await AppSettingsRepository.update(**kwargs)
return await AppSettingsRepository.get()
@router.post("/favorites", response_model=AppSettings)
async def add_favorite(request: FavoriteRequest) -> AppSettings:
"""Add a conversation to favorites."""
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.add_favorite(request.type, request.id)
@router.delete("/favorites", response_model=AppSettings)
async def remove_favorite(request: FavoriteRequest) -> AppSettings:
"""Remove a conversation from favorites."""
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.remove_favorite(request.type, request.id)
@router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
"""Toggle a conversation's favorite status."""
settings = await AppSettingsRepository.get()
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
if is_favorited:
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.remove_favorite(request.type, request.id)
else:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
return await AppSettingsRepository.add_favorite(request.type, request.id)
@router.post("/last-message-time")
async def update_last_message_time(request: LastMessageTimeUpdate) -> dict:
"""Update the last message time for a conversation.
Used to track when conversations last received messages for sidebar sorting.
Only updates if the new timestamp is greater than the existing one.
"""
await AppSettingsRepository.update_last_message_time(request.state_key, request.timestamp)
return {"status": "ok"}
@router.post("/migrate", response_model=MigratePreferencesResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Migrate all preferences from frontend localStorage to database.
This is a one-time migration. If preferences have already been migrated,
this endpoint will not overwrite them and will return migrated=false.
Call this on frontend startup to ensure preferences are moved to the database.
After successful migration, the frontend should clear localStorage preferences.
Migrates:
- favorites (remoteterm-favorites)
- sort_order (remoteterm-sortOrder)
- last_message_times (remoteterm-lastMessageTime)
"""
# Convert to dict format for the repository method
frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
favorites=frontend_favorites,
sort_order=request.sort_order,
last_message_times=request.last_message_times,
)
if did_migrate:
logger.info(
"Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
len(frontend_favorites),
request.sort_order,
len(request.last_message_times),
)
else:
logger.debug("Preferences already migrated, skipping")
return MigratePreferencesResponse(
migrated=did_migrate,
settings=settings,
)
+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 };
+3
View File
@@ -12,6 +12,9 @@ echo
# Run backend linting and type checking
echo -e "${YELLOW}Running backend lint (Ruff)...${NC}"
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
# validate
uv run ruff check app/ tests/
uv run ruff format --check app/ tests/
echo -e "${GREEN}Backend lint passed!${NC}"
+7 -7
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 8 # All 8 migrations run
assert await get_version(conn) == 8
assert applied == 9 # All 9 migrations run
assert await get_version(conn) == 9
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 8 # All 8 migrations run
assert applied1 == 9 # All 9 migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 8
assert await get_version(conn) == 9
finally:
await conn.close()
@@ -245,9 +245,9 @@ class TestMigration001:
# Run migrations - should not fail
applied = await run_migrations(conn)
# All 8 migrations applied (version incremented) but no error
assert applied == 8
assert await get_version(conn) == 8
# All 9 migrations applied (version incremented) but no error
assert applied == 9
assert await get_version(conn) == 9
finally:
await conn.close()
+5 -1
View File
@@ -260,8 +260,12 @@ class TestAdvertisementPipeline:
async def test_advertisement_triggers_historical_decrypt_for_new_contact(
self, test_db, captured_broadcasts
):
"""New contact via advertisement starts historical DM decryption."""
"""New contact via advertisement starts historical DM decryption when setting enabled."""
from app.packet_processor import process_raw_packet
from app.repository import AppSettingsRepository
# Enable auto-decrypt setting
await AppSettingsRepository.update(auto_decrypt_dm_on_advert=True)
fixture = FIXTURES["advertisement_with_gps"]
packet_bytes = bytes.fromhex(fixture["raw_packet_hex"])