Skip to content
Closed
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
32 changes: 20 additions & 12 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2335,16 +2335,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// header height back or the material toggles a full header too late.
reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
// LegendList recomputes its inset-aware end distance before invoking
// this handler, so getState() is current. Only the actual end re-arms
// follow: its broader maintain-scroll threshold is large enough for a
// streaming chunk to pull a user back before their upward drag escapes.
// A live user-scroll session still wins even if the first scroll event
// remains inside LegendList's at-end tolerance.
// this handler, so getState() is current. Follow re-arms at the actual
// end or anywhere inside LegendList's maintain-at-end tolerance: a
// swipe back to the live edge usually rests a few pixels short of the
// exact end, in space the end inset covers, and the strict test would
// leave the scroll-to-end control stuck visible. A live user-scroll
// session still wins even if the first scroll event remains inside
// LegendList's at-end tolerance.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "scroll",
isAtEnd: listState.isAtEnd,
nearEnd: listState.isWithinMaintainScrollAtEndThreshold,
userScrollSessionActive: userScrollSessionRef.current,
});
}
Expand All @@ -2365,16 +2368,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
transitionEndFollow({ type: "user-scroll-begin" });
}, [clearUserScrollSettle, transitionEndFollow]);
const finishUserScroll = useCallback(
(releaseIsAtEnd?: boolean) => {
(releaseAtEnd?: boolean) => {
clearUserScrollSettle();
const userScrollSessionActive = userScrollSessionRef.current;
userScrollSessionRef.current = false;
// With no momentum, preserve the finger-release position (at or within
// the re-arm tolerance of the live edge). Streaming growth during the
// native momentum-detection window must not turn a release at the live
// edge into an opt-out from follow.
const listState = props.listRef.current?.getState();
transitionEndFollow({
type: "user-scroll-end",
// With no momentum, preserve the finger-release position. Streaming
// growth during the native momentum-detection window must not turn a
// release at the live edge into an opt-out from follow.
isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false,
isAtEnd: releaseAtEnd ?? listState?.isAtEnd ?? false,
nearEnd: listState?.isWithinMaintainScrollAtEndThreshold ?? false,
userScrollSessionActive,
});
},
Expand All @@ -2387,8 +2393,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// mirrors the native-event handoff used by the home thread list's scroll gate.
const handleScrollEndDrag = useCallback(() => {
clearUserScrollSettle();
const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false;
userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160);
const listState = props.listRef.current?.getState();
const releaseAtEnd =
(listState?.isAtEnd ?? false) || (listState?.isWithinMaintainScrollAtEndThreshold ?? false);
userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseAtEnd), 160);
}, [clearUserScrollSettle, finishUserScroll, props.listRef]);
const handleMomentumScrollBegin = useCallback(() => {
if (userScrollSessionRef.current) {
Expand Down
33 changes: 29 additions & 4 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ describe("resolveThreadFeedLiveFollow", () => {
resolveThreadFeedLiveFollow(false, {
type: "scroll",
isAtEnd: false,
nearEnd: false,
userScrollSessionActive: true,
}),
).toBe(false);
Expand All @@ -164,6 +165,7 @@ describe("resolveThreadFeedLiveFollow", () => {
resolveThreadFeedLiveFollow(true, {
type: "scroll",
isAtEnd: false,
nearEnd: false,
userScrollSessionActive: false,
}),
).toBe(true);
Expand All @@ -174,16 +176,36 @@ describe("resolveThreadFeedLiveFollow", () => {
resolveThreadFeedLiveFollow(false, {
type: "scroll",
isAtEnd: true,
nearEnd: true,
userScrollSessionActive: true,
}),
).toBe(false);
});

it("re-arms within the maintain-at-end tolerance once the user scroll session ends", () => {
expect(
resolveThreadFeedLiveFollow(false, {
type: "scroll",
isAtEnd: false,
nearEnd: true,
userScrollSessionActive: false,
}),
).toBe(true);
expect(
resolveThreadFeedLiveFollow(false, {
type: "user-scroll-end",
isAtEnd: false,
nearEnd: true,
userScrollSessionActive: true,
}),
).toBe(true);
});

it.each([
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
{ isAtEnd: false, nearEnd: false, userScrollSessionActive: false, expected: false },
{ isAtEnd: true, nearEnd: true, userScrollSessionActive: false, expected: true },
{ isAtEnd: false, nearEnd: false, userScrollSessionActive: true, expected: false },
{ isAtEnd: true, nearEnd: true, userScrollSessionActive: true, expected: false },
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
expected,
Expand All @@ -195,13 +217,15 @@ describe("resolveThreadFeedLiveFollow", () => {
resolveThreadFeedLiveFollow(false, {
type: "user-scroll-end",
isAtEnd: true,
nearEnd: true,
userScrollSessionActive: true,
}),
).toBe(true);
expect(
resolveThreadFeedLiveFollow(false, {
type: "user-scroll-end",
isAtEnd: false,
nearEnd: false,
userScrollSessionActive: true,
}),
).toBe(false);
Expand All @@ -212,6 +236,7 @@ describe("resolveThreadFeedLiveFollow", () => {
resolveThreadFeedLiveFollow(true, {
type: "user-scroll-end",
isAtEnd: false,
nearEnd: false,
userScrollSessionActive: false,
}),
).toBe(true);
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/features/threads/thread-feed-live-follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,22 @@ export type ThreadFeedLiveFollowEvent =
| {
readonly type: "user-scroll-end";
readonly isAtEnd: boolean;
// LegendList's isWithinMaintainScrollAtEndThreshold: inside the
// maintain-at-end tolerance. Re-arming accepts it because a swipe back
// to the live edge usually rests a few pixels short of the exact end,
// in space the end inset covers; the tolerance only ever re-arms
// follow, never breaks it.
readonly nearEnd: boolean;
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "scroll" | "disclosure-settled";
readonly type: "scroll";
readonly isAtEnd: boolean;
readonly nearEnd: boolean;
readonly userScrollSessionActive: boolean;
}
| {
readonly type: "disclosure-settled";
readonly isAtEnd: boolean;
readonly userScrollSessionActive: boolean;
};
Expand Down Expand Up @@ -75,14 +87,14 @@ export function resolveThreadFeedLiveFollow(
case "user-scroll-begin":
return false;
case "user-scroll-end":
return event.userScrollSessionActive ? event.isAtEnd : current;
return event.userScrollSessionActive ? event.isAtEnd || event.nearEnd : current;
case "disclosure-settled":
return !event.userScrollSessionActive && event.isAtEnd;
case "scroll":
if (event.userScrollSessionActive) {
return false;
}
if (event.isAtEnd) {
if (event.isAtEnd || event.nearEnd) {
return true;
}
return current;
Expand Down
Loading