diff --git a/README.md b/README.md index 95ba617..a141f1c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you * Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side) * Access your radio remotely over your network or VPN * Search for hashtag room names for channels you don't have keys for yet -* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only) -* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app +* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc. +* Use the more recent 1.14 firmwares which support multibyte pathing * Visualize the mesh as a map or node set, view repeater stats, and more! **Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app. diff --git a/app/models.py b/app/models.py index a8f1cea..b53b548 100644 --- a/app/models.py +++ b/app/models.py @@ -559,6 +559,9 @@ class UnreadCounts(BaseModel): last_message_times: dict[str, int] = Field( default_factory=dict, description="Map of stateKey -> last message timestamp" ) + last_read_ats: dict[str, int | None] = Field( + default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary" + ) class AppSettings(BaseModel): diff --git a/app/repository/messages.py b/app/repository/messages.py index 6aaec09..23b4a85 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -579,11 +579,12 @@ class MessageRepository: blocked_names: Display names whose messages should be excluded from counts. Returns: - Dict with 'counts', 'mentions', and 'last_message_times' keys. + Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys. """ counts: dict[str, int] = {} mention_flags: dict[str, bool] = {} last_message_times: dict[str, int] = {} + last_read_ats: dict[str, int | None] = {} mention_token = f"@[{name}]" if name else None @@ -661,6 +662,26 @@ class MessageRepository: if mention_token and row["has_mention"]: mention_flags[state_key] = True + cursor = await db.conn.execute( + """ + SELECT key, last_read_at + FROM channels + """ + ) + rows = await cursor.fetchall() + for row in rows: + last_read_ats[f"channel-{row['key']}"] = row["last_read_at"] + + cursor = await db.conn.execute( + """ + SELECT public_key, last_read_at + FROM contacts + """ + ) + rows = await cursor.fetchall() + for row in rows: + last_read_ats[f"contact-{row['public_key']}"] = row["last_read_at"] + # Last message times for all conversations (including read ones), # excluding blocked incoming traffic so refresh matches live WS behavior. last_time_filters: list[str] = [] @@ -727,6 +748,7 @@ class MessageRepository: "counts": counts, "mentions": mention_flags, "last_message_times": last_message_times, + "last_read_ats": last_read_ats, } @staticmethod diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 344fd53..ffeabc2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -237,6 +237,7 @@ export function App() { unreadCounts, mentions, lastMessageTimes, + unreadLastReadAts, incrementUnread, renameConversationState, markAllRead, @@ -260,14 +261,12 @@ export function App() { if (activeChannelUnreadCount <= 0) { return null; } - - const activeChannel = channels.find((channel) => channel.key === activeChannelId); return { channelId: activeChannelId, - lastReadAt: activeChannel?.last_read_at ?? null, + lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null, }; }); - }, [activeConversation, channels, unreadCounts]); + }, [activeConversation, unreadCounts, unreadLastReadAts]); useEffect(() => { lastUnreadBackfillAttemptRef.current = null; diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 94e4259..398ae7b 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -15,6 +15,7 @@ interface UseUnreadCountsResult { /** Tracks which conversations have unread messages that mention the user */ mentions: Record; lastMessageTimes: ConversationTimes; + unreadLastReadAts: Record; incrementUnread: (stateKey: string, hasMention?: boolean) => void; renameConversationState: (oldStateKey: string, newStateKey: string) => void; markAllRead: () => void; @@ -30,6 +31,7 @@ export function useUnreadCounts( const [unreadCounts, setUnreadCounts] = useState>({}); const [mentions, setMentions] = useState>({}); const [lastMessageTimes, setLastMessageTimes] = useState(getLastMessageTimes); + const [unreadLastReadAts, setUnreadLastReadAts] = useState>({}); // Track active conversation via ref so applyUnreads can filter without // destabilizing the callback chain (avoids re-creating fetchUnreads on @@ -62,6 +64,8 @@ export function useUnreadCounts( setMentions(data.mentions); } + setUnreadLastReadAts(data.last_read_ats); + if (Object.keys(data.last_message_times).length > 0) { for (const [key, ts] of Object.entries(data.last_message_times)) { setLastMessageTime(key, ts); @@ -200,6 +204,7 @@ export function useUnreadCounts( // Update local state immediately setUnreadCounts({}); setMentions({}); + setUnreadLastReadAts({}); // Persist to server with single bulk request api.markAllRead().catch((err) => { @@ -227,6 +232,7 @@ export function useUnreadCounts( unreadCounts, mentions, lastMessageTimes, + unreadLastReadAts, incrementUnread, renameConversationState, markAllRead, diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 313e05c..804f0cf 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -77,7 +77,9 @@ vi.mock('../hooks', async (importOriginal) => { unreadCounts: {}, mentions: {}, lastMessageTimes: {}, + unreadLastReadAts: {}, incrementUnread: mocks.hookFns.incrementUnread, + renameConversationState: vi.fn(), markAllRead: mocks.hookFns.markAllRead, trackNewMessage: mocks.hookFns.trackNewMessage, refreshUnreads: mocks.hookFns.refreshUnreads, diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx index b144dd5..7d0a42a 100644 --- a/frontend/src/test/appSearchJump.test.tsx +++ b/frontend/src/test/appSearchJump.test.tsx @@ -51,7 +51,9 @@ vi.mock('../hooks', async (importOriginal) => { unreadCounts: {}, mentions: {}, lastMessageTimes: {}, + unreadLastReadAts: {}, incrementUnread: vi.fn(), + renameConversationState: vi.fn(), markAllRead: vi.fn(), trackNewMessage: vi.fn(), refreshUnreads: vi.fn(), diff --git a/frontend/src/test/appStartupHash.test.tsx b/frontend/src/test/appStartupHash.test.tsx index bbbb07a..9e9a932 100644 --- a/frontend/src/test/appStartupHash.test.tsx +++ b/frontend/src/test/appStartupHash.test.tsx @@ -40,7 +40,9 @@ vi.mock('../hooks', async (importOriginal) => { unreadCounts: {}, mentions: {}, lastMessageTimes: {}, + unreadLastReadAts: {}, incrementUnread: vi.fn(), + renameConversationState: vi.fn(), markAllRead: vi.fn(), trackNewMessage: vi.fn(), }), diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts index c936608..127122b 100644 --- a/frontend/src/test/useUnreadCounts.test.ts +++ b/frontend/src/test/useUnreadCounts.test.ts @@ -80,6 +80,7 @@ describe('useUnreadCounts', () => { counts: {}, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' }); mocks.markContactRead.mockResolvedValue({ status: 'ok', public_key: '' }); @@ -110,6 +111,7 @@ describe('useUnreadCounts', () => { counts: { [`channel-${CHANNEL_KEY}`]: 5 }, mentions: { [`channel-${CHANNEL_KEY}`]: true }, last_message_times: {}, + last_read_ats: { [`channel-${CHANNEL_KEY}`]: 1234 }, }); const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' }; @@ -123,6 +125,7 @@ describe('useUnreadCounts', () => { // The active conversation should NOT have unreads expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined(); expect(result.current.mentions[`channel-${CHANNEL_KEY}`]).toBeUndefined(); + expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(1234); }); it('filters out active contact conversation from server unreads', async () => { @@ -133,6 +136,7 @@ describe('useUnreadCounts', () => { counts: { [`contact-${CONTACT_KEY}`]: 3 }, mentions: {}, last_message_times: {}, + last_read_ats: { [`contact-${CONTACT_KEY}`]: 2345 }, }); const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' }; @@ -143,6 +147,7 @@ describe('useUnreadCounts', () => { }); expect(result.current.unreadCounts[`contact-${CONTACT_KEY}`]).toBeUndefined(); + expect(result.current.unreadLastReadAts[`contact-${CONTACT_KEY}`]).toBe(2345); }); it('preserves unreads for non-active conversations', async () => { @@ -157,6 +162,7 @@ describe('useUnreadCounts', () => { }, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Active' }; @@ -205,6 +211,7 @@ describe('useUnreadCounts', () => { counts: {}, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); const { result } = renderWith({ channels, activeConversation: activeConv }); @@ -218,6 +225,7 @@ describe('useUnreadCounts', () => { counts: { [`channel-${CHANNEL_KEY}`]: 7 }, mentions: {}, last_message_times: {}, + last_read_ats: { [`channel-${CHANNEL_KEY}`]: 3456 }, }); await act(async () => { @@ -226,6 +234,7 @@ describe('useUnreadCounts', () => { // Should still be filtered out expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined(); + expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(3456); }); it('re-fetches when channels change while contacts remain empty', async () => { @@ -243,6 +252,7 @@ describe('useUnreadCounts', () => { counts: { [`channel-${addedChannelKey}`]: 2 }, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); rerender({ @@ -271,6 +281,7 @@ describe('useUnreadCounts', () => { counts: { [`contact-${addedContactKey}`]: 1 }, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); rerender({ @@ -290,6 +301,7 @@ describe('useUnreadCounts', () => { counts: { [`channel-${CHANNEL_KEY}`]: 5 }, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); const { result } = renderWith({}); @@ -307,6 +319,7 @@ describe('useUnreadCounts', () => { counts: { [`channel-${CHANNEL_KEY}`]: 5 }, mentions: {}, last_message_times: {}, + last_read_ats: {}, }); const activeConv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 280ced5..603d979 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -419,6 +419,7 @@ export interface UnreadCounts { counts: Record; mentions: Record; last_message_times: Record; + last_read_ats: Record; } interface BusyChannel { diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 1c41506..f5319a6 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -175,6 +175,7 @@ export interface UnreadCounts { counts: Record; mentions: Record; last_message_times: Record; + last_read_ats: Record; } export function getUnreads(): Promise { diff --git a/tests/test_api.py b/tests/test_api.py index c3dd3c6..2f2cbe0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -583,6 +583,8 @@ class TestReadStateEndpoints: # Last message times should include all conversations assert result["last_message_times"][f"channel-{chan_key}"] == 1003 assert result["last_message_times"][f"contact-{contact_key}"] == 1005 + assert result["last_read_ats"][f"channel-{chan_key}"] == 1000 + assert result["last_read_ats"][f"contact-{contact_key}"] == 1000 @pytest.mark.asyncio async def test_get_unreads_no_name_skips_mentions(self, test_db): @@ -630,6 +632,7 @@ class TestReadStateEndpoints: data = response.json() assert data["counts"][f"channel-{chan_key}"] == 1 assert data["mentions"][f"channel-{chan_key}"] is True + assert data["last_read_ats"][f"channel-{chan_key}"] == 0 @pytest.mark.asyncio async def test_unreads_endpoint_no_radio_skips_mentions(self, test_db, client): @@ -655,6 +658,7 @@ class TestReadStateEndpoints: data = response.json() assert data["counts"][f"channel-{chan_key}"] == 1 assert len(data["mentions"]) == 0 + assert data["last_read_ats"][f"channel-{chan_key}"] == 0 @pytest.mark.asyncio async def test_unreads_reset_after_mark_read(self, test_db):