Fix up unread bugs

This commit is contained in:
Jack Kingsman
2026-03-12 22:00:18 -07:00
parent 276e0e09b3
commit 22ca5410ee
12 changed files with 62 additions and 7 deletions
+2 -2
View File
@@ -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.
+3
View File
@@ -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):
+23 -1
View File
@@ -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
+3 -4
View File
@@ -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;
+6
View File
@@ -15,6 +15,7 @@ interface UseUnreadCountsResult {
/** Tracks which conversations have unread messages that mention the user */
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
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<Record<string, number>>({});
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
const [unreadLastReadAts, setUnreadLastReadAts] = useState<Record<string, number | null>>({});
// 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,
+2
View File
@@ -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,
+2
View File
@@ -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(),
@@ -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(),
}),
+13
View File
@@ -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' };
+1
View File
@@ -419,6 +419,7 @@ export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
last_read_ats: Record<string, number | null>;
}
interface BusyChannel {
+1
View File
@@ -175,6 +175,7 @@ export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
last_read_ats: Record<string, number | null>;
}
export function getUnreads(): Promise<UnreadCounts> {
+4
View File
@@ -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):