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(
4040const NOW = "2026-06-02T00:00:00.000Z";
4141
4242describe("resolveThreadListV2Enabled", () => {
43 it.each(["development", "preview"])("defaults on for the %s variant", (appVariant) => {
44 expect(
45 resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true, appVariant }),
46 ).toBe(true);
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);
43 it("defaults on when the device has never chosen", () => {
44 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
45 true,
46 );
5347 });
5448
55 it("prefers an explicit device choice over the variant default", () => {
56 expect(
57 resolveThreadListV2Enabled({
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);
49 it("honors an explicit device opt-out", () => {
50 expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false);
51 expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true);
7052 });
7153
72 it("holds v1 while preferences are still loading so the list does not remount", () => {
73 expect(
74 resolveThreadListV2Enabled({
75 preference: undefined,
76 preferencesLoaded: false,
77 appVariant: "development",
78 }),
79 ).toBe(false);
54 it("holds the default while preferences are still loading so the list does not remount", () => {
55 expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe(
56 true,
57 );
8058 });
8159});
8260
apps/mobile/src/features/threads/threadListV2.ts+8-19
......@@ -19,34 +19,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
1919export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
2020
2121/**
22 * Whether Thread List v2 is on by default for an app variant. The `development`
23 * and `preview` variants are mobile's nightly equivalents and opt in;
24 * `production` stays on v1. Counterpart of web's `resolveSidebarV2Default`.
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".
22 * Thread List v2 is on by default on every app variant; the Settings → Beta
23 * toggle is an opt-out. Preferences persist as sparse patches, so `undefined`
24 * genuinely means "never chosen".
3425 *
3526 * `preferencesLoaded` guards the startup window: preferences load
36 * asynchronously, and treating "still loading" as "never chosen" would mount
37 * v2 on a development build and then flip to v1 once a stored opt-out arrives,
38 * remounting the whole list. While loading, hold v1 — the state both variants
39 * already start from.
27 * asynchronously, and rendering one list before the stored choice arrives would
28 * remount the whole thing a tick later. While loading, hold the default — that
29 * is where every device without an explicit opt-out lands anyway.
4030 */
4131export function resolveThreadListV2Enabled(input: {
4232 readonly preference: boolean | undefined;
4333 readonly preferencesLoaded: boolean;
44 readonly appVariant: unknown;
4534}): boolean {
4635 if (!input.preferencesLoaded) {
47 return false;
36 return true;
4837 }
49 return input.preference ?? resolveThreadListV2Default(input.appVariant);
38 return input.preference ?? true;
5039}
5140
5241export function resolveThreadListV2Status(
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts+2-8
......@@ -1,18 +1,13 @@
11import { useAtomValue } from "@effect/atom-react";
22import { AsyncResult } from "effect/unstable/reactivity";
3import Constants from "expo-constants";
43
54import { mobilePreferencesAtom } from "../../state/preferences";
65import { resolveThreadListV2Enabled } from "./threadListV2";
76
87/**
98 * 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 and
11 * preview, off for production). Every consumer must read through this rather
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.
9 * set one, otherwise the default (on). Every consumer must read through this
10 * rather than the raw preference, which is undefined until explicitly chosen.
1611 */
1712export function useThreadListV2Enabled(): boolean {
1813 const preferencesResult = useAtomValue(mobilePreferencesAtom);
......@@ -20,6 +15,5 @@ export function useThreadListV2Enabled(): boolean {
2015 return resolveThreadListV2Enabled({
2116 preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined,
2217 preferencesLoaded: loaded,
23 appVariant: Constants.expoConfig?.extra?.appVariant,
2418 });
2519}
apps/mobile/src/persistence/mobile-preferences.ts+3-3
......@@ -25,9 +25,9 @@ export interface Preferences {
2525 readonly projectGroupingEnabled?: boolean;
2626 /**
2727 * 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 per
29 * device. Undefined means the user has never chosen, in which case the app
30 * variant decides — see `resolveThreadListV2Enabled`.
28 * client-settings sync, so the flat v2 thread list is opted out of per
29 * device. Undefined means the user has never chosen, which resolves to on —
30 * see `resolveThreadListV2Enabled`.
3131 */
3232 readonly threadListV2Enabled?: boolean;
3333}