Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Jon Staab 2026-09-24 10:38:51 -07:00
commit 152214591a
3 changed files with 67 additions and 20 deletions

View file

@ -1715,6 +1715,8 @@ Acceptance:
drawn underneath it, and never appears as an item of its own. drawn underneath it, and never appears as an item of its own.
- Every note carries its reply count, including the ones with no replies. - Every note carries its reply count, including the ones with no replies.
- Scrolling to the end of the feed loads more rather than asking her to. - Scrolling to the end of the feed loads more rather than asking her to.
- The feed is one unbroken stretch back from now, so nothing appears below the
end of the list.
- The section fills from the relays that answer. One that takes the connection - The section fills from the relays that answer. One that takes the connection
and then says nothing does not hold it empty. and then says nothing does not hold it empty.

View file

@ -507,6 +507,56 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) =>
expect(fromSpace).toEqual([]) expect(fromSpace).toEqual([])
}) })
test("US-117 read a network feed whose relays answer from different depths", async ({seed, as}) => {
// Mirrors PAGE_SIZE: a full page covers only as far back as it reaches.
const pageSize = 100
const recent = "the harbourmaster moved the moorings again"
const deep = "the old crane was scrapped in the spring"
await seed(({relay, open, user, at}) => {
const space = relay("space")
const indexer = open("indexer")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
indexer.relayList(user.alice, {
read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url],
})
indexer.follows(user.alice, [user.bob])
// Bob's outbox holds a full page of recent notes, the indexer one week-old note.
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url, indexer.url]})
outbox.profile(user.bob, {name: "Bob Barker"})
outbox.note(user.bob, recent, at(1, MINUTE))
// More than pageSize so the page comes back full.
for (let minute = 2; minute <= pageSize + 10; minute++) {
outbox.note(user.bob, `mooring report ${minute}`, at(minute, MINUTE))
}
indexer.note(user.bob, deep, at(7, DAY))
})
const page = await as(users.alice, "/home")
const network = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})})
await expect(network.getByText(recent)).toBeVisible()
// Below the stretch the outbox covered, so it is held until the scroll.
await expect(network.getByText(deep)).toHaveCount(0)
await network.locator(".card").last().scrollIntoViewIfNeeded()
await expect(network.getByText(deep)).toBeVisible()
})
test("US-117 read the network feed when one relay never answers", async ({seed, as}) => { test("US-117 read the network feed when one relay never answers", async ({seed, as}) => {
// Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and // Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and
// then says nothing, which is all the spec asks of it. // then says nothing, which is all the spec asks of it.

View file

@ -375,11 +375,15 @@ export type FeedLoadState =
// nothing in it, and the two have to move the window differently. // nothing in it, and the two have to move the window differently.
export type FeedSpan = {found: number; complete: boolean; exhausted: boolean} export type FeedSpan = {found: number; complete: boolean; exhausted: boolean}
// The share of the relays a span asked that have to answer before it stops waiting on the rest. A // Relatively high because quiet relays eose first.
// request otherwise waits on every one of them, and a relay that accepts a socket and then says const SPAN_THRESHOLD = 0.8
// nothing neither answers nor drops. A span is also what releases the events it found, so one
// silent relay leaves the feed empty rather than slow. // Short because a relay can accept a socket and never eose.
const SPAN_THRESHOLD = 0.5 const SPAN_TIMEOUT = 3000
// Abort resolves a request with what arrived.
const spanSignal = (signal: AbortSignal) =>
AbortSignal.any([signal, AbortSignal.timeout(SPAN_TIMEOUT)])
// Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the // Empty spans to walk per trigger. Enough to cross a gap; not enough to reach the end of the
// history on a single request. // history on a single request.
@ -530,8 +534,6 @@ export const makeFeed = ({
} }
} }
// What arrives from elsewhere in the app, which is only rendered as far back as the feed has
// got to on its own
const addEvents = (newEvents: TrustedEvent[]) => { const addEvents = (newEvents: TrustedEvent[]) => {
const ready: TrustedEvent[] = [] const ready: TrustedEvent[] = []
@ -599,7 +601,7 @@ export const makeFeed = ({
const found = await network.get().request({ const found = await network.get().request({
relays, relays,
autoClose: true, autoClose: true,
signal: controller.signal, signal: spanSignal(controller.signal),
threshold: SPAN_THRESHOLD, threshold: SPAN_THRESHOLD,
filters: filters.map(filter => ({...filter, ...extension})), filters: filters.map(filter => ({...filter, ...extension})),
onEvent: countEvent, onEvent: countEvent,
@ -644,7 +646,8 @@ export const makeFeed = ({
oldest = edge === undefined ? since : Math.min(edge, until - 1) oldest = edge === undefined ? since : Math.min(edge, until - 1)
} }
insertEvents(found) // addEvents, not insertEvents: found can reach below oldest.
addEvents(found)
reach(oldest) reach(oldest)
return {found: found.length, complete, exhausted: false} return {found: found.length, complete, exhausted: false}
@ -666,20 +669,12 @@ export const makeFeed = ({
newest = until newest = until
} }
insertEvents(found) addEvents(found)
return {found: found.length, complete, exhausted: false} return {found: found.length, complete, exhausted: false}
} }
// What the repository already holds for these relays is in hand and goes in as one insert, addEvents(relays.flatMap(url => Array.from(getEventsForUrl(url, filters))))
// which takes the window back with it rather than leaving the rest of that stretch behind
const cached = relays.flatMap(url => Array.from(getEventsForUrl(url, filters)))
for (const event of cached) {
reached = Math.min(reached, event.created_at)
}
insertEvents(cached)
return { return {
events, events,
@ -775,7 +770,7 @@ export const makeCalendarFeed = ({
const found = await network.get().request({ const found = await network.get().request({
relays, relays,
autoClose: true, autoClose: true,
signal: controller.signal, signal: spanSignal(controller.signal),
threshold: SPAN_THRESHOLD, threshold: SPAN_THRESHOLD,
filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}], filters: [{kinds: [EVENT_TIME], "#D": daysBetween(since, until).map(String)}],
onEose: () => { onEose: () => {