| author | |
| committer | |
| log | b0c4992c79605ec310ed9fee6b60fad6e145def4 |
| tree | a36260284c9401f4a4885dc7ac46a73f7413bb78 |
| parent | dd5ea324885aea4980a49bb42d28f83023c40ea7 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 24 insertions(+), 63 deletions(-)
apps/mobile/src/features/threads/threadListV2.test.ts+11-33| ... | ... | @@ -40,43 +40,21 @@ function makeThread( |
| 40 | 40 | const NOW = "2026-06-02T00:00:00.000Z"; |
| 41 | 41 | |
| 42 | 42 | describe("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 | ); | |
| 53 | 47 | }); |
| 54 | 48 | |
| 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); | |
| 70 | 52 | }); |
| 71 | 53 | |
| 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 | ); | |
| 80 | 58 | }); |
| 81 | 59 | }); |
| 82 | 60 |
apps/mobile/src/features/threads/threadListV2.ts+8-19| ... | ... | @@ -19,34 +19,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; |
| 19 | 19 | export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; |
| 20 | 20 | |
| 21 | 21 | /** |
| 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 | */ | |
| 26 | export 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". | |
| 34 | 25 | * |
| 35 | 26 | * `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. | |
| 40 | 30 | */ |
| 41 | 31 | export function resolveThreadListV2Enabled(input: { |
| 42 | 32 | readonly preference: boolean | undefined; |
| 43 | 33 | readonly preferencesLoaded: boolean; |
| 44 | readonly appVariant: unknown; | |
| 45 | 34 | }): boolean { |
| 46 | 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 | } |
| 51 | 40 | |
| 52 | 41 | export function resolveThreadListV2Status( |
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts+2-8| ... | ... | @@ -1,18 +1,13 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 3 | import Constants from "expo-constants"; | |
| 4 | 3 | |
| 5 | 4 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 6 | 5 | import { resolveThreadListV2Enabled } from "./threadListV2"; |
| 7 | 6 | |
| 8 | 7 | /** |
| 9 | 8 | * 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. | |
| 16 | 11 | */ |
| 17 | 12 | export function useThreadListV2Enabled(): boolean { |
| 18 | 13 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| ... | ... | @@ -20,6 +15,5 @@ export function useThreadListV2Enabled(): boolean { |
| 20 | 15 | return resolveThreadListV2Enabled({ |
| 21 | 16 | preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, |
| 22 | 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 | 25 | readonly projectGroupingEnabled?: boolean; |
| 26 | 26 | /** |
| 27 | 27 | * 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`. | |
| 31 | 31 | */ |
| 32 | 32 | readonly threadListV2Enabled?: boolean; |
| 33 | 33 | } |