Skip to content

Commit 0fcb109

Browse files
authored
Merge pull request #259 from httpsphl/fix/unread-survives-restarts
fix(app): the unread mark no longer comes back after a restart
2 parents bd8c6ad + 6ec8592 commit 0fcb109

15 files changed

Lines changed: 161 additions & 10 deletions

File tree

‎app/src/features/bots/BotView.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useT } from "../../i18n";
44
import type { Bot, BotFile, Crew } from "../../lib/protocol.gen";
55
import { useWindowVisible } from "../../shell/visibility";
66
import { routinesOf } from "../../store/app";
7-
import { useApi, useApp } from "../../store/context";
7+
import { useApp } from "../../store/context";
88
import { PanelClosing, PanelRestored } from "../../ui/panelMotion";
99
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
1010
import { useStable } from "../../ui/useStable";
@@ -85,14 +85,11 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
8585
const visible = useWindowVisible();
8686
const replyAt = useApp((state) => state.replyAt[bot.id]);
8787
const markSeen = useApp((state) => state.markSeen);
88-
const api = useApi();
8988
useEffect(() => {
9089
if (visible && replyAt !== undefined) {
9190
markSeen(bot.id);
92-
// A phone connected to this computer stops marking it new (spec 28.12).
93-
api.call("chat.read", { botId: bot.id }).catch(() => {});
9491
}
95-
}, [bot.id, visible, replyAt, markSeen, api]);
92+
}, [bot.id, visible, replyAt, markSeen]);
9693
// A browser at rest is open, but the bot is not using it (spec 21.2).
9794
const browsing = useApp((state) => {
9895
const browser = state.browsers[bot.id];

‎app/src/features/crews/unread.test.tsx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,41 @@ describe("unread replies in the conversation list", () => {
6565
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread");
6666
});
6767

68+
test("what the daemon says was read stays read when the app forgot what it saw", async () => {
69+
const fake = new FakeBotloft();
70+
const crew = fake.addCrew("Ops");
71+
const scout = fake.addBot(crew.id, "Scout");
72+
const writer = fake.addBot(crew.id, "Writer");
73+
fake.setBotState(scout.id, "idle");
74+
fake.setBotState(writer.id, "idle");
75+
// Both replied at 5000; the owner read only Writer's, and the app's own
76+
// storage (cleared in beforeEach) knows nothing of it.
77+
fake.bot(scout.id).lastReplyAt = 5000;
78+
fake.bot(writer.id).lastReplyAt = 5000;
79+
fake.bot(writer.id).readThrough = 5000;
80+
renderApp(fake);
81+
await crewOpened("Ops");
82+
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread");
83+
expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle");
84+
});
85+
86+
test("seeing a chat tells the daemon, which keeps it", async () => {
87+
const fake = new FakeBotloft();
88+
const crew = fake.addCrew("Ops");
89+
const scout = fake.addBot(crew.id, "Scout");
90+
fake.setBotState(scout.id, "idle");
91+
renderApp(fake);
92+
await crewOpened("Ops");
93+
act(() => {
94+
fake.chat.reply(scout.id, "Found three sources.");
95+
});
96+
openBot("Scout");
97+
await screen.findByRole("list", { name: "Messages" });
98+
expect(
99+
fake.calls.filter((call) => call.method === "chat.read").map((call) => call.params),
100+
).toContainEqual({ botId: scout.id });
101+
});
102+
68103
test("replies from before the first start with unread marks count as seen", async () => {
69104
localStorage.removeItem("botloft.seenSince");
70105
const fake = new FakeBotloft();

‎app/src/lib/fakeBots.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
4646
workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`,
4747
lastActivity: null,
4848
lastReplyAt: null,
49+
readThrough: null,
4950
createdAt: fake.now,
5051
archivedAt: null,
5152
};

‎app/src/lib/protocol.gen.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,11 @@ lastActivity: Activity | null,
286286
* than the owner's last look (spec 15.1).
287287
*/
288288
lastReplyAt: number | null,
289+
/**
290+
* Until when the owner read the bot's replies, kept by the daemon so
291+
* the mark survives the app losing its storage; `null` if never.
292+
*/
293+
readThrough: number | null,
289294
/**
290295
* Unix time in milliseconds.
291296
*/

‎app/src/store/app.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export function createAppStore(api: BotloftApi): AppStore {
194194
set({ page, selectedCrewId: null, selectedBotId: null });
195195
}
196196
},
197-
...seenActions(get, set),
197+
...seenActions(get, set, api),
198198
setPanel: (botId, panel) => set((state) => ({ panels: { ...state.panels, [botId]: panel } })),
199199
putCrew: (crew) => set((state) => withCrew(state, crew)),
200200
putBot: (bot) => set((state) => withBot(state, bot)),

‎app/src/store/seen.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// after it marks the app's icon (`unseenFailures`).
55

66
import type { StoreApi } from "zustand/vanilla";
7+
import type { BotloftApi } from "../lib/api";
78
import type { BotId, CrewId, Routine } from "../lib/protocol.gen";
89
import type { AppState } from "./app";
910

@@ -79,12 +80,17 @@ export function withReply(
7980
export function seenActions(
8081
get: StoreApi<AppState>["getState"],
8182
set: StoreApi<AppState>["setState"],
83+
api: BotloftApi,
8284
): Pick<AppState, "markSeen" | "markUnread"> {
8385
return {
8486
markSeen: (botId) => {
8587
const { seenAt, replyAt, markedUnread } = get();
8688
const next = { ...seenAt, [botId]: Math.max(Date.now(), replyAt[botId] ?? 0) };
8789
saveSeen(next);
90+
// The daemon keeps it too: this app's own storage has gone back to an
91+
// older state before, and the mark came back. A phone connected to
92+
// this computer also stops marking the chat new (spec 28.12).
93+
api.call("chat.read", { botId }).catch(() => {});
8894
const { [botId]: wasMarked, ...marked } = markedUnread;
8995
if (wasMarked) {
9096
saveMarked(marked);
@@ -120,7 +126,8 @@ export function isUnread(state: AppState, botId: BotId): boolean {
120126
return true;
121127
}
122128
const at = state.replyAt[botId];
123-
return at !== undefined && at > Math.max(state.seenAt[botId] ?? 0, state.seenSince);
129+
const read = state.bots[botId]?.readThrough ?? 0;
130+
return at !== undefined && at > Math.max(state.seenAt[botId] ?? 0, state.seenSince, read);
124131
}
125132

126133
/** How many bots of `crewId` are unread. */
@@ -141,7 +148,8 @@ export function unseenFailures(state: AppState): Routine[] {
141148
return (
142149
last?.status === "failed" &&
143150
routine.botId !== state.selectedBotId &&
144-
(last.finishedAt ?? 0) > (state.seenAt[routine.botId] ?? 0)
151+
(last.finishedAt ?? 0) >
152+
Math.max(state.seenAt[routine.botId] ?? 0, state.bots[routine.botId]?.readThrough ?? 0)
145153
);
146154
});
147155
}

‎crates/botloft-core/src/protocol/model.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ pub struct Bot {
177177
/// before its first. The app marks the bot unread when this is newer
178178
/// than the owner's last look (spec 15.1).
179179
pub last_reply_at: Option<i64>,
180+
/// Until when the owner read the bot's replies, kept by the daemon so
181+
/// the mark survives the app losing its storage; `null` if never.
182+
pub read_through: Option<i64>,
180183
/// Unix time in milliseconds.
181184
pub created_at: i64,
182185
/// Unix time in milliseconds; `null` while the bot is active.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Until when the owner read each bot's replies (Unix ms): the unread mark
2+
-- of the conversation list survives the app losing its own storage.
3+
ALTER TABLE bots ADD COLUMN read_through INTEGER;

‎crates/botloft-store/src/chat.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,27 @@ impl Store {
178178
Ok(item.and_then(|item| activity(&item.body, item.updated_at)))
179179
}
180180

181+
/// The owner read the bot's replies up to `at`: kept, so the unread mark
182+
/// does not come back when the app forgets what it saw. Never goes back.
183+
pub fn set_read_through(&self, bot: &BotId, at: i64) -> Result<()> {
184+
self.conn.prepare_cached(
185+
"UPDATE bots SET read_through = ?2 WHERE id = ?1 AND (read_through IS NULL OR read_through < ?2)",
186+
)?
187+
.execute(params![bot.as_str(), at])?;
188+
Ok(())
189+
}
190+
191+
/// Until when the owner read the bot's replies; `None` if never.
192+
pub fn read_through(&self, bot: &BotId) -> Result<Option<i64>> {
193+
let mut stmt = self
194+
.conn
195+
.prepare_cached("SELECT read_through FROM bots WHERE id = ?1")?;
196+
Ok(stmt
197+
.query_row([bot.as_str()], |row| row.get(0))
198+
.optional()?
199+
.flatten())
200+
}
201+
181202
/// When the bot last finished a reply, for the unread mark in the
182203
/// conversation list (spec 15.1); `None` before its first.
183204
pub fn last_reply_at(&self, bot: &BotId) -> Result<Option<i64>> {

‎crates/botloft-store/src/chat/tests.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,16 @@ fn the_last_reply_is_found_behind_newer_items() {
174174
.expect("insert");
175175
assert_eq!(fx.store.last_reply_at(bot).expect("reply"), Some(9));
176176
}
177+
178+
#[test]
179+
fn what_the_owner_read_is_kept_and_never_goes_back() {
180+
let fx = Fixture::new();
181+
let bot = &fx.bots[0].id;
182+
assert_eq!(fx.store.read_through(bot).expect("none"), None);
183+
fx.store.set_read_through(bot, 50).expect("set");
184+
assert_eq!(fx.store.read_through(bot).expect("read"), Some(50));
185+
fx.store.set_read_through(bot, 20).expect("older");
186+
assert_eq!(fx.store.read_through(bot).expect("still"), Some(50));
187+
fx.store.set_read_through(bot, 90).expect("newer");
188+
assert_eq!(fx.store.read_through(bot).expect("newer"), Some(90));
189+
}

0 commit comments

Comments
 (0)