Clear out old migration logic and replace with thin shim for favorites; sort order is lost

This commit is contained in:
Jack Kingsman
2026-04-03 17:15:41 -07:00
parent 7ad1ee26a4
commit d5922a214b
15 changed files with 28 additions and 328 deletions
-4
View File
@@ -810,10 +810,6 @@ class AppSettings(BaseModel):
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",
)
advert_interval: int = Field(
default=0,
description="Periodic advertisement interval in seconds (0 = disabled)",
+1 -39
View File
@@ -27,7 +27,7 @@ class AppSettingsRepository:
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
last_message_times, preferences_migrated,
last_message_times,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, auto_resend_channel
@@ -110,7 +110,6 @@ class AppSettingsRepository:
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
flood_scope=row["flood_scope"] or "",
@@ -127,7 +126,6 @@ class AppSettingsRepository:
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
flood_scope: str | None = None,
@@ -158,10 +156,6 @@ class AppSettingsRepository:
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 advert_interval is not None:
updates.append("advert_interval = ?")
params.append(advert_interval)
@@ -243,38 +237,6 @@ class AppSettingsRepository:
new_names = settings.blocked_names + [name]
return await AppSettingsRepository.update(blocked_names=new_names)
@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,
last_message_times=last_message_times,
preferences_migrated=True,
)
return settings, True
class StatisticsRepository:
@staticmethod
-60
View File
@@ -85,26 +85,6 @@ class TrackedTelemetryResponse(BaseModel):
)
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."""
@@ -267,43 +247,3 @@ async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedT
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
)
@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,
)
-9
View File
@@ -14,8 +14,6 @@ import type {
MaintenanceResult,
Message,
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RawPacket,
RadioAdvertMode,
RadioConfig,
@@ -342,13 +340,6 @@ export const api = {
body: JSON.stringify({ type, id }),
}),
// Preferences migration (one-time, from localStorage to database)
migratePreferences: (request: MigratePreferencesRequest) =>
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
method: 'POST',
body: JSON.stringify(request),
}),
// Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: {
+22 -55
View File
@@ -2,17 +2,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import {
initLastMessageTimes,
loadLocalStorageLastMessageTimes,
loadLocalStorageSortOrder,
clearLocalStorageConversationState,
} from '../utils/conversationState';
import {
isFavorite,
loadLocalStorageFavorites,
clearLocalStorageFavorites,
} from '../utils/favorites';
import { initLastMessageTimes } from '../utils/conversationState';
import { isFavorite } from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
export function useAppSettings() {
@@ -153,59 +144,35 @@ export function useAppSettings() {
}
}, []);
// One-time migration of localStorage preferences to server
// Legacy favorites migration: if pre-server-side favorites exist in
// localStorage, toggle each one via the existing API and clear the key.
useEffect(() => {
if (!appSettings || hasMigratedRef.current) return;
if (appSettings.preferences_migrated) {
clearLocalStorageFavorites();
clearLocalStorageConversationState();
hasMigratedRef.current = true;
return;
}
const localFavorites = loadLocalStorageFavorites();
const localSortOrder = loadLocalStorageSortOrder();
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
const hasLocalData =
localFavorites.length > 0 ||
localSortOrder !== 'recent' ||
Object.keys(localLastMessageTimes).length > 0;
if (!hasLocalData) {
hasMigratedRef.current = true;
return;
}
hasMigratedRef.current = true;
const migratePreferences = async () => {
const FAVORITES_KEY = 'remoteterm-favorites';
let localFavorites: Favorite[] = [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
if (stored) localFavorites = JSON.parse(stored);
} catch {
// corrupt or unavailable
}
if (localFavorites.length === 0) return;
const migrate = 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`,
});
for (const f of localFavorites) {
await api.toggleFavorite(f.type, f.id);
}
setAppSettings(result.settings);
initLastMessageTimes(result.settings.last_message_times ?? {});
clearLocalStorageFavorites();
clearLocalStorageConversationState();
localStorage.removeItem(FAVORITES_KEY);
await fetchAppSettings();
} catch (err) {
console.error('Failed to migrate preferences:', err);
console.error('Failed to migrate legacy favorites:', err);
}
};
migratePreferences();
}, [appSettings]);
migrate();
}, [appSettings, fetchAppSettings]);
return {
appSettings,
+1 -2
View File
@@ -24,7 +24,6 @@ const mocks = vi.hoisted(() => ({
requestTrace: vi.fn(),
updateRadioConfig: vi.fn(),
setPrivateKey: vi.fn(),
migratePreferences: vi.fn(),
},
toast: {
success: vi.fn(),
@@ -191,7 +190,7 @@ const baseSettings = {
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
+1 -2
View File
@@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
useConversationMessagesCalls: vi.fn(),
}));
@@ -219,7 +218,7 @@ describe('App search jump target handling', () => {
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
});
+1 -2
View File
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
}));
@@ -170,7 +169,7 @@ describe('App startup hash resolution', () => {
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
});
+1 -1
View File
@@ -62,7 +62,7 @@ const baseSettings: AppSettings = {
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
-12
View File
@@ -333,7 +333,6 @@ export interface AppSettings {
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
last_advert_time: number;
flood_scope: string;
@@ -360,17 +359,6 @@ export interface TrackedTelemetryResponse {
names: Record<string, string>;
}
export interface MigratePreferencesRequest {
favorites: Favorite[];
sort_order: string;
last_message_times: Record<string, number>;
}
export interface MigratePreferencesResponse {
migrated: boolean;
settings: AppSettings;
}
/** Contact type constants */
export const CONTACT_TYPE_REPEATER = 2;
export const CONTACT_TYPE_ROOM = 3;
-37
View File
@@ -9,7 +9,6 @@
* across devices - see useUnreadCounts hook.
*/
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const SORT_ORDER_KEY = 'remoteterm-sortOrder';
const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders';
@@ -72,30 +71,6 @@ export function getStateKey(type: 'channel' | 'contact', id: string): string {
return `${type}-${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';
}
}
/**
* Load the legacy single sidebar sort order from localStorage, if present.
*/
@@ -149,15 +124,3 @@ export function saveLocalStorageSidebarSectionSortOrders(orders: SidebarSectionS
// localStorage might be disabled
}
}
/**
* 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
}
}
+1 -28
View File
@@ -1,15 +1,11 @@
/**
* Favorites utilities.
*
* Favorites are now stored server-side in the database.
* This file provides helper functions for checking favorites
* and loading legacy localStorage data for migration.
* Favorites are stored server-side in the database.
*/
import type { Favorite } from '../types';
const FAVORITES_KEY = 'remoteterm-favorites';
/**
* Check if a conversation is favorited (from provided favorites array)
*/
@@ -20,26 +16,3 @@ export function isFavorite(
): boolean {
return favorites.some((f) => f.type === type && f.id === id);
}
/**
* Load favorites from localStorage (for migration only)
*/
export function loadLocalStorageFavorites(): Favorite[] {
try {
const stored = localStorage.getItem(FAVORITES_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Clear favorites from localStorage (after migration)
*/
export function clearLocalStorageFavorites(): void {
try {
localStorage.removeItem(FAVORITES_KEY);
} catch {
// localStorage might be disabled
}
}
-1
View File
@@ -223,7 +223,6 @@ export interface AppSettings {
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
}
-34
View File
@@ -623,7 +623,6 @@ class TestAppSettingsRepository:
"favorites": "{not-json",
"auto_decrypt_dm_on_advert": 1,
"last_message_times": "{also-not-json",
"preferences_migrated": 0,
"advert_interval": None,
"last_advert_time": None,
"flood_scope": "",
@@ -672,39 +671,6 @@ class TestAppSettingsRepository:
assert result == existing
mock_update.assert_not_awaited()
@pytest.mark.asyncio
async def test_migrate_preferences_uses_recent_for_invalid_sort_order(self):
"""Migration normalizes invalid sort order to 'recent'."""
from app.models import AppSettings
current = AppSettings(preferences_migrated=False)
migrated = AppSettings(preferences_migrated=True)
with (
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=current,
),
patch(
"app.repository.AppSettingsRepository.update",
new_callable=AsyncMock,
return_value=migrated,
) as mock_update,
):
from app.repository import AppSettingsRepository
result, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
favorites=[{"type": "contact", "id": "bb" * 32}],
sort_order="weird-order",
last_message_times={"contact-bbbbbbbbbbbb": 123},
)
assert did_migrate is True
assert result.preferences_migrated is True
assert "sidebar_sort_order" not in mock_update.call_args.kwargs
assert mock_update.call_args.kwargs["preferences_migrated"] is True
class TestMessageRepositoryGetById:
"""Test MessageRepository.get_by_id method."""
-42
View File
@@ -10,9 +10,7 @@ from app.repository import AppSettingsRepository, ContactRepository
from app.routers.settings import (
AppSettingsUpdate,
FavoriteRequest,
MigratePreferencesRequest,
TrackedTelemetryRequest,
migrate_preferences,
toggle_favorite,
toggle_tracked_telemetry,
update_settings,
@@ -167,46 +165,6 @@ class TestToggleFavorite:
mock_create_task.assert_not_called()
class TestMigratePreferences:
@pytest.mark.asyncio
async def test_maps_frontend_payload_and_returns_migrated_true(self, test_db):
request = MigratePreferencesRequest(
favorites=[FavoriteRequest(type="contact", id="aa" * 32)],
sort_order="alpha",
last_message_times={"contact-aaaaaaaaaaaa": 123},
)
response = await migrate_preferences(request)
assert response.migrated is True
assert response.settings.preferences_migrated is True
assert len(response.settings.favorites) == 1
assert response.settings.favorites[0].type == "contact"
assert response.settings.favorites[0].id == "aa" * 32
assert response.settings.last_message_times == {"contact-aaaaaaaaaaaa": 123}
@pytest.mark.asyncio
async def test_returns_migrated_false_when_already_done(self, test_db):
# First migration
first_request = MigratePreferencesRequest(
favorites=[FavoriteRequest(type="contact", id="bb" * 32)],
sort_order="recent",
last_message_times={},
)
await migrate_preferences(first_request)
# Second attempt should be no-op
second_request = MigratePreferencesRequest(
favorites=[],
sort_order="recent",
last_message_times={},
)
response = await migrate_preferences(second_request)
assert response.migrated is False
assert response.settings.preferences_migrated is True
class TestToggleTrackedTelemetry:
"""Tests for POST /settings/tracked-telemetry/toggle."""