diff --git a/app/models.py b/app/models.py index b5d1eee..869f9fe 100644 --- a/app/models.py +++ b/app/models.py @@ -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)", diff --git a/app/repository/settings.py b/app/repository/settings.py index df4fb1b..3f1b057 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -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 diff --git a/app/routers/settings.py b/app/routers/settings.py index 62e4835..7d75466 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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, - ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7fddd28..5962965 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/settings/migrate', { - method: 'POST', - body: JSON.stringify(request), - }), - // Fanout getFanoutConfigs: () => fetchJson('/fanout'), createFanoutConfig: (config: { diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index b7656b8..c109c6a 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -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, diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 428d87a..83e1188 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -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: '', diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx index 3855527..d0942d9 100644 --- a/frontend/src/test/appSearchJump.test.tsx +++ b/frontend/src/test/appSearchJump.test.tsx @@ -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, }); diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index f207419..eaf8988 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -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, }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 7eca899..a30ca29 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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: '', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 458a26d..71a79df 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -333,7 +333,6 @@ export interface AppSettings { favorites: Favorite[]; auto_decrypt_dm_on_advert: boolean; last_message_times: Record; - preferences_migrated: boolean; advert_interval: number; last_advert_time: number; flood_scope: string; @@ -360,17 +359,6 @@ export interface TrackedTelemetryResponse { names: Record; } -export interface MigratePreferencesRequest { - favorites: Favorite[]; - sort_order: string; - last_message_times: Record; -} - -export interface MigratePreferencesResponse { - migrated: boolean; - settings: AppSettings; -} - /** Contact type constants */ export const CONTACT_TYPE_REPEATER = 2; export const CONTACT_TYPE_ROOM = 3; diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index 937ec6f..badda43 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -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 - } -} diff --git a/frontend/src/utils/favorites.ts b/frontend/src/utils/favorites.ts index c7f3999..4fe9b63 100644 --- a/frontend/src/utils/favorites.ts +++ b/frontend/src/utils/favorites.ts @@ -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 - } -} diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 816c5fa..6aa772e 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -223,7 +223,6 @@ export interface AppSettings { favorites: Favorite[]; auto_decrypt_dm_on_advert: boolean; last_message_times: Record; - preferences_migrated: boolean; advert_interval: number; } diff --git a/tests/test_repository.py b/tests/test_repository.py index cf72ea0..f90b9c7 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -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.""" diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index cc8a70d..7b72828 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -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."""