forked from iarv/Remote-Terminal-for-MeshCore
Move to server side preference and read indicator management
This commit is contained in:
+24
-3
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+542
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-542
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user