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
85 changes: 62 additions & 23 deletions docs/unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,42 @@ and supply evidence before their rows become observable.

Reusable `ChannelTimeline` and `ThreadPanel` own the standard observation policy:
focused active reading surface, visible document, settled positioning, fully
visible rows, and 750 ms dwell. Scroll/content/focus changes cancel/restart dwell.
Mounted virtualizer overscan, preload, selection, and composer focus are not
reading. Automatic observations mark **individual messages**, never a prefix that
could hide unseen siblings. Oversized rows that never fit fully are not auto-read.
visible rows, and 300 ms dwell. Scroll/content/focus changes cancel/restart dwell. Owners explicitly wake the
scheduler when positioning finishes, even if an empty history page changes no
rows or geometry.
The list and its own composer share a reading surface; focus anywhere in an open
thread panel also qualifies, including its selected owning tab header. Inactive
or hidden tab content and passive restoration do not qualify. Another pane, a
dialog, background window, preload,
and mounted virtualizer overscan do not qualify.

Away from the bottom, observations mark individual fully visible messages. At the
physical bottom, 300 ms visibility of the newest message's bottom edge also earns
catch-up (including a message taller than the viewport):

- `activity:<channel>` uses the newest retained verified event timestamp at dwell
completion, including replies newer than the visible top-level head. It reads
ordinary top-level backlog and quiets ordinary reply activity in the channel
aggregate. It never acknowledges
the replies themselves: direct thread/message selectors retain them. Mentions,
broadcasts, DMs, manual intent and currently known participating-thread activity
are not quieted. Missing retained participation cannot erase a reply's unread
state; later participation evidence can restore the sidebar attention dot.
- `thread-activity:<root>` acknowledges replies through the newest reply only in
that thread. A collapsed newest reply, or one outside the bottom viewport in a
branch-ordered thread, cannot earn catch-up; visible rows still read individually.
- Both keys use verified event timestamps, never wall time. Neither enters generic
channel/thread inheritance or remote override baselines. Automatic catch-up does
not clear manual-unread intent and uses the same cancellable reading lease.

This intentionally relaxes the old individual-row-only policy for ordinary
backlog and the thread being read, without reading unopened threads' replies.

- `observedCount` is `null` when unknown or denied, never a fabricated zero.
Otherwise it counts the bounded evidence currently known, excluding own messages,
auxiliary events and authorized deletions. It is **not an exact total or lower
auxiliary events and authorized deletions. The channel aggregate additionally
excludes ordinary replies quieted by its activity cutoff; direct thread and
message selectors still report those replies as unread. It is **not an exact total or lower
bound**: missing markers/deletions can overcount; missing history can undercount.
- `coverage` and `freshness` describe message evidence, separately from `sync()`.
Evidence is capped at 4,096 events / 8 MiB. Repair queries the membership roster
Expand All @@ -65,17 +93,23 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea
- `markThrough(target, messageId)` is explicit prefix intent through verified
evidence. It can mark unloaded earlier messages read; do not use it for viewport
observation. A channel prefix requires a top-level message, not a reply.
- `markChannelRead(channelId)` snapshots the newest retained verified message
(including replies) when invoked, then atomically advances the channel frontier
and clears the channel's owned local manual-unread marks. It does not fetch
history, select the row, or substitute the wall clock for message evidence.
Arrivals beyond that timestamp remain unread; like other timestamp prefixes,
this also covers messages at or before the cut that arrive later.
With no message evidence, it clears only the channel's local mark and invents
no frontier. Success means local durability; publication may still be pending.
- `markAllChannelsRead()` runs `markChannelRead` one channel at a time over the
accessible listed channels that still show unread evidence or a local mark, so
an already-read community costs no writes. One failing channel does not stop
- `markChannelRead(channelId)` captures the greater of invocation-time integer
seconds and the newest retained verified message timestamp (including replies),
then atomically advances the explicit channel frontier and clears the channel's
owned local manual-unread marks. It does not fetch history or select the row.
Pre-click history arriving later stays read; events beyond the cutoff remain
unread. Same-second arrivals are covered too. An ahead-of-time device clock is
sticky because frontier merges take the maximum: correcting that clock does not
rewind the cutoff. Automatic reading never uses this clock-based action.
Empty evidence still saves the click cutoff on frontier-capable hosts; other
hosts retain local-only clearing for the empty case. Success means local
durability; publication may still be pending.
- `markAllChannelsRead()` snapshots selected channel cutoffs/evidence at sweep
invocation and reserves each channel's mutation order before newer manual
actions. It saves one channel at a time, so a slow first write cannot
acknowledge post-click activity in a later channel. It selects accessible
listed channels with retained unread evidence or a local mark, including replies
quieted in the channel aggregate, so an already-read community costs no writes. One failing channel does not stop
the sweep; the first failure is rethrown afterwards. A channel whose grant is
revoked before its turn is skipped, not failed; like a grant that arrives
mid-sweep, it waits for the next explicit action. The community rail's
Expand All @@ -87,9 +121,10 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea
and retries pending publication. `ReadMutationResult.durability === "saved"`
means the local transaction committed, not that the relay accepted it.

The sidebar separates ordinary unread from directed attention. Any unread state,
including activity that exists only in a relevant thread, strengthens the channel
label. Ordinary unread renders no row marker. DMs, mentions, broadcasts, and
The sidebar separates ordinary unread from directed attention. Ordinary backlog
or local manual intent strengthens the channel label; protected attention alone
retains its dot without bolding a caught-up channel. DMs retain unread bolding.
Ordinary unread renders no row marker. DMs, mentions, broadcasts, and
relevant thread replies add one accent dot; non-DM row numerals are omitted and DM
avatars are reserved for promoted offscreen cues. Thread activity reuses that dot:
its hover/focus/click popover groups unread replies by canonical thread root and
Expand Down Expand Up @@ -124,8 +159,8 @@ thread selector has observed unread replies or explicit thread-unread intent.
Accessible names distinguish observed evidence, stale evidence and local-only
intent; unknown/observed-zero omit the dot, not assert complete read history.
Each mounted button subscribes to its own thread, without fetching thread history.
Opening/hovering a button does not acknowledge replies; the existing focused
viewport dwell in `ThreadPanel` supplies individual-message reading intent.
Hovering a button does not acknowledge replies. Opening the panel qualifies as
reading while focus remains in it; viewport/bottom dwell supplies the intent.
Unread ancestry uses the same canonical marked-reference parser as thread opening
and row projection (case-insensitive hex, last valid marker wins). Resolution still
requires bounded, retained same-channel message evidence; references alone do not
Expand All @@ -136,9 +171,10 @@ grant access or trigger a read.
| Intent | Durable frontier | Local manual-unread clears |
| --- | --- | --- |
| Automatic visible dwell | Individual verified message | None |
| Automatic bottom dwell | Ordinary channel activity or that thread’s replies through verified evidence | None |
| `markThrough(target, messageId)` | Explicit verified target prefix | That target only |
| `markChannelRead(channelId)` | Channel through newest retained verified message, including replies | Channel, retained messages, verified same-channel reply roots, and threads whose top-level root is retained |
| Channel read with no evidence | None | Channel only |
| `markChannelRead(channelId)` | Channel through max(click time, newest retained verified message) | Channel, retained messages, verified same-channel reply roots, and threads whose top-level root is retained |
| Channel read with no evidence | Click time on frontier-capable hosts; none otherwise | Channel only |
| Mute/Unmute | None | None |

Channel read does not clear other channels, unproven ancestry, or remote manual
Expand Down Expand Up @@ -178,6 +214,9 @@ The local state has a 96 KiB serialized-blob budget and wire publication a 40 Ki
plaintext budget. Persisted local interaction order prioritizes newly read old
history as well as current traffic. Only frontier-only hints can be pruned; older
messages may look unread again. No synthetic channel prefix is introduced to fit.
The automatic activity keys share these bounded-hint limits. Older clients can
preserve/republish them but do not interpret their catch-up meaning; mixed-version
sidebar behavior is not identical. No storage migration is required.
Override groups, permanent clear floors, directly associated frontiers and possible
inherited channel/thread frontiers are protected; capacity failure is visible,
never floor truncation. Publication of any override-bearing state is deliberately
Expand Down
22 changes: 20 additions & 2 deletions patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,33 @@ between two wrap widths.

The momentum-interruption predicate requires MacIntel and Apple vendor, excluding
Virtua's iOS detector (including desktop-mode iPad). Chrome/Firefox, non-Mac WebKit
and iOS keep existing momentum policy. Store/layout/observer timing and imperative
smooth/instant scheduling remain stock. Scheduler-driven reveal/restore/bottom navigation is a
and iOS keep existing momentum policy. Store/layout timing and imperative smooth/instant target calculation remain stock.
The cancellation boundary below retires superseded targets. Scheduler-driven
reveal/restore/bottom navigation is a
separate acceptance path, not implicitly repaired by the automatic-correction fix.
Native reveal controls showed one/two transient blank interior source frames
before immediate recovery, despite valid sampled DOM coverage. This remaining
imperative-path flicker is not the sustained automatic-correction failure; the
patch does not claim to fix it.
The stale source-map directive is removed because the generated map is unpatched.

## Newer reader input cancels retained navigation

Virtua retains imperative targets for late measurements for 150 ms. After a jump
to latest, new upward input could detach the reader, but a row measurement inside
that window replayed the old target and pulled them back down. The element driver
now invokes its existing cancellation closure on wheel, touchmove, keydown and
pointerdown, matching ChannelTimeline's reader-intent events. Capture listeners
retire the old command before a descendant handler can issue new navigation;
no input is prevented and automatic resize compensation is unchanged. Disposal
also cancels retained work and removes the listeners.

Installed-driver tests keep the clock inside the pending command's lifetime,
including a replay already queued as a microtask, and verify a fresh navigation
still responds to measurements. The browser navigation case exercises real wheel
input and late row growth, asserting position as well as arrival count. These are
not native momentum/compositor acceptance, and do not change the limits above.

## Automated checks

```sh
Expand Down
26 changes: 16 additions & 10 deletions patches/virtua@0.51.0.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/lib/index.js b/lib/index.js
index a183cc7b..44e2d963 100644
index a183cc7b1669aab25467840540ce3bd1c025fb47..d960e2ea2a9435296756a64fe3a3773b429ab081 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -6,6 +6,10 @@ import { jsx as c } from "react/jsx-runtime";
Expand Down Expand Up @@ -86,17 +86,24 @@ index a183cc7b..44e2d963 100644
behavior: t ? "smooth" : "instant"
});
}), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, h = C(t => {
@@ -266,7 +310,8 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
@@ -263,10 +307,15 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
}
o.length && e.W(3, o);
});
+ // New reader input supersedes retained imperative targets. Capture runs
+ // before a button/key handler can issue the next intentional navigation.
+ const inputEvents = ["wheel", "touchmove", "keydown", "pointerdown"];
return {
D(f, u = f.parentElement) {
+ for (const type of inputEvents) u.addEventListener(type, a, { capture: true, passive: true });
h.A(n = u), t && (i = "rtl" === getComputedStyle(u).direction), o = M(e, u, t, () => T(u[s], i), (t, n, o) => {
- if (o) {
+ if (isMacWebKit()) interruptMomentum();
+ else if (o) {
const e = u.style, t = e[c];
e[c] = "hidden", p(() => {
e[c] = t;
@@ -274,7 +319,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
@@ -274,7 +323,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
}
const r = e.T() + t;
r <= 0 || r >= e.O() + e.t() - e.o() ? u.scrollTo({
Expand All @@ -105,22 +112,21 @@ index a183cc7b..44e2d963 100644
behavior: "instant"
}) : u.scrollBy({
[l]: T(t, i),
@@ -283,7 +328,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
@@ -283,7 +332,9 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
}), r[1](!0);
},
_() {
- h.X(), o && o.X(), r[1](!1), r = $();
+ a();
+ for (const type of inputEvents) n && n.removeEventListener(type, a, true);
+ restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $();
},
P: (e, t) => (d.set(e, t), h.A(e), () => {
d.delete(e), h.B(e);
@@ -796,5 +841,4 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
});
@@ -797,4 +848,3 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
});
-
+
-export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
\ No newline at end of file
-//# sourceMappingURL=index.js.map
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/bundled/channels/ChannelSidebarRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
max-width: 100%;
flex: 0 1 auto;
}
.row:has([data-channel-unread], [data-channel-activity]) .label {
.row:has([data-channel-ordinary="true"]) .label {
color: var(--text-navigation-unread);
font-weight: var(--type-weight-semibold);
}
Expand Down
8 changes: 8 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,13 @@ function ChannelWorkspace({
showingThread?.navigation,
setThread,
]);
// Retargeting the retained thread is a new opening, not a passive rerender.
const threadChannelId = showingThread?.channelId;
const threadMessageId = showingThread?.messageId;
const threadInstance = useMemo(
() => ({ queries, scope, threadChannelId, threadMessageId }),
[queries, scope, threadChannelId, threadMessageId],
);
type Opening = PanelOpening;
const opened = entries.find(
(entry) => panelTabId(entry) === tabState.selected,
Expand Down Expand Up @@ -1642,6 +1649,7 @@ function ChannelWorkspace({
? [
{
id: "thread",
instance: threadInstance,
label: "Thread",
icon: (
<ChatCircleIcon
Expand Down
7 changes: 6 additions & 1 deletion src/bundled/channels/UnreadBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export function UnreadBadge({
const count = snapshot.observedCount;
const manual = snapshot.manual !== "none";
const unread = manual || (count ?? 0) > 0;
// Bold tracks ordinary backlog; protected attention has its own dot.
const ordinary =
manual ||
(dm ? (count ?? 0) > 0 : (count ?? 0) > (snapshot.attentionCount ?? 0));
const threadCount = activity.items?.length ?? 0;
if (!unread && !threadCount && label === undefined) return null;
const priority = dm || (snapshot.attentionCount ?? 0) > 0;
Expand All @@ -61,12 +65,13 @@ export function UnreadBadge({
return (
<>
{label !== undefined && (
<span data-channel-unread-title={unread || undefined}>{label}</span>
<span data-channel-unread-title={ordinary || undefined}>{label}</span>
)}
{unread && (
<span
className={styles.unreadState}
data-channel-unread=""
data-channel-ordinary={ordinary || undefined}
data-priority={priority}
role="img"
aria-label={unreadLabel}
Expand Down
6 changes: 5 additions & 1 deletion src/features/messages/ChannelTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import type { ChannelMessage, ChannelWindow } from "../relay/contracts";
// metrics reproduce the event ordering measured separately in Chromium/WebKit.
// Reading geometry/dwell has its own real-hook boundary suite. This fixture
// deliberately supplies only the DOM shape needed for positioning.
vi.mock("./use-reading", () => ({ useReading: vi.fn() }));
vi.mock("./use-reading", () => ({
useReading: vi.fn(),
readingPositioned: vi.fn(),
}));
const hooks = vi.hoisted(() => ({
refs: [] as { current: unknown }[],
states: [] as unknown[],
Expand Down Expand Up @@ -1154,6 +1157,7 @@ it("wires the shared reading hook to its owned scroller and settled position", (
expect(useReading).toHaveBeenCalledWith({
session: expect.any(Object),
channelId: "channel",
latestMessageId: expect.any(String),
scroller: expect.objectContaining({ current: expect.anything() }),
settled: expect.objectContaining({ current: expect.any(Boolean) }),
});
Expand Down
Loading
Loading