Compare commits

..

6 Commits

Author SHA1 Message Date
Jack Kingsman 3b7e2737ee Updating changelog + build for 3.11.3 2026-04-12 23:54:44 -07:00
Jack Kingsman 01158ac69f Add screenshots and icons for webmanifest 2026-04-12 23:51:13 -07:00
Jack Kingsman 485df05372 Modify radio contact fill logic to use sent OR received messages as recency queue for loadin selection after favorites 2026-04-12 23:45:43 -07:00
Jack Kingsman e5e9eab935 Updating changelog + build for 3.11.2 2026-04-12 22:44:46 -07:00
Jack Kingsman 33b2d3c260 Unread DMs are ALWAYS at the top. Closes #185. 2026-04-12 22:41:41 -07:00
Jack Kingsman eccbd0bac5 use-credentials on webmanifest fetches so basic auth behaves. Closes #182. 2026-04-12 22:36:08 -07:00
14 changed files with 246 additions and 18 deletions
+10
View File
@@ -1,3 +1,13 @@
## [3.11.3] - 2026-04-12
* Bugfix: Add icons and screenshots for webmanifest
* Bugfix: Use incoming DMs, not just outgoing, for recency ranking for preferential radio contact load
## [3.11.2] - 2026-04-12
* Feature: Unread DMs are always at the top of the DM list no matter what
* Bugfix: Webmanifest needs withCredentials
## [3.11.1] - 2026-04-12 ## [3.11.1] - 2026-04-12
* Feature: Home Assistant MQTT fanout * Feature: Home Assistant MQTT fanout
+33
View File
@@ -148,6 +148,39 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
"type": "image/png", "type": "image/png",
"purpose": "maskable", "purpose": "maskable",
}, },
{
"src": f"{base}favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any",
},
{
"src": f"{base}favicon-256x256.png",
"sizes": "256x256",
"type": "image/png",
"purpose": "any",
},
],
"screenshots": [
{
"src": f"{base}screenshot-wide.png",
"sizes": "1367x909",
"type": "image/png",
"form_factor": "wide",
"label": "RemoteTerm desktop view",
},
{
"src": f"{base}screenshot-mobile.png",
"sizes": "1170x2532",
"type": "image/png",
"label": "RemoteTerm mobile view",
},
{
"src": f"{base}screenshot-mobile-2.png",
"sizes": "750x1334",
"type": "image/png",
"label": "RemoteTerm mobile conversation",
},
], ],
} }
return JSONResponse( return JSONResponse(
+12 -6
View File
@@ -1295,7 +1295,13 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]: async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now.""" """Return the contacts that would be loaded onto the radio right now.
Fill order:
1. Favorites (up to full capacity)
2. Most recently DM-active non-repeaters (sent or received, up to 80% refill target)
3. Most recently advertised non-repeaters (up to 80% refill target)
"""
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts) max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts) refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
@@ -1315,7 +1321,7 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
break break
if len(selected_contacts) < refill_target: if len(selected_contacts) < refill_target:
for contact in await ContactRepository.get_recently_contacted_non_repeaters( for contact in await ContactRepository.get_recently_dm_active_non_repeaters(
limit=max_contacts limit=max_contacts
): ):
key = contact.public_key.lower() key = contact.public_key.lower()
@@ -1354,8 +1360,8 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
Fill order is: Fill order is:
1. Favorite contacts 1. Favorite contacts
2. Most recently interacted-with non-repeaters 2. Most recently DM-active non-repeaters (sent or received)
3. Most recently advert-heard non-repeaters without interaction history 3. Most recently advert-heard non-repeaters
Favorite contacts are always reloaded first, up to the configured capacity. Favorite contacts are always reloaded first, up to the configured capacity.
Additional non-favorite fill stops at the refill target (80% of capacity). Additional non-favorite fill stops at the refill target (80% of capacity).
@@ -1489,8 +1495,8 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
""" """
Load contacts to the radio for DM ACK support. Load contacts to the radio for DM ACK support.
Fill order is favorites, then recently contacted non-repeaters, Fill order is favorites, then recently DM-active non-repeaters (sent or
then recently advert-heard non-repeaters. Favorites are always reloaded received), then recently advert-heard non-repeaters. Favorites are always reloaded
up to the configured capacity; additional non-favorite fill stops at the up to the configured capacity; additional non-favorite fill stops at the
80% refill target. 80% refill target.
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced. Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
+22
View File
@@ -294,6 +294,28 @@ class ContactRepository:
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows] return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recently_dm_active_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get non-repeater contacts with the most recent DM activity (sent or received)."""
cursor = await db.conn.execute(
"""
SELECT c.*
FROM contacts c
INNER JOIN (
SELECT conversation_key, MAX(received_at) AS last_dm
FROM messages
WHERE type = 'PRIV'
GROUP BY conversation_key
) m ON c.public_key = m.conversation_key
WHERE c.type != 2 AND length(c.public_key) = 64
ORDER BY m.last_dm DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod @staticmethod
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]: async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently advert-heard non-repeater contacts.""" """Get recently advert-heard non-repeater contacts."""
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="./favicon.ico" /> <link rel="shortcut icon" href="./favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
<link rel="manifest" href="./site.webmanifest" /> <link rel="manifest" href="./site.webmanifest" crossorigin="use-credentials" />
<script> <script>
// Register minimal service worker for PWA installability. // Register minimal service worker for PWA installability.
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"private": true, "private": true,
"version": "3.11.1", "version": "3.11.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+7 -1
View File
@@ -265,6 +265,12 @@ export function Sidebar({
const sortContactsByOrder = useCallback( const sortContactsByOrder = useCallback(
(items: Contact[], order: SortOrder) => (items: Contact[], order: SortOrder) =>
[...items].sort((a, b) => { [...items].sort((a, b) => {
// Unread DM contacts always float to the top
const unreadA = unreadCounts[getStateKey('contact', a.public_key)] || 0;
const unreadB = unreadCounts[getStateKey('contact', b.public_key)] || 0;
if (unreadA > 0 && unreadB === 0) return -1;
if (unreadA === 0 && unreadB > 0) return 1;
if (order === 'recent') { if (order === 'recent') {
const timeA = getContactRecentTime(a); const timeA = getContactRecentTime(a);
const timeB = getContactRecentTime(b); const timeB = getContactRecentTime(b);
@@ -274,7 +280,7 @@ export function Sidebar({
} }
return (a.name || a.public_key).localeCompare(b.name || b.public_key); return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}), }),
[getContactRecentTime] [getContactRecentTime, unreadCounts]
); );
const sortRepeatersByOrder = useCallback( const sortRepeatersByOrder = useCallback(
+36
View File
@@ -513,6 +513,42 @@ describe('Sidebar section summaries', () => {
expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']); expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']);
}); });
it('floats contacts with unread DMs above read contacts regardless of recency', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const readRecent = makeContact('11'.repeat(32), 'Read Recent', 1, { last_advert: 500 });
const unreadOld = makeContact('22'.repeat(32), 'Unread Old', 1, { last_advert: 100 });
render(
<Sidebar
contacts={[readRecent, unreadOld]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', readRecent.public_key)]: 500,
[getStateKey('contact', unreadOld.public_key)]: 200,
}}
unreadCounts={{
[getStateKey('contact', unreadOld.public_key)]: 3,
}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
/>
);
const contactRows = screen
.getAllByText(/^(Read Recent|Unread Old)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
// Unread Old has unread DMs so it floats above Read Recent despite older recency
expect(contactRows).toEqual(['Unread Old', 'Read Recent']);
});
it('sorts repeaters by heard recency even when message times disagree', () => { it('sorts repeaters by heard recency even when message times disagree', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public'); const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const staleMessageRelay = makeContact( const staleMessageRelay = makeContact(
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.11.1" version = "3.11.3"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks" description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+122 -7
View File
@@ -377,14 +377,22 @@ class TestSyncRecentContactsToRadio:
assert result["loaded"] == 2 assert result["loaded"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db): async def test_fills_remaining_slots_with_dm_active_then_advertised(self, test_db):
"""Fill order is favorites, then recent contacts, then recent adverts.""" """Fill order is favorites, then DM-active contacts, then recent adverts."""
await _insert_contact(KEY_A, "Alice", last_contacted=100) await _insert_contact(KEY_A, "Alice")
await _insert_contact(KEY_B, "Bob", last_contacted=2000) await _insert_contact(KEY_B, "Bob")
await _insert_contact("cc" * 32, "Carol", last_contacted=1000) await _insert_contact("cc" * 32, "Carol")
await _insert_contact("dd" * 32, "Dave", last_advert=3000) await _insert_contact("dd" * 32, "Dave", last_advert=3000)
await _insert_contact("ee" * 32, "Eve", last_advert=2500) await _insert_contact("ee" * 32, "Eve", last_advert=2500)
# Create DM activity for Alice (oldest), Bob (most recent), Carol (middle)
for key, ts in [(KEY_A, 100), (KEY_B, 2000), ("cc" * 32, 1000)]:
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', ?)",
(key, ts),
)
await test_db.conn.commit()
await AppSettingsRepository.update(max_radio_contacts=5) await AppSettingsRepository.update(max_radio_contacts=5)
await ContactRepository.set_favorite(KEY_A, True) await ContactRepository.set_favorite(KEY_A, True)
@@ -401,6 +409,7 @@ class TestSyncRecentContactsToRadio:
loaded_keys = [ loaded_keys = [
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
] ]
# Alice (favorite), then Bob & Carol (DM-active, most recent first), then Dave (advert)
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32] assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -509,8 +518,15 @@ class TestSyncAndOffloadAll:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_duplicate_favorite_not_loaded_twice(self, test_db): async def test_duplicate_favorite_not_loaded_twice(self, test_db):
"""Duplicate favorite entries still load the contact only once.""" """Duplicate favorite entries still load the contact only once."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000) await _insert_contact(KEY_A, "Alice")
await _insert_contact(KEY_B, "Bob", last_contacted=1000) await _insert_contact(KEY_B, "Bob")
# Bob has DM activity so he appears in tier 2
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hi', 1000)",
(KEY_B,),
)
await test_db.conn.commit()
await AppSettingsRepository.update(max_radio_contacts=2) await AppSettingsRepository.update(max_radio_contacts=2)
await ContactRepository.set_favorite(KEY_A, True) await ContactRepository.set_favorite(KEY_A, True)
@@ -1862,3 +1878,102 @@ class TestCollectRepeaterTelemetryLpp:
await _collect_repeater_telemetry(mc, contact) await _collect_repeater_telemetry(mc, contact)
assert "lpp_sensors" not in recorded_data assert "lpp_sensors" not in recorded_data
# ---------------------------------------------------------------------------
# get_contacts_selected_for_radio_sync — DM-active prioritization
# ---------------------------------------------------------------------------
class TestContactSelectionDmActive:
"""Verify that tier 2 prioritizes contacts with recent DM activity."""
@pytest.mark.asyncio
async def test_incoming_dm_contact_selected_over_advert_only(self, test_db):
"""A contact who sent us a DM should be prioritized over one who only advertised."""
from app.radio_sync import get_contacts_selected_for_radio_sync
# Create two non-repeater contacts
dm_sender_key = "aa" * 32
advert_only_key = "bb" * 32
await test_db.conn.execute(
"INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 100, 100)",
(dm_sender_key, "DM Sender"),
)
await test_db.conn.execute(
"INSERT INTO contacts (public_key, name, type, last_seen, last_advert) VALUES (?, ?, 1, 200, 200)",
(advert_only_key, "Advert Only"),
)
# DM Sender sent us a message (incoming DM)
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'hello', 300)",
(dm_sender_key,),
)
await test_db.conn.commit()
with patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
):
selected = await get_contacts_selected_for_radio_sync()
keys = [c.public_key for c in selected]
assert dm_sender_key in keys
assert advert_only_key in keys
# DM Sender should come before Advert Only (tier 2 before tier 3)
assert keys.index(dm_sender_key) < keys.index(advert_only_key)
@pytest.mark.asyncio
async def test_outgoing_dm_contact_also_selected(self, test_db):
"""A contact we sent a DM to should also appear via DM-active tier."""
from app.radio_sync import get_contacts_selected_for_radio_sync
contact_key = "cc" * 32
await test_db.conn.execute(
"INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 1)",
(contact_key, "Outgoing Target"),
)
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES ('PRIV', ?, 'hey', 300, 1)",
(contact_key,),
)
await test_db.conn.commit()
with patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
):
selected = await get_contacts_selected_for_radio_sync()
keys = [c.public_key for c in selected]
assert contact_key in keys
@pytest.mark.asyncio
async def test_repeaters_excluded_from_dm_active_tier(self, test_db):
"""Repeater contacts should not appear in tier 2 even with DM activity."""
from app.radio_sync import get_contacts_selected_for_radio_sync
repeater_key = "dd" * 32
await test_db.conn.execute(
"INSERT INTO contacts (public_key, name, type) VALUES (?, ?, 2)",
(repeater_key, "Repeater"),
)
await test_db.conn.execute(
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES ('PRIV', ?, 'cmd', 300)",
(repeater_key,),
)
await test_db.conn.commit()
with patch(
"app.radio_sync.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=MagicMock(max_radio_contacts=200, tracked_telemetry_repeaters=[]),
):
selected = await get_contacts_selected_for_radio_sync()
keys = [c.public_key for c in selected]
assert repeater_key not in keys
Generated
+1 -1
View File
@@ -983,7 +983,7 @@ wheels = [
[[package]] [[package]]
name = "remoteterm-meshcore" name = "remoteterm-meshcore"
version = "3.11.1" version = "3.11.3"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiomqtt" }, { name = "aiomqtt" },