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,
)