diff --git a/app/database.py b/app/database.py index 0834ced..6fa85ad 100644 --- a/app/database.py +++ b/app/database.py @@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts ( on_radio INTEGER DEFAULT 0, last_contacted INTEGER, first_seen INTEGER, - last_read_at INTEGER + last_read_at INTEGER, + favorite INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS channels ( @@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels ( on_radio INTEGER DEFAULT 0, flood_scope_override TEXT, path_hash_mode_override INTEGER, - last_read_at INTEGER + last_read_at INTEGER, + favorite INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( diff --git a/app/migrations.py b/app/migrations.py index 35b4cb1..871eb78 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -413,6 +413,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 54) applied += 1 + if version < 55: + logger.info("Applying migration 55: move favorites to per-entity columns") + await _migrate_055_favorites_to_columns(conn) + await set_version(conn, 55) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -3213,3 +3219,91 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None: "ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0" ) await conn.commit() + + +async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None: + """Move favorites from app_settings JSON blob to per-entity boolean columns. + + 1. Add ``favorite`` column to contacts and channels tables. + 2. Backfill from the ``app_settings.favorites`` JSON array. + 3. Drop the ``favorites`` column from app_settings. + """ + import json as _json + + # --- Add columns --- + tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + existing_tables = {row[0] for row in await tables_cursor.fetchall()} + for table in ("contacts", "channels"): + if table not in existing_tables: + continue + col_cursor = await conn.execute(f"PRAGMA table_info({table})") + columns = {row[1] for row in await col_cursor.fetchall()} + if "favorite" not in columns: + await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0") + await conn.commit() + + # --- Backfill from JSON --- + tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}: + await conn.commit() + return + + col_cursor = await conn.execute("PRAGMA table_info(app_settings)") + settings_columns = {row[1] for row in await col_cursor.fetchall()} + if "favorites" not in settings_columns: + await conn.commit() + return + + cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1") + row = await cursor.fetchone() + if row and row[0]: + try: + favorites = _json.loads(row[0]) + except (ValueError, TypeError): + favorites = [] + + contact_keys = [] + channel_keys = [] + for fav in favorites: + if not isinstance(fav, dict): + continue + fav_type = fav.get("type") + fav_id = fav.get("id") + if not fav_id: + continue + if fav_type == "contact": + contact_keys.append(fav_id) + elif fav_type == "channel": + channel_keys.append(fav_id) + + if contact_keys: + placeholders = ",".join("?" for _ in contact_keys) + await conn.execute( + f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})", + contact_keys, + ) + if channel_keys: + placeholders = ",".join("?" for _ in channel_keys) + await conn.execute( + f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})", + channel_keys, + ) + if contact_keys or channel_keys: + logger.info( + "Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings", + len(contact_keys), + len(channel_keys), + ) + await conn.commit() + + # --- Drop the JSON column --- + try: + await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites") + await conn.commit() + except Exception as e: + error_msg = str(e).lower() + if "syntax error" in error_msg or "drop column" in error_msg: + logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused") + await conn.commit() + else: + raise diff --git a/app/models.py b/app/models.py index b711edb..52fa989 100644 --- a/app/models.py +++ b/app/models.py @@ -91,6 +91,7 @@ class Contact(BaseModel): lon: float | None = None last_seen: int | None = None on_radio: bool = False + favorite: bool = False last_contacted: int | None = None # Last time we sent/received a message last_read_at: int | None = None # Server-side read state tracking first_seen: int | None = None @@ -326,6 +327,7 @@ class Channel(BaseModel): description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)", ) last_read_at: int | None = None # Server-side read state tracking + favorite: bool = False class ChannelMessageCounts(BaseModel): @@ -756,13 +758,6 @@ class RadioDiscoveryResponse(BaseModel): ) -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 UnreadCounts(BaseModel): """Aggregated unread counts, mention flags, and last message times for all conversations.""" @@ -790,9 +785,6 @@ class AppSettings(BaseModel): "favorites reload first, then background fill targets about 80% of this value" ), ) - favorites: list[Favorite] = Field( - default_factory=list, description="List of favorited conversations" - ) auto_decrypt_dm_on_advert: bool = Field( default=True, description="Whether to attempt historical DM decryption on new contact advertisement", diff --git a/app/radio_sync.py b/app/radio_sync.py index f5d86a1..467fbb3 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME from app.config import settings from app.event_handlers import cleanup_expired_acks, on_contact_message -from app.models import Contact, ContactUpsert, Favorite +from app.models import Contact, ContactUpsert from app.radio import RadioOperationBusyError from app.repository import ( AmbiguousPublicKeyPrefixError, @@ -1071,18 +1071,17 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict: logger.debug("Synced %d contacts from radio snapshot", synced) # Import radio-favorited contacts into app favorites - radio_fav_keys = {pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01} + radio_fav_keys = [pk for pk, data in contacts.items() if data.get("flags", 0) & 0x01] if radio_fav_keys: try: - settings_obj = await AppSettingsRepository.get() - existing_fav_ids = {f.id for f in settings_obj.favorites} - new_favs = radio_fav_keys - existing_fav_ids - if new_favs: - merged = settings_obj.favorites + [ - Favorite(type="contact", id=pk) for pk in sorted(new_favs) - ] - await AppSettingsRepository.update(favorites=merged) - logger.info("Imported %d radio favorite(s) into app favorites", len(new_favs)) + imported = 0 + for pk in radio_fav_keys: + existing = await ContactRepository.get_by_key(pk) + if existing and not existing.favorite: + await ContactRepository.set_favorite(pk, True) + imported += 1 + if imported: + logger.info("Imported %d radio favorite(s) into app favorites", imported) except Exception as e: logger.warning("Failed to import radio favorites: %s", e) @@ -1297,26 +1296,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]: selected_contacts: list[Contact] = [] selected_keys: set[str] = set() + # Favorites first — always loaded up to max_contacts favorite_contacts_loaded = 0 - for favorite in app_settings.favorites: - if favorite.type != "contact": - continue - try: - contact = await ContactRepository.get_by_key_or_prefix(favorite.id) - except AmbiguousPublicKeyPrefixError: - logger.warning( - "Skipping favorite contact '%s': ambiguous key prefix; use full key", - favorite.id, - ) - continue - if not contact: - continue - if len(contact.public_key) < 64: - logger.debug( - "Skipping unresolved prefix-only favorite contact '%s' for radio sync", - favorite.id, - ) - continue + for contact in await ContactRepository.get_favorites(): key = contact.public_key.lower() if key in selected_keys: continue diff --git a/app/repository/channels.py b/app/repository/channels.py index 7255422..47f232c 100644 --- a/app/repository/channels.py +++ b/app/repository/channels.py @@ -26,7 +26,7 @@ class ChannelRepository: """Get a channel by its key (32-char hex string).""" cursor = await db.conn.execute( """ - SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at + SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite FROM channels WHERE key = ? """, @@ -42,6 +42,7 @@ class ChannelRepository: flood_scope_override=row["flood_scope_override"], path_hash_mode_override=row["path_hash_mode_override"], last_read_at=row["last_read_at"], + favorite=bool(row["favorite"]), ) return None @@ -49,7 +50,7 @@ class ChannelRepository: async def get_all() -> list[Channel]: cursor = await db.conn.execute( """ - SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at + SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite FROM channels ORDER BY name """ @@ -64,10 +65,21 @@ class ChannelRepository: flood_scope_override=row["flood_scope_override"], path_hash_mode_override=row["path_hash_mode_override"], last_read_at=row["last_read_at"], + favorite=bool(row["favorite"]), ) for row in rows ] + @staticmethod + async def set_favorite(key: str, value: bool) -> bool: + """Set or clear the favorite flag for a channel. Returns True if row was found.""" + cursor = await db.conn.execute( + "UPDATE channels SET favorite = ? WHERE key = ?", + (1 if value else 0, key.upper()), + ) + await db.conn.commit() + return cursor.rowcount > 0 + @staticmethod async def delete(key: str) -> None: """Delete a channel by key.""" diff --git a/app/repository/contacts.py b/app/repository/contacts.py index 3a6a343..f1386e1 100644 --- a/app/repository/contacts.py +++ b/app/repository/contacts.py @@ -170,6 +170,7 @@ class ContactRepository: lon=row["lon"], last_seen=row["last_seen"], on_radio=bool(row["on_radio"]), + favorite=bool(row["favorite"]) if "favorite" in available_columns else False, last_contacted=row["last_contacted"], last_read_at=row["last_read_at"], first_seen=row["first_seen"], @@ -392,6 +393,24 @@ class ContactRepository: ) await db.conn.commit() + @staticmethod + async def get_favorites() -> list[Contact]: + """Return all contacts marked as favorite.""" + cursor = await db.conn.execute( + "SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64" + ) + rows = await cursor.fetchall() + return [ContactRepository._row_to_contact(row) for row in rows] + + @staticmethod + async def set_favorite(public_key: str, value: bool) -> None: + """Set or clear the favorite flag for a contact.""" + await db.conn.execute( + "UPDATE contacts SET favorite = ? WHERE public_key = ?", + (1 if value else 0, public_key.lower()), + ) + await db.conn.commit() + @staticmethod async def delete(public_key: str) -> None: normalized = public_key.lower() diff --git a/app/repository/settings.py b/app/repository/settings.py index d7c82bb..aa4d703 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -1,10 +1,10 @@ import json import logging import time -from typing import Any, Literal +from typing import Any from app.database import db -from app.models import AppSettings, Favorite +from app.models import AppSettings from app.path_utils import bucket_path_hash_widths logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ class AppSettingsRepository: """ cursor = await db.conn.execute( """ - SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert, + SELECT max_radio_contacts, auto_decrypt_dm_on_advert, last_message_times, advert_interval, last_advert_time, flood_scope, blocked_keys, blocked_names, discovery_blocked_types, @@ -40,20 +40,6 @@ class AppSettingsRepository: # 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"]: @@ -107,7 +93,6 @@ class AppSettingsRepository: return AppSettings( max_radio_contacts=row["max_radio_contacts"], - favorites=favorites, auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]), last_message_times=last_message_times, advert_interval=row["advert_interval"] or 0, @@ -123,7 +108,6 @@ class AppSettingsRepository: @staticmethod async def update( max_radio_contacts: int | None = None, - favorites: list[Favorite] | None = None, auto_decrypt_dm_on_advert: bool | None = None, last_message_times: dict[str, int] | None = None, advert_interval: int | None = None, @@ -143,11 +127,6 @@ class AppSettingsRepository: 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) @@ -195,27 +174,6 @@ class AppSettingsRepository: 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 toggle_blocked_key(key: str) -> AppSettings: """Toggle a public key in the blocked list. Keys are normalized to lowercase.""" diff --git a/app/routers/settings.py b/app/routers/settings.py index 7d75466..1420ab9 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from app.models import CONTACT_TYPE_REPEATER, AppSettings from app.region_scope import normalize_region_scope -from app.repository import AppSettingsRepository, ContactRepository +from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository logger = logging.getLogger(__name__) router = APIRouter(prefix="/settings", tags=["settings"]) @@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel): id: str = Field(description="Channel key or contact public key") +class FavoriteToggleResponse(BaseModel): + type: Literal["channel", "contact"] + id: str + favorite: bool + + class TrackedTelemetryRequest(BaseModel): public_key: str = Field(description="Public key of the repeater to toggle tracking") @@ -157,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: return await AppSettingsRepository.get() -@router.post("/favorites/toggle", response_model=AppSettings) -async def toggle_favorite(request: FavoriteRequest) -> AppSettings: +@router.post("/favorites/toggle", response_model=FavoriteToggleResponse) +async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse: """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 request.type == "contact": + contact = await ContactRepository.get_by_key(request.id) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + new_value = not contact.favorite + await ContactRepository.set_favorite(request.id, new_value) + logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12]) + # When newly favorited, load to radio immediately for DM ACK support + if new_value: + from app.radio_sync import ensure_contact_on_radio - if is_favorited: - logger.info("Removing favorite: %s %s", request.type, request.id[:12]) - result = await AppSettingsRepository.remove_favorite(request.type, request.id) + asyncio.create_task(ensure_contact_on_radio(request.id, force=True)) else: - logger.info("Adding favorite: %s %s", request.type, request.id[:12]) - result = await AppSettingsRepository.add_favorite(request.type, request.id) + channel = await ChannelRepository.get_by_key(request.id) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + new_value = not channel.favorite + await ChannelRepository.set_favorite(request.id, new_value) + logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12]) - # When a contact is newly favorited, load just that contact to the radio - # immediately so DM ACK support does not wait for the next maintenance cycle. - if request.type == "contact" and not is_favorited: - from app.radio_sync import ensure_contact_on_radio - - asyncio.create_task(ensure_contact_on_radio(request.id, force=True)) - - return result + return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value) @router.post("/blocked-keys/toggle", response_model=AppSettings) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2ec167..61ed56a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { useUnreadTitle, useRawPacketStatsSession, } from './hooks'; +import { toast } from './components/ui/sonner'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; @@ -150,10 +151,8 @@ export function App() { const { appSettings, - favorites, fetchAppSettings, handleSaveAppSettings, - handleToggleFavorite, handleToggleBlockedKey, handleToggleBlockedName, handleToggleTrackedTelemetry, @@ -204,6 +203,38 @@ export function App() { removeConversationMessagesRef.current(conversationId), }); + const handleToggleFavorite = useCallback( + async (type: 'channel' | 'contact', id: string) => { + // Optimistically toggle the favorite flag + if (type === 'contact') { + setContacts((prev) => + prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c)) + ); + } else { + setChannels((prev) => + prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c)) + ); + } + + try { + await api.toggleFavorite(type, id); + } catch { + // Revert on failure + if (type === 'contact') { + setContacts((prev) => + prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c)) + ); + } else { + setChannels((prev) => + prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c)) + ); + } + toast.error('Failed to update favorite'); + } + }, + [setContacts, setChannels] + ); + // useConversationRouter is called second — it receives channels/contacts as inputs const { activeConversation, @@ -290,8 +321,8 @@ export function App() { markAllRead, refreshUnreads, } = useUnreadCounts(channels, contacts, activeConversation); - useFaviconBadge(unreadCounts, mentions, favorites); - useUnreadTitle(unreadCounts, favorites); + useFaviconBadge(unreadCounts, mentions, channels); + useUnreadTitle(unreadCounts, contacts, channels); useEffect(() => { if (activeConversation?.type !== 'channel') { @@ -491,7 +522,6 @@ export function App() { onMarkAllRead: () => { void markAllRead(); }, - favorites, isConversationNotificationsEnabled, blockedKeys: appSettings?.blocked_keys ?? [], blockedNames: appSettings?.blocked_names ?? [], @@ -507,7 +537,6 @@ export function App() { rawPacketStatsSession, config, health, - favorites, messages: sortedMessages, preSorted: activeContactIsRoom, messagesLoading, @@ -614,7 +643,6 @@ export function App() { onClose: handleCloseContactInfo, contacts, config, - favorites, onToggleFavorite: handleToggleFavorite, onNavigateToChannel: handleNavigateToChannel, onSearchMessagesByKey: (publicKey: string) => { @@ -632,7 +660,6 @@ export function App() { channelKey: infoPaneChannelKey, onClose: handleCloseChannelInfo, channels, - favorites, onToggleFavorite: handleToggleFavorite, }; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4707117..39f957d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -9,7 +9,6 @@ import type { ContactAnalytics, ContactAdvertPathSummary, FanoutConfig, - Favorite, HealthStatus, MaintenanceResult, Message, @@ -334,8 +333,8 @@ export const api = { }), // Favorites - toggleFavorite: (type: Favorite['type'], id: string) => - fetchJson('/settings/favorites/toggle', { + toggleFavorite: (type: 'channel' | 'contact', id: string) => + fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', { method: 'POST', body: JSON.stringify({ type, id }), }), diff --git a/frontend/src/components/ChannelInfoPane.tsx b/frontend/src/components/ChannelInfoPane.tsx index c117f59..8c63da6 100644 --- a/frontend/src/components/ChannelInfoPane.tsx +++ b/frontend/src/components/ChannelInfoPane.tsx @@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } import { Star } from 'lucide-react'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; -import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; -import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types'; +import type { Channel, ChannelDetail, PathHashWidthStats } from '../types'; interface ChannelInfoPaneProps { channelKey: string | null; onClose: () => void; channels: Channel[]; - favorites: Favorite[]; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; } @@ -21,7 +19,6 @@ export function ChannelInfoPane({ channelKey, onClose, channels, - favorites, onToggleFavorite, }: ChannelInfoPaneProps) { const [detail, setDetail] = useState(null); @@ -125,7 +122,7 @@ export function ChannelInfoPane({ className="text-sm flex items-center gap-2 hover:text-primary transition-colors" onClick={() => onToggleFavorite('channel', channel.key)} > - {isFavorite(favorites, 'channel', channel.key) ? ( + {channel.favorite ? ( <>