Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,15 @@ revokes all of them, and a pairing unused for 30 days expires.
Further reactions during that hold do not change the icon; history loading does not trigger it.
- **New messages** marks the first known unread incoming channel message when
you enter. Once established, the divider stays in place for that visit as you
read, reveal another message, or receive new messages. It is absent for a visit
that starts with incomplete or unverified history, even if older rows load
later. It may also be absent when unread evidence is incomplete, or when read
intent or the read evidence needed to locate the boundary changes before it is
established.
read, reveal another message, or receive new messages. In a channel with more
history than loads on entry, it appears when the oldest unread message is
among the rows loaded on entry, below a message you have read; when the unread
messages start at the first loaded row or further up, there is no divider for
that visit, even if older rows load later. The divider is settled before the
channel marks anything read, so opening a channel does not hide its own
divider. It may also be absent when unread evidence is incomplete, or when
read intent or the read evidence needed to locate the boundary changes
elsewhere before it is established.
- **Opening a channel or DM** while Buzz is visible and focused clears its sidebar row: the bold name, the mention
dot and the thread dot. Switching away from Buzz pauses this: new messages
can light the row even while that conversation stays selected. The macOS Dock dot follows the rows. Nothing is marked
Expand Down
24 changes: 24 additions & 0 deletions docs/unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,30 @@ viewport):
reader's entry evidence outlives them. A channel read covers the channel
mark and reading a message covers its receipt; retention keeps row marks
last and never treats them as override ancestry.
- The channel snapshot's `oldestUnreadId` names the oldest unread message its
timeline shows (a top-level post or broadcast reply), from the same bounded
evidence as `observedCount`: missing history can make a later message the
oldest one known. Every relay read feeds that evidence, a channel window's
page included, so `sampled` says a repair has read every channel in unread
scope since evidence was last emptied (a cache clear or capacity overflow) or
a channel entered that scope: evidence then reaches past what windows loaded,
up to each batch's 500-row limit. A cache clear or newly accessible channel
starts that repair at once; an overflow waits for the next recovery tick.
Later repairs, refreshes included, leave `sampled` set. When entry did not
capture a verified, idle window with all of its history (rows sit above it, or
it was cached, refreshing or still paging), the **New messages** divider waits
for `sampled`, then appears only at `oldestUnreadId` among the rows loaded on
entry and only below a read one. While it waits, the timeline holds its own
reading (dwell and bottom catch-up), which would otherwise read the loaded
rows and erase the evidence it waits for; the hold ends when the repair
finishes or fails, and reading then starts at once. A batch that filled its
limit can still stop short of the true oldest unread message, and the
read-row condition covers only ordinary rows: catch-up's `activity:` mark
reads all of them up to a timestamp, but never mentions, broadcasts,
broadcast replies or messages marked unread, which only their own `msg:` or
`thread:` marks read. A short sample can hide an unread one of those above
the window, and the divider then lands below it. A row above read only by
its own mark says nothing about older rows either.
- Both keys use verified event timestamps, never wall time. Neither enters generic
channel/thread inheritance or remote override baselines. Channel catch-up ends
a local manual unread on the channel itself; message and thread manual-unread
Expand Down
38 changes: 34 additions & 4 deletions src/bundled/inbox/InboxPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,15 +797,22 @@ it("retries the card's connection, activity and read-state failures", async () =
act(() => h.failSync());
expect(screen.queryByRole("alert")).not.toBeInTheDocument();

// Cleared activity is not reported as nothing to read.
await act(async () => {
await h.owner.clearCache();
// Cleared activity is not reported as nothing to read while the repair the
// clear starts observes it again, and that repair brings it back.
let clearing: Promise<void> | undefined;
act(() => {
clearing = h.owner.clearCache();
});
expect(
screen.queryByRole("list", { name: "Recent activity" }),
).not.toBeInTheDocument();
expect(screen.queryByText("No recent activity")).not.toBeInTheDocument();
expect(screen.queryByText("Nothing unread")).not.toBeInTheDocument();
await act(async () => {
await clearing;
});
await waitFor(() => expect(previewRows()).toHaveLength(2));
expect(screen.queryByText("No recent activity")).not.toBeInTheDocument();
view.unmount();

act(() => h.failConnection("relay offline"));
Expand Down Expand Up @@ -3035,8 +3042,24 @@ it("waits for roster recovery before explicit evidence refresh", async () => {

it("cache clear shows explicit refresh instead of a spinner with no pending work", async () => {
const h = fixture();
const root = h.root;
if (!root) throw new Error("Missing fixture root");
// Only the feed reads reactions; unread evidence never includes them.
h.addEvent(
signed(h.alice, {
kind: 7,
content: "❤️",
created_at: 30,
tags: [
["e", root.id],
["p", h.viewer.pubkey],
],
}),
);
const reaction = { name: /Alice reacted with heart/ };
render(h.view);
await screen.findByText("Please review this");
await screen.findByRole("button", reaction);
await waitFor(() =>
expect(
screen.queryByText("Checking recent activity…"),
Expand All @@ -3045,12 +3068,19 @@ it("cache clear shows explicit refresh instead of a spinner with no pending work
await act(async () => {
await h.owner.clearCache();
});
// The repair the clear starts brings back the mention from unread evidence
// on its own, but the cleared feed stays idle: it offers Refresh rather than
// a spinner with nothing pending.
await screen.findByText("Please review this");
expect(h.owner.session.inboxFeed.snapshot().status).toBe("idle");
expect(screen.getByText("Check recent activity.")).toBeVisible();
expect(
screen.queryByText("Checking recent activity…"),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", reaction)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("Please review this");
await screen.findByRole("button", reaction);
expect(h.owner.session.inboxFeed.snapshot().status).toBe("ready");
expect(h.retrySync).not.toHaveBeenCalled();
});

Expand Down
Loading
Loading