From a5801718ad7e218c0f38ca18d37e5dcc6bd99da8 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:43:08 +0200 Subject: [PATCH] feat(identity): persist ownership filters and add relation sub-filter Remember Mine/Theirs across mobile restarts via device preferences, and let Mine/Theirs refine by created, participated, or both (default). --- apps/mobile/src/features/home/HomeHeader.tsx | 46 +++++- .../src/features/home/HomeRouteScreen.tsx | 11 +- .../home/home-list-filter-menu.test.ts | 132 +++++++++++------- .../features/home/home-list-filter-menu.ts | 39 ++++-- .../features/home/home-list-options.test.ts | 2 + .../src/features/home/home-list-options.ts | 110 ++++++++++++++- .../identity/ownershipFilterSurface.test.ts | 10 ++ .../layout/AdaptiveWorkspaceLayout.tsx | 41 +++++- .../threads/ThreadNavigationSidebar.tsx | 15 +- .../src/persistence/mobile-preferences.ts | 51 +++++++ apps/web/src/components/SidebarV2.tsx | 57 +++++++- .../client-runtime/src/state/identity.test.ts | 79 +++++++++++ packages/client-runtime/src/state/identity.ts | 53 +++++-- 13 files changed, 561 insertions(+), 85 deletions(-) diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e93f844784a..b16d971b2d2 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -28,7 +28,12 @@ import { } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, + OWNERSHIP_FILTER_LABELS, + OWNERSHIP_FILTERS, + OWNERSHIP_RELATION_LABELS, + OWNERSHIP_RELATIONS, type OwnershipFilter, + type OwnershipRelation, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; @@ -58,6 +63,7 @@ export function HomeHeader(props: { readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; readonly ownershipFilter: OwnershipFilter; + readonly ownershipRelation: OwnershipRelation; /** * Hide settled from the main Threads inbox. Recency/none default on; * project grouping defaults off at the call site. @@ -72,6 +78,7 @@ export function HomeHeader(props: { readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; + readonly onOwnershipRelationChange: (relation: OwnershipRelation) => void; readonly onHideSettledThreadsChange: (hide: boolean) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -110,6 +117,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || props.ownershipFilter !== "any" || + props.ownershipRelation !== "both" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -118,6 +126,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, @@ -147,15 +156,25 @@ function AndroidHomeHeader(props: HomeHeaderProps) { { id: "ownership", title: "Ownership", - subactions: [ - { id: "ownership:any", title: "Anyone" }, - { id: "ownership:mine", title: "Mine" }, - { id: "ownership:theirs", title: "Theirs" }, - ].map((action) => ({ - ...action, - state: checkedMenuState(action.id === `ownership:${props.ownershipFilter}`), + subactions: OWNERSHIP_FILTERS.map((value) => ({ + id: `ownership:${value}`, + title: OWNERSHIP_FILTER_LABELS[value], + state: checkedMenuState(value === props.ownershipFilter), })), }, + ...(props.ownershipFilter === "mine" || props.ownershipFilter === "theirs" + ? ([ + { + id: "ownership-relation", + title: props.ownershipFilter === "mine" ? "Mine includes" : "Theirs includes", + subactions: OWNERSHIP_RELATIONS.map((value) => ({ + id: `ownership-relation:${value}`, + title: OWNERSHIP_RELATION_LABELS[value], + state: checkedMenuState(value === props.ownershipRelation), + })), + }, + ] satisfies MenuAction[]) + : []), ...(props.projects.length === 0 || props.listMode === "board" ? [] : ([ @@ -223,6 +242,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.hideSettledThreads, props.listMode, props.ownershipFilter, + props.ownershipRelation, props.projectSortOrder, props.projects, props.selectedEnvironmentIds, @@ -250,6 +270,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id.startsWith("ownership-relation:")) { + const relation = id.slice("ownership-relation:".length); + if (relation === "created" || relation === "participated" || relation === "both") { + props.onOwnershipRelationChange(relation); + } + return; + } + if (id.startsWith("ownership:")) { const ownership = id.slice("ownership:".length); if (ownership === "any" || ownership === "mine" || ownership === "theirs") { @@ -421,6 +449,7 @@ function IosHomeHeader(props: HomeHeaderProps) { const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || props.ownershipFilter !== "any" || + props.ownershipRelation !== "both" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -429,6 +458,7 @@ function IosHomeHeader(props: HomeHeaderProps) { hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, @@ -446,12 +476,14 @@ function IosHomeHeader(props: HomeHeaderProps) { selectedEnvironmentIds: props.selectedEnvironmentIds, selectedProjectKey: props.selectedProjectKey, ownershipFilter: props.ownershipFilter, + ownershipRelation: props.ownershipRelation, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, onClearEnvironments: props.onClearEnvironments, onToggleEnvironment: props.onToggleEnvironment, onProjectChange: props.onProjectChange, onOwnershipFilterChange: props.onOwnershipFilterChange, + onOwnershipRelationChange: props.onOwnershipRelationChange, onProjectSortOrderChange: props.onProjectSortOrderChange, onThreadSortOrderChange: props.onThreadSortOrderChange, listOrganization, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 4d21201524b..2b721a65544 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -71,6 +71,7 @@ export function HomeRouteScreen() { toggleSelectedEnvironmentId, clearSelectedEnvironments, setOwnershipFilter, + setOwnershipRelation, setListMode, setThreadGrouping, setProjectSortOrder, @@ -91,9 +92,15 @@ export function HomeRouteScreen() { (participant) => participant.personId, ), mode: listOptions.ownershipFilter, + relation: listOptions.ownershipRelation, }), ), - [claimPersonIdByEnvironment, listOptions.ownershipFilter, threads], + [ + claimPersonIdByEnvironment, + listOptions.ownershipFilter, + listOptions.ownershipRelation, + threads, + ], ); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -175,6 +182,7 @@ export function HomeRouteScreen() { selectedEnvironmentIds={selectedEnvironmentIds} selectedProjectKey={selectedProjectKey} ownershipFilter={listOptions.ownershipFilter} + ownershipRelation={listOptions.ownershipRelation} hideSettledThreads={hideSettledThreads} projectSortOrder={listOptions.projectSortOrder} threadSortOrder={listOptions.threadSortOrder} @@ -184,6 +192,7 @@ export function HomeRouteScreen() { onToggleEnvironment={toggleSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOwnershipFilterChange={setOwnershipFilter} + onOwnershipRelationChange={setOwnershipRelation} onHideSettledThreadsChange={setHideSettledThreads} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index ee14ad15663..3f65be020c6 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -2,27 +2,42 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; +function baseProps( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + environments: [], + projects: [], + selectedEnvironmentIds: [], + selectedProjectKey: null, + ownershipFilter: "any", + ownershipRelation: "both", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + onClearEnvironments: vi.fn(), + onToggleEnvironment: vi.fn(), + onProjectChange: vi.fn(), + onOwnershipFilterChange: vi.fn(), + onOwnershipRelationChange: vi.fn(), + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + ...overrides, + }; +} + describe("buildHomeListFilterMenu", () => { it("adds a project scope submenu that selects and clears the same scope as the chips", () => { const onProjectChange = vi.fn(); - const menu = buildHomeListFilterMenu({ - environments: [], - projects: [ - { key: "environment-1:project-1", label: "Codething" }, - { key: "environment-1:project-2", label: "Website" }, - ], - selectedEnvironmentIds: [], - selectedProjectKey: "environment-1:project-1", - ownershipFilter: "any", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - onClearEnvironments: vi.fn(), - onToggleEnvironment: vi.fn(), - onProjectChange, - onOwnershipFilterChange: vi.fn(), - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), - }); + const menu = buildHomeListFilterMenu( + baseProps({ + projects: [ + { key: "environment-1:project-1", label: "Codething" }, + { key: "environment-1:project-2", label: "Website" }, + ], + selectedProjectKey: "environment-1:project-1", + onProjectChange, + }), + ); const projectMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Project", @@ -47,24 +62,17 @@ describe("buildHomeListFilterMenu", () => { it("supports multi-select environment toggles", () => { const onToggleEnvironment = vi.fn(); const onClearEnvironments = vi.fn(); - const menu = buildHomeListFilterMenu({ - environments: [ - { environmentId: "env-1" as never, label: "Smart" }, - { environmentId: "env-2" as never, label: "t3vm" }, - ], - projects: [], - selectedEnvironmentIds: ["env-1" as never], - selectedProjectKey: null, - ownershipFilter: "any", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - onClearEnvironments, - onToggleEnvironment, - onProjectChange: vi.fn(), - onOwnershipFilterChange: vi.fn(), - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), - }); + const menu = buildHomeListFilterMenu( + baseProps({ + environments: [ + { environmentId: "env-1" as never, label: "Smart" }, + { environmentId: "env-2" as never, label: "t3vm" }, + ], + selectedEnvironmentIds: ["env-1" as never], + onClearEnvironments, + onToggleEnvironment, + }), + ); const environmentMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Environment", @@ -86,21 +94,12 @@ describe("buildHomeListFilterMenu", () => { it("offers Anyone, Mine, and Theirs ownership filters", () => { const onOwnershipFilterChange = vi.fn(); - const menu = buildHomeListFilterMenu({ - environments: [], - projects: [], - selectedEnvironmentIds: [], - selectedProjectKey: null, - ownershipFilter: "mine", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - onClearEnvironments: vi.fn(), - onToggleEnvironment: vi.fn(), - onProjectChange: vi.fn(), - onOwnershipFilterChange, - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), - }); + const menu = buildHomeListFilterMenu( + baseProps({ + ownershipFilter: "mine", + onOwnershipFilterChange, + }), + ); const ownershipMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Ownership", @@ -117,4 +116,35 @@ describe("buildHomeListFilterMenu", () => { ownershipMenu.items[2]?.onPress(); expect(onOwnershipFilterChange).toHaveBeenCalledWith("theirs"); }); + + it("offers created / participated / both sub-filters when Mine or Theirs is selected", () => { + const onOwnershipRelationChange = vi.fn(); + const menu = buildHomeListFilterMenu( + baseProps({ + ownershipFilter: "mine", + ownershipRelation: "created", + onOwnershipRelationChange, + }), + ); + + const relationMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Mine includes", + ); + expect(relationMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "Created or participated", state: "off" }, + { title: "Created", state: "on" }, + { title: "Participated", state: "off" }, + ], + }); + if (relationMenu?.type !== "submenu") throw new Error("Expected relation submenu"); + relationMenu.items[2]?.onPress(); + expect(onOwnershipRelationChange).toHaveBeenCalledWith("participated"); + + const anyoneMenu = buildHomeListFilterMenu(baseProps({ ownershipFilter: "any" })); + expect( + anyoneMenu.items.some((item) => item.type === "submenu" && item.title === "Mine includes"), + ).toBe(false); + }); }); diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index ca0c6d9f92a..0e6f56d0ed8 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -7,8 +7,16 @@ import { type HomeThreadGrouping, } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; -import type { OwnershipFilter } from "./home-list-options"; +import { + OWNERSHIP_FILTER_LABELS, + OWNERSHIP_FILTERS, + OWNERSHIP_RELATION_LABELS, + OWNERSHIP_RELATIONS, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, + type OwnershipFilter, + type OwnershipRelation, +} from "./home-list-options"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -45,12 +53,14 @@ export function buildHomeListFilterMenu(props: { readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; readonly ownershipFilter: OwnershipFilter; + readonly ownershipRelation: OwnershipRelation; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; + readonly onOwnershipRelationChange: (relation: OwnershipRelation) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; /** @@ -99,18 +109,27 @@ export function buildHomeListFilterMenu(props: { items.push({ type: "submenu", title: "Ownership", - items: [ - { value: "any", label: "Anyone" }, - { value: "mine", label: "Mine" }, - { value: "theirs", label: "Theirs" }, - ].map((option) => ({ + items: OWNERSHIP_FILTERS.map((value) => ({ type: "action" as const, - title: option.label, - state: props.ownershipFilter === option.value ? ("on" as const) : ("off" as const), - onPress: () => props.onOwnershipFilterChange(option.value as OwnershipFilter), + title: OWNERSHIP_FILTER_LABELS[value], + state: props.ownershipFilter === value ? ("on" as const) : ("off" as const), + onPress: () => props.onOwnershipFilterChange(value), })), }); + if (props.ownershipFilter === "mine" || props.ownershipFilter === "theirs") { + items.push({ + type: "submenu", + title: props.ownershipFilter === "mine" ? "Mine includes" : "Theirs includes", + items: OWNERSHIP_RELATIONS.map((value) => ({ + type: "action" as const, + title: OWNERSHIP_RELATION_LABELS[value], + state: props.ownershipRelation === value ? ("on" as const) : ("off" as const), + onPress: () => props.onOwnershipRelationChange(value), + })), + }); + } + if (props.showProjectFilter !== false && props.projects.length > 0) { items.push({ type: "submenu", diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index e594c4735c3..7466630a76d 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -9,6 +9,7 @@ import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-opti const defaults: HomeListOptions = { selectedEnvironmentIds: [], ownershipFilter: "any", + ownershipRelation: "both", listMode: "threads", threadGrouping: "project", projectSortOrder: @@ -38,6 +39,7 @@ describe("home list options", () => { it("marks ownership filters as customized", () => { expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "mine" })).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "theirs" })).toBe(true); + expect(hasCustomHomeListOptions({ ...defaults, ownershipRelation: "created" })).toBe(true); }); it("marks non-default thread grouping as customized", () => { diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 99d4e94da98..1a8153945ef 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -7,6 +7,10 @@ import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; +import { + DEFAULT_OWNERSHIP_RELATION, + type OwnershipRelation, +} from "@t3tools/client-runtime/state/identity"; import { createContext, createElement, @@ -30,6 +34,9 @@ import { } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; +export type { OwnershipRelation }; +export { DEFAULT_OWNERSHIP_RELATION }; + export interface HomeListOptions { /** * Multi-select environment filter. Empty means all environments. @@ -38,6 +45,11 @@ export interface HomeListOptions { */ readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly ownershipFilter: OwnershipFilter; + /** + * Sub-filter for mine/theirs: created, participated, or both (default). + * Ignored when ownership is "any". Persisted with ownership filter. + */ + readonly ownershipRelation: OwnershipRelation; readonly listMode: HomeListMode; /** Organization of the Threads list (ignored on Board). */ readonly threadGrouping: HomeThreadGrouping; @@ -47,6 +59,34 @@ export interface HomeListOptions { export type OwnershipFilter = "any" | "mine" | "theirs"; +export const OWNERSHIP_FILTERS = [ + "any", + "mine", + "theirs", +] as const satisfies readonly OwnershipFilter[]; + +export const OWNERSHIP_FILTER_LABELS: Record = { + any: "Anyone", + mine: "Mine", + theirs: "Theirs", +}; + +export const OWNERSHIP_RELATIONS = [ + "both", + "created", + "participated", +] as const satisfies readonly OwnershipRelation[]; + +export const OWNERSHIP_RELATION_LABELS: Record = { + both: "Created or participated", + created: "Created", + participated: "Participated", +}; + +export function isOwnershipFilter(value: unknown): value is OwnershipFilter { + return value === "any" || value === "mine" || value === "theirs"; +} + export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } @@ -77,6 +117,7 @@ function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentIds: [], ownershipFilter: "any", + ownershipRelation: DEFAULT_OWNERSHIP_RELATION, listMode: DEFAULT_HOME_LIST_MODE, threadGrouping: DEFAULT_HOME_THREAD_GROUPING, projectSortOrder: @@ -101,9 +142,9 @@ const HomeListOptionsContext = createContext /** * Keeps list preferences stable while the app moves between compact and split - * shells. Optional storedEnvironmentIds / storedThreadGrouping + store - * callbacks make the env filter and Recent/project/none grouping survive app - * restarts (device preferences). + * shells. Optional stored* + store callbacks make filters survive app restarts + * (device preferences). Ownership is included so Mine/Theirs does not reset + * on every launch (especially painful on mobile). */ export function HomeListOptionsProvider({ children, @@ -120,18 +161,33 @@ export function HomeListOptionsProvider({ */ storedThreadGrouping, onStoreThreadGrouping, + /** + * `undefined` = storage not loaded yet (do not hydrate). + */ + storedOwnershipFilter, + onStoreOwnershipFilter, + storedOwnershipRelation, + onStoreOwnershipRelation, }: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode; readonly storedEnvironmentIds?: readonly EnvironmentId[]; readonly onStoreEnvironmentIds?: (ids: readonly EnvironmentId[]) => void; readonly storedThreadGrouping?: HomeThreadGrouping; readonly onStoreThreadGrouping?: (grouping: HomeThreadGrouping) => void; + readonly storedOwnershipFilter?: OwnershipFilter; + readonly onStoreOwnershipFilter?: (filter: OwnershipFilter) => void; + readonly storedOwnershipRelation?: OwnershipRelation; + readonly onStoreOwnershipRelation?: (relation: OwnershipRelation) => void; }>) { const [options, setOptions] = useState(defaultHomeListOptions); const envFilterHydratedRef = useRef(false); const lastPersistedEnvKeyRef = useRef(null); const threadGroupingHydratedRef = useRef(false); const lastPersistedThreadGroupingRef = useRef(null); + const ownershipFilterHydratedRef = useRef(false); + const lastPersistedOwnershipFilterRef = useRef(null); + const ownershipRelationHydratedRef = useRef(false); + const lastPersistedOwnershipRelationRef = useRef(null); useEffect(() => { if (envFilterHydratedRef.current) return; @@ -177,6 +233,46 @@ export function HomeListOptionsProvider({ onStoreThreadGrouping(options.threadGrouping); }, [onStoreThreadGrouping, options.threadGrouping]); + useEffect(() => { + if (ownershipFilterHydratedRef.current) return; + if (storedOwnershipFilter === undefined) return; + setOptions((current) => + current.ownershipFilter === storedOwnershipFilter + ? current + : { ...current, ownershipFilter: storedOwnershipFilter }, + ); + lastPersistedOwnershipFilterRef.current = storedOwnershipFilter; + ownershipFilterHydratedRef.current = true; + }, [storedOwnershipFilter]); + + useEffect(() => { + if (!ownershipFilterHydratedRef.current) return; + if (!onStoreOwnershipFilter) return; + if (lastPersistedOwnershipFilterRef.current === options.ownershipFilter) return; + lastPersistedOwnershipFilterRef.current = options.ownershipFilter; + onStoreOwnershipFilter(options.ownershipFilter); + }, [onStoreOwnershipFilter, options.ownershipFilter]); + + useEffect(() => { + if (ownershipRelationHydratedRef.current) return; + if (storedOwnershipRelation === undefined) return; + setOptions((current) => + current.ownershipRelation === storedOwnershipRelation + ? current + : { ...current, ownershipRelation: storedOwnershipRelation }, + ); + lastPersistedOwnershipRelationRef.current = storedOwnershipRelation; + ownershipRelationHydratedRef.current = true; + }, [storedOwnershipRelation]); + + useEffect(() => { + if (!ownershipRelationHydratedRef.current) return; + if (!onStoreOwnershipRelation) return; + if (lastPersistedOwnershipRelationRef.current === options.ownershipRelation) return; + lastPersistedOwnershipRelationRef.current = options.ownershipRelation; + onStoreOwnershipRelation(options.ownershipRelation); + }, [onStoreOwnershipRelation, options.ownershipRelation]); + const value = useMemo( () => ({ options, setOptions, projectGroupingMode }), [options, projectGroupingMode], @@ -196,6 +292,7 @@ export function hasCustomHomeListOptions( return ( options.selectedEnvironmentIds.length > 0 || options.ownershipFilter !== "any" || + options.ownershipRelation !== DEFAULT_OWNERSHIP_RELATION || (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.threadGrouping !== DEFAULT_HOME_THREAD_GROUPING || options.projectSortOrder !== defaultProjectSortOrder || @@ -251,6 +348,12 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { + setOptions((current) => ({ ...current, ownershipRelation: value })); + }, + [setOptions], + ); const setThreadGrouping = useCallback( (value: HomeThreadGrouping) => { setOptions((current) => ({ ...current, threadGrouping: value })); @@ -275,6 +378,7 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { const homeHeader = readSource("../home/HomeHeader.tsx"); const homeRoute = readSource("../home/HomeRouteScreen.tsx"); const sidebar = readSource("../threads/ThreadNavigationSidebar.tsx"); + const preferences = readSource("../../persistence/mobile-preferences.ts"); + const layout = readSource("../layout/AdaptiveWorkspaceLayout.tsx"); expect(homeHeader).toContain('title: "Ownership"'); expect(homeHeader).toContain("onOwnershipFilterChange"); + expect(homeHeader).toContain("onOwnershipRelationChange"); expect(homeRoute).toContain("ownershipFilteredThreads"); + expect(homeRoute).toContain("relation: listOptions.ownershipRelation"); expect(sidebar).toContain("threadMatchesMine"); expect(sidebar).toContain("ownershipFilter: options.ownershipFilter"); + expect(sidebar).toContain("ownershipRelation: options.ownershipRelation"); + // Device persistence — without these, Mine/Theirs resets on every launch. + expect(preferences).toContain("ownershipFilter"); + expect(preferences).toContain("ownershipRelation"); + expect(layout).toContain("storedOwnershipFilter"); + expect(layout).toContain("onStoreOwnershipFilter"); }); }); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c745818a7af..a57d8663527 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -42,12 +42,21 @@ import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; +import { + HomeListOptionsProvider, + resolveProjectGroupingMode, + type OwnershipFilter, + type OwnershipRelation, +} from "../home/home-list-options"; import { DEFAULT_HOME_THREAD_GROUPING, resolveHomeThreadGrouping, type HomeThreadGrouping, } from "../home/homeListMode"; +import { + resolveOwnershipFilter, + resolveOwnershipRelation, +} from "../../persistence/mobile-preferences"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -207,6 +216,18 @@ export function AdaptiveWorkspaceLayout(props: { }, [savePreferences], ); + const storeOwnershipFilter = useCallback( + (filter: OwnershipFilter) => { + savePreferences({ ownershipFilter: filter }); + }, + [savePreferences], + ); + const storeOwnershipRelation = useCallback( + (relation: OwnershipRelation) => { + savePreferences({ ownershipRelation: relation }); + }, + [savePreferences], + ); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( @@ -217,6 +238,10 @@ export function AdaptiveWorkspaceLayout(props: { onStoreEnvironmentIds={storeEnvironmentIds} storedThreadGrouping={DEFAULT_HOME_THREAD_GROUPING} onStoreThreadGrouping={storeThreadGrouping} + storedOwnershipFilter="any" + onStoreOwnershipFilter={storeOwnershipFilter} + storedOwnershipRelation="both" + onStoreOwnershipRelation={storeOwnershipRelation} /> ) : null; } @@ -224,6 +249,8 @@ export function AdaptiveWorkspaceLayout(props: { const storedEnvironmentIds = (preferencesResult.value.selectedEnvironmentIds ?? []) as readonly EnvironmentId[]; const storedThreadGrouping = resolveHomeThreadGrouping(preferencesResult.value.threadGrouping); + const storedOwnershipFilter = resolveOwnershipFilter(preferencesResult.value); + const storedOwnershipRelation = resolveOwnershipRelation(preferencesResult.value); return ( ); } @@ -249,6 +280,10 @@ function AdaptiveWorkspaceLayoutContent( readonly onStoreEnvironmentIds: (ids: readonly EnvironmentId[]) => void; readonly storedThreadGrouping: HomeThreadGrouping; readonly onStoreThreadGrouping: (grouping: HomeThreadGrouping) => void; + readonly storedOwnershipFilter: OwnershipFilter; + readonly onStoreOwnershipFilter: (filter: OwnershipFilter) => void; + readonly storedOwnershipRelation: OwnershipRelation; + readonly onStoreOwnershipRelation: (relation: OwnershipRelation) => void; }, ) { const projectGroupingMode = props.projectGroupingMode; @@ -551,6 +586,10 @@ function AdaptiveWorkspaceLayoutContent( onStoreEnvironmentIds={props.onStoreEnvironmentIds} storedThreadGrouping={props.storedThreadGrouping} onStoreThreadGrouping={props.onStoreThreadGrouping} + storedOwnershipFilter={props.storedOwnershipFilter} + onStoreOwnershipFilter={props.onStoreOwnershipFilter} + storedOwnershipRelation={props.storedOwnershipRelation} + onStoreOwnershipRelation={props.onStoreOwnershipRelation} > diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d28f671ea7a..56375f37655 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -241,6 +241,7 @@ function ThreadNavigationSidebarPane( toggleSelectedEnvironmentId, clearSelectedEnvironments, setOwnershipFilter, + setOwnershipRelation, setListMode, setThreadGrouping, setProjectSortOrder, @@ -383,9 +384,16 @@ function ThreadNavigationSidebarPane( (participant) => participant.personId, ), mode: options.ownershipFilter, + relation: options.ownershipRelation, }), ), - [claimPersonIdByEnvironment, options.ownershipFilter, selectedProjectRefs, threads], + [ + claimPersonIdByEnvironment, + options.ownershipFilter, + options.ownershipRelation, + selectedProjectRefs, + threads, + ], ); const scopedPendingTasks = useMemo( () => @@ -1321,6 +1329,7 @@ function ThreadNavigationSidebarPane( const filterCustomized = options.selectedEnvironmentIds.length > 0 || options.ownershipFilter !== "any" || + options.ownershipRelation !== "both" || selectedProjectKey !== null || options.threadGrouping !== "project" || (options.listMode === "threads" && @@ -1337,12 +1346,14 @@ function ThreadNavigationSidebarPane( selectedEnvironmentIds: options.selectedEnvironmentIds, selectedProjectKey, ownershipFilter: options.ownershipFilter, + ownershipRelation: options.ownershipRelation, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onClearEnvironments: clearSelectedEnvironments, onToggleEnvironment: toggleSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, onOwnershipFilterChange: setOwnershipFilter, + onOwnershipRelationChange: setOwnershipRelation, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization, @@ -1363,6 +1374,7 @@ function ThreadNavigationSidebarPane( listOrganization, options.listMode, options.ownershipFilter, + options.ownershipRelation, options.projectSortOrder, options.selectedEnvironmentIds, options.threadGrouping, @@ -1371,6 +1383,7 @@ function ThreadNavigationSidebarPane( selectedProjectKey, setHideSettledThreads, setOwnershipFilter, + setOwnershipRelation, setProjectSortOrder, setThreadGrouping, setThreadSortOrder, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 00fadfd5885..5486a12499b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -58,6 +58,15 @@ export interface Preferences { * Device-local; survives restarts. Omitted = default project grouping. */ readonly threadGrouping?: "recency" | "project" | "none"; + /** + * Home/sidebar ownership filter (anyone / mine / theirs). Device-local so + * it survives app restarts — without this, Mine/Theirs resets on launch. + */ + readonly ownershipFilter?: "any" | "mine" | "theirs"; + /** + * Sub-filter for mine/theirs: created, participated, or both (default). + */ + readonly ownershipRelation?: "created" | "participated" | "both"; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -115,6 +124,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { hideSettledOnRecent?: boolean; hideSettledOnProjects?: boolean; threadGrouping?: "recency" | "project" | "none"; + ownershipFilter?: "any" | "mine" | "theirs"; + ownershipRelation?: "created" | "participated" | "both"; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -171,9 +182,49 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.threadGrouping = parsed.threadGrouping; } + if ( + parsed.ownershipFilter === "any" || + parsed.ownershipFilter === "mine" || + parsed.ownershipFilter === "theirs" + ) { + preferences.ownershipFilter = parsed.ownershipFilter; + } + if ( + parsed.ownershipRelation === "created" || + parsed.ownershipRelation === "participated" || + parsed.ownershipRelation === "both" + ) { + preferences.ownershipRelation = parsed.ownershipRelation; + } return preferences; } +/** Resolve stored ownership filter; default anyone when never chosen. */ +export function resolveOwnershipFilter(preferences: Preferences): "any" | "mine" | "theirs" { + if ( + preferences.ownershipFilter === "any" || + preferences.ownershipFilter === "mine" || + preferences.ownershipFilter === "theirs" + ) { + return preferences.ownershipFilter; + } + return "any"; +} + +/** Resolve mine/theirs relation sub-filter; default both. */ +export function resolveOwnershipRelation( + preferences: Preferences, +): "created" | "participated" | "both" { + if ( + preferences.ownershipRelation === "created" || + preferences.ownershipRelation === "participated" || + preferences.ownershipRelation === "both" + ) { + return preferences.ownershipRelation; + } + return "both"; +} + /** Resolve stored Threads grouping; default project when never chosen. */ export function resolveThreadGrouping(preferences: Preferences): "recency" | "project" | "none" { if ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b717c0ded47..92c83ef40f8 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -117,7 +117,10 @@ import { } from "./identity/IdentityClaimGate"; import { claimPersonIdForEnvironment, + DEFAULT_OWNERSHIP_RELATION, + isOwnershipRelation, threadMatchesMine, + type OwnershipRelation, } from "@t3tools/client-runtime/state/identity"; import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; import { @@ -1335,6 +1338,15 @@ export default function SidebarV2() { } return "any"; }); + const [ownershipRelation, setOwnershipRelation] = useState(() => { + try { + const raw = window.localStorage.getItem("t3.sidebar.ownershipRelation"); + if (isOwnershipRelation(raw)) return raw; + } catch { + // ignore + } + return DEFAULT_OWNERSHIP_RELATION; + }); // Per-environment claims (not primary-only): smart has no map while t3vm does. const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); @@ -1343,7 +1355,8 @@ export default function SidebarV2() { storedThreadGrouping !== DEFAULT_WEB_THREAD_GROUPING || settledRecencyHeadersEnabled !== DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS || settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED || - ownershipFilter !== "any"; + ownershipFilter !== "any" || + ownershipRelation !== DEFAULT_OWNERSHIP_RELATION; const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1654,6 +1667,7 @@ export default function SidebarV2() { (participant) => participant.personId, ), mode: ownershipFilter, + relation: ownershipRelation, }), ); const active: EnvironmentThreadShell[] = []; @@ -1707,6 +1721,7 @@ export default function SidebarV2() { claimPersonIdByEnvironment, nowMinute, ownershipFilter, + ownershipRelation, scopedProjectKeys, selectedEnvironmentIds, serverConfigs, @@ -2841,6 +2856,46 @@ export default function SidebarV2() { ))} + {ownershipFilter === "mine" || ownershipFilter === "theirs" ? ( + <> + + +
+ {ownershipFilter === "mine" ? "Mine includes" : "Theirs includes"} +
+ { + if (!isOwnershipRelation(value)) return; + setOwnershipRelation(value); + try { + window.localStorage.setItem("t3.sidebar.ownershipRelation", value); + } catch { + // ignore + } + }} + > + {( + [ + ["both", "Created or participated"], + ["created", "Created"], + ["participated", "Participated"], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} + +
+ + ) : null}
diff --git a/packages/client-runtime/src/state/identity.test.ts b/packages/client-runtime/src/state/identity.test.ts index 73387811995..014a3c61299 100644 --- a/packages/client-runtime/src/state/identity.test.ts +++ b/packages/client-runtime/src/state/identity.test.ts @@ -150,6 +150,85 @@ describe("threadMatchesMine", () => { }), ).toBe(true); }); + + it("supports created / participated / both relation sub-filters", () => { + // Creator only + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + participantPersonIds: ["julius"], + mode: "mine", + relation: "created", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "participated", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + participantPersonIds: [], + mode: "mine", + relation: "participated", + }), + ).toBe(false); + // Both keeps current default behavior + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "mine", + relation: "both", + }), + ).toBe(true); + // Theirs + created: other person started + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + participantPersonIds: ["patroza"], + mode: "theirs", + relation: "created", + }), + ).toBe(true); + // Fully unattributed only under mine + both + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "mine", + relation: "created", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: null, + participantPersonIds: [], + mode: "mine", + relation: "both", + }), + ).toBe(true); + }); }); describe("claimPersonIdForEnvironment", () => { diff --git a/packages/client-runtime/src/state/identity.ts b/packages/client-runtime/src/state/identity.ts index 73edfe00dd0..32c02d3a7b9 100644 --- a/packages/client-runtime/src/state/identity.ts +++ b/packages/client-runtime/src/state/identity.ts @@ -73,38 +73,71 @@ export function filterPeopleForTypeahead( }); } +/** + * Sub-filter for Mine / Theirs: which attribution signal to use. + * - `created` — only origin/starter person + * - `participated` — only participant person ids + * - `both` (default) — origin or any participant + */ +export type OwnershipRelation = "created" | "participated" | "both"; + +export const DEFAULT_OWNERSHIP_RELATION: OwnershipRelation = "both"; + +export function isOwnershipRelation(value: unknown): value is OwnershipRelation { + return value === "created" || value === "participated" || value === "both"; +} + /** * Match a thread for Mine / Theirs ownership filters. * - * **Mine** includes: + * **Mine** (relation `both`, default) includes: * - threads where the session claim person appears on origin or participants * - threads with **no person attribution** (no identity tags, channel-only * stamps like `{ channel: "desktop" }`, identity-disabled servers, legacy * threads) — treated as "ours" so filters stay useful offline of a map * - * **Theirs** is only threads that have at least one person tag and do not - * include the claim person. + * **Mine** with `created` / `participated` only matches that signal (and does + * not pull in fully unattributed threads). + * + * **Theirs** is only threads that have at least one person tag in the active + * relation set and do not include the claim person. */ export function threadMatchesMine(input: { readonly claimPersonId: string | null | undefined; readonly originPersonId?: string | null | undefined; readonly participantPersonIds?: ReadonlyArray | null | undefined; readonly mode: "mine" | "theirs" | "any"; + /** Defaults to `both` (created or participated). */ + readonly relation?: OwnershipRelation; }): boolean { if (input.mode === "any") return true; - const people = new Set(); + const relation = input.relation ?? DEFAULT_OWNERSHIP_RELATION; const origin = input.originPersonId?.trim().toLowerCase() ?? ""; - if (origin.length > 0) people.add(origin); + const participants = new Set(); for (const id of input.participantPersonIds ?? []) { const personId = id?.trim().toLowerCase() ?? ""; - if (personId.length > 0) people.add(personId); + if (personId.length > 0) participants.add(personId); } - const unattributed = people.size === 0; - // No person tags (channel-only source, identity off, pre-attribution history). - if (unattributed) { - return input.mode === "mine"; + // Fully unattributed (no origin, no participants) stays under Mine only for + // the default "both" relation so created/participated refinements stay precise. + const fullyUnattributed = origin.length === 0 && participants.size === 0; + if (fullyUnattributed) { + return input.mode === "mine" && relation === "both"; + } + + const people = new Set(); + if (relation === "created" || relation === "both") { + if (origin.length > 0) people.add(origin); + } + if (relation === "participated" || relation === "both") { + for (const personId of participants) people.add(personId); + } + + // Relation-specific empty (e.g. "created" with participants only) is not a match. + if (people.size === 0) { + return false; } const claimId = input.claimPersonId?.trim().toLowerCase() ?? "";