Skip to content
Merged
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

This file was deleted.

132 changes: 93 additions & 39 deletions apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useAtomValue } from "@effect/atom-react";
import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping";
import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { useMemo } from "react";
import { Platform, ScrollView } from "react-native";
import type { MenuProps } from "../../components/ui/Menu.types";
import { ListFilterMenu } from "../workspace/ListFilterMenu";
import { usePublishPullRequestFilterMenu } from "./PullRequestFilterMenuContext";
import { useProjects, useThreadShells } from "../../state/entities";
import { environmentProjects } from "../../state/projects";
import { environmentThreadShells } from "../../state/threads";
import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList";
import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
Expand Down Expand Up @@ -39,32 +42,58 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec
}));
}

type ProjectOrder = readonly { readonly memberProjectRefs: readonly ScopedProjectRef[] }[];
const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) =>
previous.length === next.length &&
previous.every((group, index) => {
const members = next[index]!.memberProjectRefs;
return (
group.memberProjectRefs.length === members.length &&
group.memberProjectRefs.every(
(ref, member) =>
ref.environmentId === members[member]!.environmentId &&
ref.projectId === members[member]!.projectId,
)
);
});

// Thread shells change on every agent tick, but the sidebar order they decide rarely does. The
// equality check keeps the list screen, which builds these filters, from re-rendering on each tick.
const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) =>
Atom.make((get) =>
sortHomeProjectScopes({
scopes: buildHomeProjectScopes({
projects: get(environmentProjects.projectsAtom),
environmentId: null,
projectGroupingMode: mode,
}),
threads: get(environmentThreadShells.threadShellsAtom),
pendingTasks: [],
projectSortOrder: "updated_at",
}).map((scope) => ({ memberProjectRefs: scope.projectRefs })),
).pipe(Atom.withEquality(sameProjectOrder)),
);

function useProjectsInSidebarOrder<Project extends { id: string; environmentId: string }>(
projects: readonly Project[],
) {
const allProjects = useProjects();
const threads = useThreadShells();
const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings();
const scopes = useMemo(
() =>
sortHomeProjectScopes({
scopes: buildHomeProjectScopes({
projects: allProjects,
environmentId: null,
projectGroupingMode: sidebarProjectGroupingMode,
}),
threads,
pendingTasks: [],
projectSortOrder: "updated_at",
}).map((scope) => ({ memberProjectRefs: scope.projectRefs })),
[allProjects, sidebarProjectGroupingMode, threads],
);
const scopes = useAtomValue(sidebarProjectOrderFamily(sidebarProjectGroupingMode));
return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]);
}

export function PullRequestListFilters(props: {
/** Android sidebar rows sit further in than full-width rows; the pills follow them. */
readonly sidebar?: boolean;
export interface PullRequestFilterMenu {
readonly actions: MenuProps["actions"];
readonly onSelect: (id: string) => void;
/** Whether any filter, the environment, or a non-default sort is active. */
readonly customized: boolean;
}

/**
* Builds the filter pills and, on Android, the header's full filter menu. The list screen owns
* the call so both the pills and the header it frames read the same menu.
*/
export function usePullRequestListFilters(props: {
readonly environments: readonly { id: string; label: string }[];
readonly environmentId: string | null;
readonly onEnvironmentChange: (id: string | null) => void;
Expand Down Expand Up @@ -280,7 +309,7 @@ export function PullRequestListFilters(props: {
];
const customized = count > 0 || value.sort !== "updated" || props.environmentId !== null;
// The Android header's filter button opens every filter.
const headerMenu =
const headerMenu: PullRequestFilterMenu | null =
Platform.OS === "android"
? {
actions: [
Expand All @@ -296,7 +325,35 @@ export function PullRequestListFilters(props: {
customized,
}
: null;
usePublishPullRequestFilterMenu(headerMenu);
return {
headerMenu,
select,
count,
more,
reset,
environmentLabel:
props.environments.length > 1
? (props.environments.find((environment) => environment.id === props.environmentId)
?.label ?? "All environments")
: null,
environmentActions,
state: value.state,
stateActions,
involvement: value.involvement,
involvementActions,
selectedLabelCount: selectedLabels.length,
labelActions,
author,
authorActions,
};
}

export function PullRequestListFilters(props: {
/** Android sidebar rows sit further in than full-width rows; the pills follow them. */
readonly sidebar?: boolean;
readonly filters: ReturnType<typeof usePullRequestListFilters>;
}) {
const { count, more, reset, select, author, selectedLabelCount } = props.filters;
return (
<ScrollView
horizontal
Expand All @@ -319,43 +376,40 @@ export function PullRequestListFilters(props: {
onSelect={select}
/>
)}
{props.environments.length > 1 ? (
{props.filters.environmentLabel !== null ? (
<ListFilterMenu
label={
props.environments.find((environment) => environment.id === props.environmentId)
?.label ?? "All environments"
}
label={props.filters.environmentLabel}
accessibilityLabel="Choose pull request environment"
actions={environmentActions}
actions={props.filters.environmentActions}
onSelect={select}
/>
) : null}
<ListFilterMenu
label={STATES[value.state]}
active={value.state !== "open"}
label={STATES[props.filters.state]}
active={props.filters.state !== "open"}
accessibilityLabel="Filter by state"
actions={stateActions}
actions={props.filters.stateActions}
onSelect={select}
/>
<ListFilterMenu
label={INVOLVEMENT[value.involvement]}
active={value.involvement !== "all"}
label={INVOLVEMENT[props.filters.involvement]}
active={props.filters.involvement !== "all"}
accessibilityLabel="Filter by involvement"
actions={involvementActions}
actions={props.filters.involvementActions}
onSelect={select}
/>
<ListFilterMenu
label={selectedLabels.length ? `Labels ${selectedLabels.length}` : "Labels"}
active={selectedLabels.length > 0}
label={selectedLabelCount ? `Labels ${selectedLabelCount}` : "Labels"}
active={selectedLabelCount > 0}
accessibilityLabel="Filter by label"
actions={labelActions}
actions={props.filters.labelActions}
onSelect={select}
/>
<ListFilterMenu
label={author === "me" ? "By you" : (author ?? "Author")}
active={Boolean(author)}
accessibilityLabel="Filter by author"
actions={authorActions}
actions={props.filters.authorActions}
onSelect={select}
/>
</ScrollView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import type { PullRequestReviewerCandidate } from "@t3tools/contracts";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import {
FlatList,
Platform,
Expand Down Expand Up @@ -86,13 +86,14 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
(a, b) => Number(b.isRequested) - Number(a.isRequested) || a.login.localeCompare(b.login),
);
})();
useEffect(() => {
if (candidates.length === 0) return;
// A new search shows its best matches from the top.
const search = (next: string) => {
setQuery(next);
listRef.current?.scrollToOffset({
offset: Platform.OS === "ios" ? -insets.top : 0,
animated: false,
});
}, [candidates, insets.top]);
};
const update = async (
candidate: Pick<PullRequestReviewerCandidate, "id" | "kind" | "isRequested">,
) => {
Expand All @@ -118,16 +119,16 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
return;
}
if (!pr?.capabilities.reviewers.listCandidates) {
setQuery("");
search("");
detail.refresh();
}
};
const listCandidates = pr?.capabilities.reviewers.listCandidates === true;
const searchOptions = {
placeholder: "Search reviewers",
autoCapitalize: "none" as const,
onChangeText: (event: { nativeEvent: { text: string } }) => setQuery(event.nativeEvent.text),
onCancelButtonPress: () => setQuery(""),
onChangeText: (event: { nativeEvent: { text: string } }) => search(event.nativeEvent.text),
onCancelButtonPress: () => search(""),
hideNavigationBar: false,
hideWhenScrolling: false,
obscureBackground: false,
Expand All @@ -150,7 +151,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
? [
createNativeMailSearchToolbarItem({
placeholder: "Search reviewers",
onSearchTextChange: setQuery,
onSearchTextChange: search,
showsSearchDismissButton: true,
searchTextChangeId: "pr-reviewer-search",
}),
Expand Down Expand Up @@ -182,7 +183,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
placeholder="Search reviewers"
placeholderTextColorClassName="accent-foreground-muted"
value={query}
onChangeText={setQuery}
onChangeText={search}
autoCapitalize="none"
className="min-h-11 rounded-xl bg-grouped-card px-3 text-sm"
/>
Expand Down Expand Up @@ -287,7 +288,7 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
placeholder="Reviewer login or ID"
placeholderTextColorClassName="accent-foreground-muted"
value={query}
onChangeText={setQuery}
onChangeText={search}
autoCapitalize="none"
autoCorrect={false}
className="min-h-12 rounded-xl bg-grouped-card px-3 text-base"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useNavigation, useNavigationState } from "@react-navigation/native";
import { useNavigation } from "@react-navigation/native";
import { useCallback, useEffect, useRef } from "react";
import { BackHandler, Keyboard, View, type TextInputInstance } from "react-native";
import { AndroidHeaderIconButton } from "../../components/ui/AndroidScreenHeader";
Expand All @@ -8,17 +8,18 @@ import { useToolbarLayout } from "../../components/ui/useToolbarLayout";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { WorkspaceListToolbarRow } from "../workspace/WorkspaceListToolbarRow";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import { usePullRequestFilterMenu } from "./PullRequestFilterMenuContext";
import type { PullRequestFilterMenu } from "./PullRequestListFilters";
import { openSettings } from "../settings/settings-navigation";

/** PR search shares the thread list's Android header row. */
export function PullRequestToolbar(props: { readonly onRequestVisibility?: () => void }) {
export function PullRequestToolbar(props: {
readonly filters: PullRequestFilterMenu | null;
readonly onRequestVisibility?: () => void;
}) {
const navigation = useNavigation();
const filters = usePullRequestFilterMenu();
const { filters } = props;
const workspace = useWorkspaceTabs()!;
const { dispatch, getScrollOffset, recordScrollOffset, isTabActive } = workspace;
const homeFocused = useNavigationState((state) => state.routes[state.index]?.name === "Home");
const listFocused = homeFocused && workspace.state.selectedTab === "pullRequests";
const { onRequestVisibility } = props;
const tab = workspace.state.tabs.pullRequests;
const input = useRef<TextInputInstance>(null);
Expand All @@ -41,9 +42,10 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () =>
dispatch({ type: "cancel", tab: "pullRequests" });
}, [dispatch, recordScrollOffset, tab.searchEntryOffset]);
useEffect(() => {
if (!searching || !listFocused) return;
if (!searching) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
// Frozen Home screens can keep effects alive after a detail route opens.
// Frozen Home screens stay mounted under a detail route or another tab,
// so back only applies while the PR list is in front.
const state = navigation.getState();
if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
return false;
Expand All @@ -52,7 +54,7 @@ export function PullRequestToolbar(props: { readonly onRequestVisibility?: () =>
return true;
});
return () => subscription.remove();
}, [searching, closeSearch, listFocused, navigation, isTabActive]);
}, [searching, closeSearch, navigation, isTabActive]);
const { paddingTop, paddingBottom } = useToolbarLayout();
return (
<View className="bg-header px-2" style={{ paddingTop, paddingBottom }}>
Expand Down
Loading
Loading