mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 05:45:11 +02:00
Add favorites as contact field (dug)
This commit is contained in:
+4
-2
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-10
@@ -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",
|
||||
|
||||
+12
-30
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+27
-18
@@ -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)
|
||||
|
||||
+35
-8
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+2
-3
@@ -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<AppSettings>('/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 }),
|
||||
}),
|
||||
|
||||
@@ -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<ChannelDetail | null>(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 ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
@@ -13,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -49,7 +40,6 @@ export function ChatHeader({
|
||||
contacts,
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -105,12 +95,18 @@ export function ChatHeader({
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
const isFav =
|
||||
conversation.type === 'contact'
|
||||
? (activeContact?.favorite ?? false)
|
||||
: conversation.type === 'channel'
|
||||
? (activeChannel?.favorite ?? false)
|
||||
: false;
|
||||
const favoriteTitle =
|
||||
conversation.type === 'contact'
|
||||
? isFavorite(favorites, 'contact', conversation.id)
|
||||
? isFav
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
: isFav
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites';
|
||||
|
||||
@@ -359,13 +355,9 @@ export function ChatHeader({
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={favoriteTitle}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
{isFav ? (
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -42,7 +41,6 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Favorite,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
@@ -67,7 +65,6 @@ interface ContactInfoPaneProps {
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
@@ -84,7 +81,6 @@ export function ContactInfoPane({
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
@@ -384,7 +380,7 @@ export function ContactInfoPane({
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
title="Favorite contacts stay loaded on the radio for ACK support"
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
{contact.favorite ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
preSorted?: boolean;
|
||||
messagesLoading: boolean;
|
||||
@@ -119,7 +117,6 @@ export function ConversationPane({
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
preSorted,
|
||||
messagesLoading,
|
||||
@@ -237,7 +234,6 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
@@ -266,7 +262,6 @@ export function ConversationPane({
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
|
||||
@@ -9,17 +9,10 @@ import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../types';
|
||||
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
|
||||
interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -61,7 +53,6 @@ interface RepeaterDashboardProps {
|
||||
export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -134,7 +125,7 @@ export function RepeaterDashboard({
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
const isFav = contact?.favorite ?? false;
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type Contact,
|
||||
type Channel,
|
||||
type Conversation,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import {
|
||||
buildSidebarSectionSortOrders,
|
||||
@@ -36,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -106,7 +104,6 @@ interface SidebarProps {
|
||||
crackerRunning: boolean;
|
||||
onToggleCracker: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
favorites: Favorite[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
@@ -135,7 +132,6 @@ export function Sidebar({
|
||||
crackerRunning,
|
||||
onToggleCracker,
|
||||
onMarkAllRead,
|
||||
favorites,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
@@ -488,22 +484,16 @@ export function Sidebar({
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favChannels = filteredChannels.filter((c) => c.favorite);
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
].filter((c) => c.favorite);
|
||||
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
|
||||
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
|
||||
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
@@ -522,7 +512,6 @@ export function Sidebar({
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
@@ -3,16 +3,11 @@ import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { initLastMessageTimes } from '../utils/conversationState';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||
import type { AppSettings, AppSettingsUpdate } from '../types';
|
||||
|
||||
export function useAppSettings() {
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
|
||||
// Stable empty array prevents a new reference every render when there are none.
|
||||
const emptyFavorites = useRef<Favorite[]>([]).current;
|
||||
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
||||
|
||||
// One-time migration guard
|
||||
const hasMigratedRef = useRef(false);
|
||||
|
||||
@@ -85,32 +80,6 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const currentFavorites = prev.favorites ?? [];
|
||||
const wasFavorited = isFavorite(currentFavorites, type, id);
|
||||
const optimisticFavorites = wasFavorited
|
||||
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
|
||||
: [...currentFavorites, { type, id }];
|
||||
return { ...prev, favorites: optimisticFavorites };
|
||||
});
|
||||
|
||||
try {
|
||||
const updatedSettings = await api.toggleFavorite(type, id);
|
||||
setAppSettings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
toast.error('Failed to update favorite');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
@@ -151,7 +120,7 @@ export function useAppSettings() {
|
||||
hasMigratedRef.current = true;
|
||||
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
let localFavorites: Favorite[] = [];
|
||||
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) localFavorites = JSON.parse(stored);
|
||||
@@ -161,25 +130,26 @@ export function useAppSettings() {
|
||||
if (localFavorites.length === 0) return;
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
for (const f of localFavorites) {
|
||||
let migrated = 0;
|
||||
for (const f of localFavorites) {
|
||||
try {
|
||||
await api.toggleFavorite(f.type, f.id);
|
||||
migrated++;
|
||||
} catch {
|
||||
// Entity may have been deleted; skip and continue
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
await fetchAppSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate legacy favorites:', err);
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
// Reload so contacts/channels pick up the new favorite flags
|
||||
if (migrated > 0) window.location.reload();
|
||||
};
|
||||
migrate();
|
||||
}, [appSettings, fetchAppSettings]);
|
||||
}, [appSettings]);
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
return channels.reduce(
|
||||
(sum, channel) =>
|
||||
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
let sum = 0;
|
||||
for (const contact of contacts) {
|
||||
if (contact.favorite) {
|
||||
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
|
||||
}
|
||||
}
|
||||
for (const channel of channels) {
|
||||
if (channel.favorite) {
|
||||
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
@@ -65,13 +74,13 @@ export function getUnreadTitle(
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
export function useUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const title = useMemo(
|
||||
() => getUnreadTitle(unreadCounts, contacts, channels),
|
||||
[contacts, channels, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
|
||||
[channels, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -187,7 +187,6 @@ const baseConfig = {
|
||||
|
||||
const baseSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -204,6 +203,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App favorite toggle flow', () => {
|
||||
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
mocks.api.toggleFavorite.mockResolvedValue({
|
||||
...baseSettings,
|
||||
favorites: [{ type: 'channel', id: publicChannel.key }],
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back favorite state by refetching settings on toggle failure', async () => {
|
||||
it('rolls back favorite state on toggle failure', async () => {
|
||||
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
|
||||
mocks.api.getSettings
|
||||
.mockResolvedValueOnce({ ...baseSettings }) // initial load
|
||||
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
|
||||
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
|
||||
});
|
||||
|
||||
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
|
||||
@@ -145,6 +145,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App startup hash resolution', () => {
|
||||
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChannelInfoPane } from '../components/ChannelInfoPane';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../api', () => ({
|
||||
@@ -15,7 +15,7 @@ import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
function makeDetail(channel: Channel): ChannelDetail {
|
||||
@@ -41,7 +41,6 @@ const noop = () => {};
|
||||
|
||||
const baseProps = {
|
||||
onClose: noop,
|
||||
favorites: [] as Favorite[],
|
||||
onToggleFavorite: noop,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -15,7 +15,6 @@ const noop = () => {};
|
||||
const baseProps = {
|
||||
contacts: [],
|
||||
config: null,
|
||||
favorites: [] as Favorite[],
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: 1699990000,
|
||||
@@ -90,7 +91,6 @@ const baseProps = {
|
||||
onClose: () => {},
|
||||
contacts: [] as Contact[],
|
||||
config: null,
|
||||
favorites: [],
|
||||
onToggleFavorite: () => {},
|
||||
onSearchMessagesByKey: vi.fn(),
|
||||
onSearchMessagesByName: vi.fn(),
|
||||
|
||||
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -97,6 +89,7 @@ const channel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
favorites: [] as Favorite[],
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
@@ -205,6 +197,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -278,6 +271,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -372,6 +366,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
@@ -408,6 +403,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
|
||||
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: true,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('MapView', () => {
|
||||
lon: -74,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -74,6 +75,7 @@ describe('MapView', () => {
|
||||
lon: -73,
|
||||
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
|
||||
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
||||
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
|
||||
// Mock the hook — typed as mutable version of the return type
|
||||
const mockHook: {
|
||||
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
];
|
||||
|
||||
const favorites: Favorite[] = [];
|
||||
|
||||
const defaultProps = {
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ const roomContact: Contact = {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
|
||||
const defaultProps = {
|
||||
contacts: [],
|
||||
channels: [
|
||||
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
|
||||
{
|
||||
key: 'ABC123',
|
||||
name: 'Public',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
};
|
||||
@@ -239,6 +246,7 @@ describe('SearchView', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
|
||||
@@ -59,7 +59,6 @@ const baseHealth: HealthStatus = {
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
type Channel,
|
||||
type Contact,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, type Channel, type Contact } from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
@@ -19,6 +13,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +36,7 @@ function makeContact(
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -51,7 +47,6 @@ function makeContact(
|
||||
function renderSidebar(overrides?: {
|
||||
unreadCounts?: Record<string, number>;
|
||||
mentions?: Record<string, boolean>;
|
||||
favorites?: Favorite[];
|
||||
lastMessageTimes?: ConversationTimes;
|
||||
channels?: Channel[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
@@ -59,7 +54,7 @@ function renderSidebar(overrides?: {
|
||||
const aliceName = 'Alice';
|
||||
const roomName = 'Ops Board';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
|
||||
const flightChannel = { ...makeChannel('BB'.repeat(16), '#flight'), favorite: true };
|
||||
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
|
||||
const alice = makeContact('11'.repeat(32), aliceName);
|
||||
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
|
||||
@@ -73,7 +68,6 @@ function renderSidebar(overrides?: {
|
||||
[getStateKey('contact', relay.public_key)]: 4,
|
||||
};
|
||||
|
||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||
const onSelectConversation = vi.fn();
|
||||
|
||||
@@ -91,7 +85,6 @@ function renderSidebar(overrides?: {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={favorites}
|
||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -138,7 +131,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -195,11 +187,26 @@ describe('Sidebar section summaries', () => {
|
||||
});
|
||||
|
||||
it('turns favorite contact row badges red', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
|
||||
});
|
||||
const alice = makeContact('11'.repeat(32), 'Alice', 1, { favorite: true });
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[alice]}
|
||||
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{}}
|
||||
unreadCounts={{ [getStateKey('contact', alice.public_key)]: 3 }}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const aliceRow = screen.getByText('Alice').closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
@@ -297,7 +304,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -393,7 +399,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [],
|
||||
};
|
||||
|
||||
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
||||
@@ -464,7 +469,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -498,7 +502,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -546,7 +549,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -578,7 +580,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -593,8 +594,8 @@ describe('Sidebar section summaries', () => {
|
||||
|
||||
it('sorts favorites independently and persists the favorites sort preference', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy],
|
||||
@@ -611,10 +612,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
] satisfies Favorite[],
|
||||
};
|
||||
|
||||
const getFavoritesOrder = () =>
|
||||
@@ -641,8 +638,8 @@ describe('Sidebar section summaries', () => {
|
||||
localStorage.setItem('remoteterm-sortOrder', 'alpha');
|
||||
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
@@ -660,10 +657,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeContact(
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -193,6 +193,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: true,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: '11111111111111111111111111111111',
|
||||
@@ -200,6 +201,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: '22222222222222222222222222222222',
|
||||
@@ -207,6 +209,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -241,6 +244,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -258,6 +262,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -275,6 +280,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeContact(suffix: string): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -184,6 +185,7 @@ describe('useContactsAndChannels', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const sentMessage: Message = {
|
||||
@@ -208,6 +209,7 @@ describe('useConversationActions', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||
|
||||
@@ -10,9 +10,34 @@ import {
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
} from '../hooks/useFaviconBadge';
|
||||
import type { Favorite } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
function makeChannel(key: string, favorite = false): Channel {
|
||||
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
|
||||
}
|
||||
|
||||
function makeContact(publicKey: string, favorite = false): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name: publicKey,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
|
||||
return (
|
||||
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
|
||||
@@ -71,16 +96,16 @@ describe('useFaviconBadge', () => {
|
||||
});
|
||||
|
||||
it('derives badge priority from unread counts, mentions, and favorites', () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
|
||||
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
|
||||
expect(deriveFaviconBadgeState({}, {}, channels)).toBe('none');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 3,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('green');
|
||||
expect(
|
||||
@@ -89,7 +114,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'abc')]: 12,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('red');
|
||||
expect(
|
||||
@@ -100,7 +125,7 @@ describe('useFaviconBadge', () => {
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: true,
|
||||
},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('red');
|
||||
});
|
||||
@@ -116,7 +141,7 @@ describe('useFaviconBadge', () => {
|
||||
it('derives the unread count and page title', () => {
|
||||
expect(getTotalUnreadCount({})).toBe(0);
|
||||
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
|
||||
expect(getFavoriteUnreadCount({}, [])).toBe(0);
|
||||
expect(getFavoriteUnreadCount({}, [], [])).toBe(0);
|
||||
expect(
|
||||
getFavoriteUnreadCount(
|
||||
{
|
||||
@@ -124,20 +149,19 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'fav-contact')]: 3,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[
|
||||
{ type: 'channel', id: 'fav-chan' },
|
||||
{ type: 'contact', id: 'fav-contact' },
|
||||
]
|
||||
[makeContact('fav-contact', true)],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe(10);
|
||||
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(getUnreadTitle({}, [], [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
[],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe('(7) RemoteTerm');
|
||||
expect(
|
||||
@@ -145,28 +169,29 @@ describe('useFaviconBadge', () => {
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 120,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
[],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe('(99+) RemoteTerm');
|
||||
});
|
||||
|
||||
it('switches between the base favicon and generated blob badges', async () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
mentions,
|
||||
currentFavorites,
|
||||
currentChannels,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
currentFavorites: Favorite[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
|
||||
currentChannels: Channel[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentChannels),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -181,7 +206,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -194,7 +219,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'dm-key')]: 12,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -205,7 +230,7 @@ describe('useFaviconBadge', () => {
|
||||
rerender({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -220,18 +245,22 @@ describe('useFaviconBadge', () => {
|
||||
});
|
||||
|
||||
it('writes unread counts into the page title', () => {
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
const { rerender, unmount } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
favorites,
|
||||
contacts,
|
||||
currentChannels,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
favorites: Favorite[];
|
||||
}) => useUnreadTitle(unreadCounts, favorites),
|
||||
contacts: Contact[];
|
||||
currentChannels: Channel[];
|
||||
}) => useUnreadTitle(unreadCounts, contacts, currentChannels),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
contacts: [],
|
||||
currentChannels: channels,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -243,7 +272,8 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('channel', 'fav-chan')]: 4,
|
||||
[getStateKey('contact', 'dm-key')]: 2,
|
||||
},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
contacts: [],
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
expect(document.title).toBe('(4) RemoteTerm');
|
||||
|
||||
@@ -28,6 +28,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
@@ -109,6 +110,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -151,6 +153,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -241,6 +244,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ function makeContact(pubkey: string): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -55,6 +55,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface Contact {
|
||||
lon: number | null;
|
||||
last_seen: number | null;
|
||||
on_radio: boolean;
|
||||
favorite: boolean;
|
||||
last_contacted: number | null;
|
||||
last_read_at: number | null;
|
||||
first_seen: number | null;
|
||||
@@ -203,6 +204,7 @@ export interface Channel {
|
||||
flood_scope_override?: string | null;
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelMessageCounts {
|
||||
@@ -323,14 +325,8 @@ 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;
|
||||
last_message_times: Record<string, number>;
|
||||
advert_interval: number;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Favorites utilities.
|
||||
*
|
||||
* Favorites are stored server-side in the database.
|
||||
*/
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
|
||||
/**
|
||||
* Check if a conversation is favorited (from provided favorites array)
|
||||
*/
|
||||
export function isFavorite(
|
||||
favorites: Favorite[],
|
||||
type: 'channel' | 'contact',
|
||||
id: string
|
||||
): boolean {
|
||||
return favorites.some((f) => f.type === type && f.id === id);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export interface Channel {
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
favorite: boolean;
|
||||
flood_scope_override?: string | null;
|
||||
}
|
||||
|
||||
@@ -216,11 +217,8 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export type Favorite = { type: string; id: string };
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
last_message_times: Record<string, number>;
|
||||
advert_interval: number;
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
type Favorite,
|
||||
} from '../helpers/api';
|
||||
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
|
||||
|
||||
test.describe('Favorites persistence', () => {
|
||||
let originalFavorites: Favorite[] = [];
|
||||
let channelName = '';
|
||||
let channelKey = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const settings = await getSettings();
|
||||
originalFavorites = settings.favorites ?? [];
|
||||
|
||||
// Start deterministic: no favorites
|
||||
await updateSettings({ favorites: [] });
|
||||
|
||||
channelName = `#e2efav${Date.now().toString().slice(-6)}`;
|
||||
const channel = await createChannel(channelName);
|
||||
channelKey = channel.key;
|
||||
@@ -30,11 +17,6 @@ test.describe('Favorites persistence', () => {
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
try {
|
||||
await updateSettings({ favorites: originalFavorites });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test('add and remove favorite channel with persistence across reload', async ({ page }) => {
|
||||
@@ -51,8 +33,8 @@ test.describe('Favorites persistence', () => {
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
|
||||
const channels = await getChannels();
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
@@ -66,8 +48,8 @@ test.describe('Favorites persistence', () => {
|
||||
await expect(page.getByTitle('Add to favorites')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
|
||||
const channels = await getChannels();
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.toBe(false);
|
||||
await expect(page.getByText('Favorites')).not.toBeVisible();
|
||||
|
||||
+16
-16
@@ -1224,8 +1224,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 17
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1296,8 +1296,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 17
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1363,8 +1363,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1416,8 +1416,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1478,8 +1478,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1531,8 +1531,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1671,8 +1671,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1765,8 +1765,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
+16
-28
@@ -13,7 +13,6 @@ from meshcore import EventType
|
||||
from meshcore.events import Event
|
||||
|
||||
import app.radio_sync as radio_sync
|
||||
from app.models import Favorite
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
_message_poll_loop,
|
||||
@@ -363,12 +362,8 @@ class TestSyncRecentContactsToRadio:
|
||||
"""Favorite contacts not on radio are added via add_contact."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
await AppSettingsRepository.update(
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_B),
|
||||
]
|
||||
)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
await ContactRepository.set_favorite(KEY_B, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -390,9 +385,8 @@ class TestSyncRecentContactsToRadio:
|
||||
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
|
||||
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)]
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=5)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -416,10 +410,9 @@ class TestSyncRecentContactsToRadio:
|
||||
for index, key in enumerate(favorite_keys):
|
||||
await _insert_contact(key, f"Favorite{index}", last_contacted=2000 - index)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=4,
|
||||
favorites=[Favorite(type="contact", id=key) for key in favorite_keys],
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=4)
|
||||
for key in favorite_keys:
|
||||
await ContactRepository.set_favorite(key, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -498,7 +491,7 @@ class TestSyncAndOffloadAll:
|
||||
await _insert_contact(KEY_A, "Alice", last_advert=3000, contact_type=2)
|
||||
await _insert_contact(KEY_B, "Bob", last_advert=2000, contact_type=1)
|
||||
|
||||
await AppSettingsRepository.update(max_radio_contacts=1, favorites=[])
|
||||
await AppSettingsRepository.update(max_radio_contacts=1)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -519,13 +512,8 @@ class TestSyncAndOffloadAll:
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=2,
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
],
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=2)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -546,7 +534,7 @@ class TestSyncAndOffloadAll:
|
||||
async def test_skips_contacts_already_on_radio(self, test_db):
|
||||
"""Contacts already on radio are counted but not re-added."""
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=False)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
@@ -606,7 +594,7 @@ class TestSyncAndOffloadAll:
|
||||
async def test_handles_add_failure(self, test_db):
|
||||
"""Failed add_contact increments the failed counter."""
|
||||
await _insert_contact(KEY_A, "Alice")
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -632,7 +620,7 @@ class TestSyncAndOffloadAll:
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -661,7 +649,7 @@ class TestSyncAndOffloadAll:
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -686,7 +674,7 @@ class TestSyncAndOffloadAll:
|
||||
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
|
||||
"""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -726,7 +714,7 @@ class TestSyncAndOffloadAll:
|
||||
"""If _meshcore is swapped between pre-check and lock acquisition,
|
||||
the function uses the new (post-lock) instance, not the stale one."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
old_mc = MagicMock(name="old_mc")
|
||||
new_mc = MagicMock(name="new_mc")
|
||||
|
||||
@@ -620,7 +620,6 @@ class TestAppSettingsRepository:
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"max_radio_contacts": 250,
|
||||
"favorites": "{not-json",
|
||||
"auto_decrypt_dm_on_advert": 1,
|
||||
"last_message_times": "{also-not-json",
|
||||
"advert_interval": None,
|
||||
@@ -641,36 +640,10 @@ class TestAppSettingsRepository:
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
assert settings.max_radio_contacts == 250
|
||||
assert settings.favorites == []
|
||||
assert settings.last_message_times == {}
|
||||
assert settings.advert_interval == 0
|
||||
assert settings.last_advert_time == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_favorite_is_idempotent(self):
|
||||
"""Adding an existing favorite does not write duplicate entries."""
|
||||
from app.models import AppSettings, Favorite
|
||||
|
||||
existing = AppSettings(favorites=[Favorite(type="contact", id="aa" * 32)])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=existing,
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update,
|
||||
):
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
result = await AppSettingsRepository.add_favorite("contact", "aa" * 32)
|
||||
|
||||
assert result == existing
|
||||
mock_update.assert_not_awaited()
|
||||
|
||||
|
||||
class TestMessageRepositoryGetById:
|
||||
"""Test MessageRepository.get_by_id method."""
|
||||
|
||||
@@ -133,6 +133,7 @@ class TestUpdateSettings:
|
||||
class TestToggleFavorite:
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_when_not_favorited(self, test_db):
|
||||
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
with (
|
||||
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
|
||||
@@ -141,16 +142,16 @@ class TestToggleFavorite:
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert len(result.favorites) == 1
|
||||
assert result.favorites[0].type == "contact"
|
||||
assert result.favorites[0].id == "aa" * 32
|
||||
assert result.favorite is True
|
||||
assert result.type == "contact"
|
||||
assert result.id == "aa" * 32
|
||||
mock_sync.assert_called_once_with("aa" * 32, force=True)
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_when_already_favorited(self, test_db):
|
||||
# Pre-add a favorite
|
||||
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
|
||||
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
|
||||
await ContactRepository.set_favorite("aa" * 32, True)
|
||||
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
with (
|
||||
@@ -160,7 +161,7 @@ class TestToggleFavorite:
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert result.favorites == []
|
||||
assert result.favorite is False
|
||||
mock_sync.assert_not_called()
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user