authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-28 10:48:31+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 10:48:31+02:00
logb0c4992c79605ec310ed9fee6b60fad6e145def4
treea36260284c9401f4a4885dc7ac46a73f7413bb78
parentdd5ea324885aea4980a49bb42d28f83023c40ea7
signature Signed by PGP key B5690EEEBB952194

Make mobile Thread List v2 the default (#4717)


4 files changed, 24 insertions(+), 63 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+11-33
...@@ -40,43 +40,21 @@ function makeThread(...@@ -40,43 +40,21 @@ function makeThread(
40const NOW = "2026-06-02T00:00:00.000Z";40const NOW = "2026-06-02T00:00:00.000Z";
4141
42describe("resolveThreadListV2Enabled", () => {42describe("resolveThreadListV2Enabled", () => {
43 it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => {43 it("defaults on when the device has never chosen", () => {
44 expect(44 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
45 resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),45 true,
46 ).toBe(true);46 );
47 });
48
49 it.each(["production", undefined])("defaults off for the %s variant", (appVariant) => {
50 expect(
51 resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
52 ).toBe(false);
53 });47 });
5448
55 it("prefers an explicit device choice over the variant default", () => {49 it("honors an explicit device opt-out", () => {
56 expect(50 expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false);
57 resolveThreadListV2Enabled({51 expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true);
58 preference: false,
59 preferencesLoaded: true,
60 appVariant: "preview",
61 }),
62 ).toBe(false);
63 expect(
64 resolveThreadListV2Enabled({
65 preference: true,
66 preferencesLoaded: true,
67 appVariant: "production",
68 }),
69 ).toBe(true);
70 });52 });
7153
72 it("holds v1 while preferences are still loading so the list does not remount", () => {54 it("holds the default while preferences are still loading so the list does not remount", () => {
73 expect(55 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe(
74 resolveThreadListV2Enabled({56 true,
75 preference: undefined,57 );
76 preferencesLoaded: false,
77 appVariant: "development",
78 }),
79 ).toBe(false);
80 });58 });
81});59});
8260
apps/mobile/src/features/threads/threadListV2.ts+8-19
...@@ -19,34 +19,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;...@@ -19,34 +19,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
19export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;19export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
2020
21/**21/**
22 * Whether Thread List v2 is on by default for an app variant. The `development`22 * Thread List v2 is on by default on every app variant; the Settings → Beta
23 * and `preview` variants are mobile's nightly equivalents and opt in;23 * toggle is an opt-out. Preferences persist as sparse patches, so `undefined`
24 * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`.24 * genuinely means "never chosen".
25 */
26export function resolveThreadListV2Default(appVariant: unknown): boolean {
27 return appVariant === "development" || appVariant === "preview";
28}
29
30/**
31 * Resolved Thread List v2 state: the device-local preference if the user has
32 * set one, otherwise the default for this app variant. Preferences persist as
33 * sparse patches, so `undefined` genuinely means "never chosen".
34 *25 *
35 * `preferencesLoaded` guards the startup window: preferences load26 * `preferencesLoaded` guards the startup window: preferences load
36 * asynchronously, and treating "still loading" as "never chosen" would mount27 * asynchronously, and rendering one list before the stored choice arrives would
37 * v2 on a development build and then flip to v1 once a stored opt-out arrives,28 * remount the whole thing a tick later. While loading, hold the default — that
38 * remounting the whole list. While loading, hold v1 — the state both variants29 * is where every device without an explicit opt-out lands anyway.
39 * already start from.
40 */30 */
41export function resolveThreadListV2Enabled(input: {31export function resolveThreadListV2Enabled(input: {
42 readonly preference: boolean | undefined;32 readonly preference: boolean | undefined;
43 readonly preferencesLoaded: boolean;33 readonly preferencesLoaded: boolean;
44 readonly appVariant: unknown;
45}): boolean {34}): boolean {
46 if (!input.preferencesLoaded) {35 if (!input.preferencesLoaded) {
47 return false;36 return true;
48 }37 }
49 return input.preference ?? resolveThreadListV2Default(input.appVariant);38 return input.preference ?? true;
50}39}
5140
52export function resolveThreadListV2Status(41export function resolveThreadListV2Status(
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts+2-8
...@@ -1,18 +1,13 @@...@@ -1,18 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";2import { AsyncResult } from "effect/unstable/reactivity";
3import Constants from "expo-constants";
43
5import { mobilePreferencesAtom } from "../../state/preferences";4import { mobilePreferencesAtom } from "../../state/preferences";
6import { resolveThreadListV2Enabled } from "./threadListV2";5import { resolveThreadListV2Enabled } from "./threadListV2";
76
8/**7/**
9 * Resolved Thread List v2 state: the device-local preference if the user has8 * Resolved Thread List v2 state: the device-local preference if the user has
10 * set one, otherwise the default for this app variant (on for development and9 * set one, otherwise the default (on). Every consumer must read through this
11 * preview, off for production). Every consumer must read through this rather10 * rather than the raw preference, which is undefined until explicitly chosen.
12 * than the raw preference, which is undefined until explicitly chosen.
13 *
14 * Kept out of `state/preferences.ts` so that module stays importable from node
15 * test environments, which have no `__DEV__` for expo-constants.
16 */11 */
17export function useThreadListV2Enabled(): boolean {12export function useThreadListV2Enabled(): boolean {
18 const preferencesResult = useAtomValue(mobilePreferencesAtom);13 const preferencesResult = useAtomValue(mobilePreferencesAtom);
...@@ -20,6 +15,5 @@ export function useThreadListV2Enabled(): boolean {...@@ -20,6 +15,5 @@ export function useThreadListV2Enabled(): boolean {
20 return resolveThreadListV2Enabled({15 return resolveThreadListV2Enabled({
21 preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,16 preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
22 preferencesLoaded: loaded,17 preferencesLoaded: loaded,
23 appVariant: Constants.expoConfig?.extra?.appVariant,
24 });18 });
25}19}
apps/mobile/src/persistence/mobile-preferences.ts+3-3
...@@ -25,9 +25,9 @@ export interface Preferences {...@@ -25,9 +25,9 @@ export interface Preferences {
25 readonly projectGroupingEnabled?: boolean;25 readonly projectGroupingEnabled?: boolean;
26 /**26 /**
27 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no27 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
28 * client-settings sync, so the flat v2 thread list is opted into per28 * client-settings sync, so the flat v2 thread list is opted out of per
29 * device. Undefined means the user has never chosen, in which case the app29 * device. Undefined means the user has never chosen, which resolves to on —
30 * variant decides — see `resolveThreadListV2Enabled`.30 * see `resolveThreadListV2Enabled`.
31 */31 */
32 readonly threadListV2Enabled?: boolean;32 readonly threadListV2Enabled?: boolean;
33}33}