diff --git a/apps/mobile/assets/icons/issue.png b/apps/mobile/assets/icons/issue.png new file mode 100644 index 0000000000000..d90e5053a9395 Binary files /dev/null and b/apps/mobile/assets/icons/issue.png differ diff --git a/apps/mobile/assets/icons/issue@2x.png b/apps/mobile/assets/icons/issue@2x.png new file mode 100644 index 0000000000000..98518dda6098b Binary files /dev/null and b/apps/mobile/assets/icons/issue@2x.png differ diff --git a/apps/mobile/assets/icons/issue@3x.png b/apps/mobile/assets/icons/issue@3x.png new file mode 100644 index 0000000000000..6b43b0cf37caf Binary files /dev/null and b/apps/mobile/assets/icons/issue@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt index 736f7cd3019f0..1208f3176935d 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt @@ -126,7 +126,7 @@ class T3WorkspaceNavigationView( private val onTabSelected by EventDispatcher() private val onNewThread by EventDispatcher() private val onOverlayChange by EventDispatcher() - var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings")) + var tabs by mutableStateOf(listOf("threads", "pullRequests", "issues", "usage", "settings")) var selectedTab by mutableStateOf("threads") var rail by mutableStateOf(false) var floating by mutableStateOf(false) @@ -415,6 +415,7 @@ class T3WorkspaceNavigationView( private fun tabTitle(tab: String) = when (tab) { "threads" -> "Chats" "pullRequests" -> "Pulls" + "issues" -> "Issues" "usage" -> "Usage" else -> "Settings" } @@ -427,6 +428,7 @@ class T3WorkspaceNavigationView( when (tab) { "threads" -> R.drawable.t3_chats "pullRequests" -> R.drawable.t3_pull_requests + "issues" -> R.drawable.t3_issues "usage" -> R.drawable.t3_usage else -> R.drawable.t3_settings } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml new file mode 100644 index 0000000000000..14de69947215f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png new file mode 100644 index 0000000000000..d90e5053a9395 Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png new file mode 100644 index 0000000000000..98518dda6098b Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png new file mode 100644 index 0000000000000..6b43b0cf37caf Binary files /dev/null and b/apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png differ diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec index c32f88b0dacb3..ed5fd7f091dba 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControls.podspec @@ -15,6 +15,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', } - s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png'] } + s.resource_bundles = { 'T3NativeControlIcons' => ['Resources/Icons/pull-request*.png', 'Resources/Icons/issue*.png'] } s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' end diff --git a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift index 243490678c530..ddfdc92147465 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3SidebarTabBarView.swift @@ -40,7 +40,7 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { private let composeButton = UIButton(type: .system) private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor) private var itemsByTab: [String: UITabBarItem] = [:] - var tabs = ["threads", "pullRequests", "usage", "settings"] { + var tabs = ["threads", "pullRequests", "issues", "usage", "settings"] { didSet { if tabs != oldValue { updateItems() } } } var selectedTab = "threads" { didSet { updateSelection() } } @@ -106,10 +106,11 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate { private func prepareItems() { let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:)) let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) - let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"] + let labels = ["threads": "Chats", "pullRequests": "PRs", "issues": "Issues", "usage": "Usage", "settings": "Settings"] + let issueIcon = UIImage(named: "issue", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"] for (tab, label) in labels { - let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle") + let icon = tab == "pullRequests" ? pullRequestIcon : tab == "issues" ? issueIcon : UIImage(systemName: symbols[tab] ?? "circle") let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0) item.accessibilityLabel = label item.accessibilityIdentifier = "workspace-tab-\(tab)" diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b2ad65cf05374..e736ced34503c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -37,6 +37,11 @@ import { PullRequestReviewersScreen } from "./features/pull-requests/PullRequest import { PullRequestLabelsScreen } from "./features/pull-requests/PullRequestLabelsScreen"; import { PullRequestEditScreen } from "./features/pull-requests/PullRequestEditScreen"; import { PullRequestFilterSearchScreen } from "./features/pull-requests/PullRequestFilterSearchScreen"; +import { IssueAssigneesScreen } from "./features/issues/IssueAssigneesScreen"; +import { IssueCreateScreen } from "./features/issues/IssueCreateScreen"; +import { IssueEditScreen } from "./features/issues/IssueEditScreen"; +import { IssueLabelsScreen } from "./features/issues/IssueLabelsScreen"; +import { IssueRouteScreen } from "./features/issues/IssueRouteScreen"; import { RenderErrorBoundary, RenderFailureView, @@ -552,6 +557,27 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: PullRequestEditScreen, options: sheetRoute({ detent: 0.75, androidFit: true }), }), + Issue: createNativeStackScreen({ + screen: IssueRouteScreen, + linking: "issues/:environmentId/:projectId/:tracker/:scope/:issueKey", + options: pageRoute({ duoMinimize: "never" }), + }), + IssueEdit: createNativeStackScreen({ + screen: IssueEditScreen, + options: sheetRoute({ detent: 0.75, androidFit: true }), + }), + IssueLabels: createNativeStackScreen({ + screen: IssueLabelsScreen, + options: SEARCH_SHEET_ROUTE, + }), + IssueAssignees: createNativeStackScreen({ + screen: IssueAssigneesScreen, + options: SEARCH_SHEET_ROUTE, + }), + IssueCreate: createNativeStackScreen({ + screen: IssueCreateScreen, + options: sheetRoute({ detent: 0.75, androidFit: true }), + }), Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] }, diff --git a/apps/mobile/src/components/ComposerSurface.tsx b/apps/mobile/src/components/ComposerSurface.tsx new file mode 100644 index 0000000000000..1d6197fdebda4 --- /dev/null +++ b/apps/mobile/src/components/ComposerSurface.tsx @@ -0,0 +1,249 @@ +import { useState, type ReactNode } from "react"; +import { I18nManager, Platform, type ViewStyle } from "react-native"; +import Animated, { + type LayoutAnimationFunction, + ReduceMotion, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; +import { useSidebarWorkspaceTabs } from "../features/layout/workspace/useSystemWorkspaceTabBar"; +import { useAndroidFloatingTabs } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { compactEditorInsets } from "../features/threads/compactEditorInsets"; +import { + useDeviceType, + useDuoBottomBarInset, + useNativeLayoutMetrics, +} from "../native/native-layout-metrics"; +import type { ScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; +import { GlassSurface } from "./ui/GlassSurface"; + +const COLLAPSED_EDITOR_DEFAULT_INSET = 6; +// iOS puts line-height slack above glyphs. DM Sans's lowercase midline is +// descender (0.31em) + half x-height (0.504em) above the line bottom. +const LOWERCASE_MIDLINE_EM = 0.562; + +function lowercaseMidline(text: ScaledTextRole): number { + return text.lineHeight - LOWERCASE_MIDLINE_EM * text.fontSize; +} + +function collapsedEditorInset(text: ScaledTextRole): number { + const shift = lowercaseMidline(text) - lowercaseMidline(MOBILE_TYPOGRAPHY.body); + return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift)); +} + +export function useCompactComposerEditor(text: ScaledTextRole, value: string) { + const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); + const [compactWidth, setCompactWidth] = useState(0); + const contentHeight = contentSize.height; + const rowWidth = useSharedValue(0); + const editorX = useSharedValue(0); + const editorWidth = useSharedValue(0); + const inset = + Platform.OS === "android" ? COLLAPSED_EDITOR_DEFAULT_INSET : collapsedEditorInset(text); + const minimum = Math.max(INLINE_EDITOR_HEIGHT, Math.ceil(text.lineHeight) + 2 * inset); + const height = Math.max( + minimum, + Math.min(text.lineHeight * 4 + 2 * inset, contentHeight + 2 * inset), + ); + // A line that fits the expanded editor may still overflow its compact slot. + // Compare its measured width too so widening cannot trigger a collapse loop. + const isExpanded = + value.length > 0 && + (contentHeight > text.lineHeight * 1.5 || + (compactWidth > 0 && contentSize.width > compactWidth)); + const compactRowHeight = Math.max(44, minimum); + const isRTL = I18nManager.isRTL; + const editorStyle = useAnimatedStyle(() => ({ + position: "absolute", + overflow: "hidden", + height, + ...(isExpanded + ? { left: 14, right: 14 } + : compactEditorInsets({ + slotX: editorX.value, + slotWidth: editorWidth.value, + rowWidth: rowWidth.value, + isRTL, + })), + // The full-width editor must move above the controls in the same frame it widens. + bottom: isExpanded ? 52 : (compactRowHeight - minimum) / 2, + })); + return { + isExpanded, + containerStyle: { height: isExpanded ? height + 52 : compactRowHeight }, + editorStyle, + onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { + rowWidth.set(event.nativeEvent.layout.width); + }, + onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { + // The expanded toolbar gives this slot to the model control. Keep the + // compact geometry for the return transition, rather than recording zero. + if (!isExpanded) { + editorX.set(event.nativeEvent.layout.x); + editorWidth.set(event.nativeEvent.layout.width); + setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8)); + } + }, + editorProps: { + singleLineCentered: !isExpanded, + scrollEnabled: Platform.OS === "android" || contentHeight >= text.lineHeight * 4, + contentInsetVertical: inset, + onContentSizeChange: (size: { width: number; height: number }) => { + setContentSize(size); + }, + style: { flex: 1 }, + }, + }; +} + +// Android KeyboardStickyView tracks the IME; concurrent timed layout morphs cause jitter. +export const COMPOSER_TRANSITION_DURATION_MS = 220; +// Side panes animate dock width; nested horizontal transitions would make the surface trail the toolbar. +const composerHeightTransition: LayoutAnimationFunction = (values) => { + "worklet"; + const timing = { + duration: COMPOSER_TRANSITION_DURATION_MS, + reduceMotion: ReduceMotion.System, + }; + return { + initialValues: { + originX: values.targetOriginX, + originY: values.currentOriginY, + width: values.targetWidth, + height: values.currentHeight, + }, + animations: { + originX: values.targetOriginX, + originY: withTiming(values.targetOriginY, timing), + width: values.targetWidth, + height: withTiming(values.targetHeight, timing), + }, + }; +}; +const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; +// Share of the one-row composer the draft keeps; a long model name truncates first. +export const INLINE_EDITOR_MIN_WIDTH = "40%"; +const INLINE_EDITOR_HEIGHT = 36; + +const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); + +export function ComposerSurface(props: { + readonly children: ReactNode; + readonly style: ViewStyle; + readonly animateLayout?: boolean; +}) { + const colors = useUniwindTheme(); + const targetBorderRadius = + typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; + const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; + const animatedBorderRadius = useDerivedValue(() => + shouldAnimate + ? withTiming(targetBorderRadius, { + duration: COMPOSER_TRANSITION_DURATION_MS, + reduceMotion: ReduceMotion.System, + }) + : targetBorderRadius, + ); + const borderCurve = props.style.borderCurve; + const animatedShapeStyle = useAnimatedStyle(() => ({ + borderRadius: animatedBorderRadius.value, + ...(borderCurve ? { borderCurve } : {}), + })); + const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined; + + // Animate every native frame or glass and content jump to final height inside the clip. + return ( + + + {null} + + + {props.children} + + + ); +} + +/** Space below the composer pill, inside the bottom inset it is given. */ +export const COMPOSER_BOTTOM_GUTTER = 6; + +/** Control size and pill shape: larger beside a sidebar tab picker and on iPad. */ +export function useComposerChrome() { + const { layout } = useAdaptiveWorkspaceLayout(); + const floatingSidebarComposer = useAndroidFloatingTabs() && layout.usesSplitView; + const sidebarWorkspaceTabs = useSidebarWorkspaceTabs(); + const deviceType = useDeviceType(); + const usesLargeControls = sidebarWorkspaceTabs === "picker" || deviceType === "pad"; + return { + usesLargeControls, + controlSize: usesLargeControls ? ("large" as const) : ("regular" as const), + borderRadius: usesLargeControls ? 31 : 27, + paddingVertical: usesLargeControls ? 9 : floatingSidebarComposer ? 6 : 2, + }; +} + +/** + * The inset below a composer pinned to the bottom of a page. Fixed while the keyboard animates: + * changing it at keyboardDidHide makes the composer land low, then jump back above the safe area. + */ +export function useComposerBottomInset() { + const insets = useSafeAreaInsets(); + const windowMetrics = useNativeLayoutMetrics(); + const { layout } = useAdaptiveWorkspaceLayout(); + const bottomBarInset = useDuoBottomBarInset(); + const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; + // A pushed screen temporarily inherits the outgoing tab bar's safe area. + // Use the window edge so hiding that bar cannot move the composer mid-push. + const bottomSafeArea = + Platform.OS === "ios" ? (windowMetrics?.safeArea.bottom ?? insets.bottom) : insets.bottom; + // The pill already adds its own gutter; iPad and Duo align its edge with the native bar. + const alignedBottomInset = + bottomBarInset ?? + (Platform.OS === "ios" && Platform.isPad + ? Math.max(bottomSafeArea, 12) - (layout.usesSplitView ? 5 : 0) + : undefined); + // The Duo's floating sidebar pill ends three points below the composer's usual gutter. + return Math.max( + 0, + (alignedBottomInset !== undefined + ? alignedBottomInset - COMPOSER_BOTTOM_GUTTER + : Math.max(bottomSafeArea, 12)) - (hasSidebarTabBar ? 3 : 0), + ); +} + +/** The editor's text style: the composer's scaled role in the theme's foreground color. */ +export function useComposerTextStyle(text: ScaledTextRole) { + return { ...text, color: useUniwindTheme()["--color-foreground"] }; +} diff --git a/apps/mobile/src/components/ui/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx index b34c27bd471f3..ea0cedd52447e 100644 --- a/apps/mobile/src/components/ui/AppSymbol.android.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx @@ -148,6 +148,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "git.pull-request.closed": IconGitPullRequestClosed, "git.pull-request.draft": IconGitPullRequestDraft, "git.commit": IconGitCommit, + "git.issue": IconCircleDot, "arrow.branch": IconGitBranch, "arrow.left": IconArrowLeft, "arrow.right": IconArrowRight, @@ -300,6 +301,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" + | "git.issue" | "file.diff" | "robot", Icon @@ -311,6 +313,7 @@ const SF_ICON_LOOKUP: Partial< | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" + | "git.issue" | "file.diff" | "robot", Icon diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 146a9ccb305e7..896765db5d233 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -4,6 +4,7 @@ import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed"; import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; import type { SFSymbol } from "expo-symbols"; +import IconCircleDot from "@tabler/icons-react-native/IconCircleDot"; import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; import IconRobot from "@tabler/icons-react-native/IconRobot"; @@ -23,6 +24,7 @@ const CUSTOM_ICONS = { "git.pull-request.closed": IconGitPullRequestClosed, "git.pull-request.draft": IconGitPullRequestDraft, "git.commit": IconGitCommit, + "git.issue": IconCircleDot, robot: IconRobot, }; diff --git a/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx b/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx new file mode 100644 index 0000000000000..131f51e0923bd --- /dev/null +++ b/apps/mobile/src/features/issues/IssueAssigneesScreen.tsx @@ -0,0 +1,78 @@ +import type { StaticScreenProps } from "@react-navigation/native"; +import { useRef, useState } from "react"; + +import { issueEnvironment } from "../../state/issues"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { PullRequestAvatar } from "../pull-requests/PullRequestPresentation"; +import { IssueCandidatesSheet } from "./IssueCandidatesSheet"; +import { issueTarget, type IssueParams } from "./issueParams"; +import { issueCommandError, useIssueAccess } from "./issuePresentation"; + +export function IssueAssigneesScreen({ route }: StaticScreenProps) { + const target = issueTarget(route.params); + const detail = useEnvironmentQuery(issueEnvironment.detail(target)).data; + const access = useIssueAccess(route.params, detail); + const result = useEnvironmentQuery(issueEnvironment.candidates(target)); + const update = useAtomCommand(issueEnvironment.update, { reportFailure: false }); + const [query, setQuery] = useState(""); + const [pending, setPending] = useState<{ readonly id: string; readonly assigned: boolean }>(); + const [error, setError] = useState(null); + const inFlight = useRef(false); + const allowed = access?.assignees === true; + const assigned = new Set(detail?.assignees.map((actor) => actor.login.toLowerCase())); + const needle = query.trim().toLowerCase(); + const candidates = (result.data?.assignees ?? []).filter((candidate) => + `${candidate.login} ${candidate.name ?? ""}`.toLowerCase().includes(needle), + ); + const toggle = async (id: string) => { + const candidate = candidates.find((entry) => entry.id === id); + if (!detail || !candidate || !allowed || inFlight.current) return; + inFlight.current = true; + const login = candidate.login.toLowerCase(); + setPending({ id, assigned: !assigned.has(login) }); + setError(null); + // The whole set is written: people already assigned by login, the new one by tracker id. + const assignees = assigned.has(login) + ? detail.assignees + .filter((actor) => actor.login.toLowerCase() !== login) + .map((actor) => actor.login) + : [...detail.assignees.map((actor) => actor.login), candidate.id]; + const failure = issueCommandError( + await update({ ...target, input: { ...target.input, assignees } }), + ); + inFlight.current = false; + setPending(undefined); + setError(failure); + }; + return ( + ({ + key: candidate.id, + title: candidate.login, + ...(candidate.name ? { subtitle: candidate.name } : {}), + leading: , + selected: + pending?.id === candidate.id + ? pending.assigned + : assigned.has(candidate.login.toLowerCase()), + }))} + allowed={allowed} + notice={ + detail && !allowed + ? (access?.blocked ?? "Changing assignees needs triage access on this tracker.") + : null + } + error={error} + pending={result.isPending && !result.data} + loadError={result.data ? null : result.error} + onRetry={result.refresh} + truncated={result.data?.truncated === true} + onToggle={(id) => void toggle(id)} + /> + ); +} diff --git a/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx b/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx new file mode 100644 index 0000000000000..6757e1bee6a3a --- /dev/null +++ b/apps/mobile/src/features/issues/IssueCandidatesSheet.tsx @@ -0,0 +1,125 @@ +import { LegendList } from "@legendapp/list/react-native"; +import type { ReactNode } from "react"; +import { Platform, View } from "react-native"; + +import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { EmptyState } from "../../components/ui/EmptyState"; +import { SelectionRow } from "../../components/ui/SelectionRow"; +import { NativeScrollView } from "../../native/NativeScrollViewMarker"; +import { PullRequestSearchSheet } from "../pull-requests/PullRequestSearchSheet"; + +interface Candidate { + readonly key: string; + readonly title: string; + readonly subtitle?: string; + readonly leading: ReactNode; + readonly selected: boolean; +} + +/** The searchable checklist behind the label and assignee pickers. */ +export function IssueCandidatesSheet(props: { + readonly title: string; + readonly noun: "labels" | "people"; + readonly query: string; + readonly onQueryChange: (query: string) => void; + readonly candidates: ReadonlyArray; + readonly allowed: boolean; + readonly notice: string | null; + readonly error: string | null; + readonly pending: boolean; + readonly loadError: string | null; + readonly onRetry: () => void; + readonly truncated: boolean; + readonly onToggle: (key: string) => void; +}) { + const searching = props.query.trim().length > 0; + return ( + + {({ paddingBottom }) => ( + candidate.key} + extraData={props.candidates} + estimatedItemSize={64} + recycleItems + className="flex-1" + contentInsetAdjustmentBehavior="automatic" + automaticallyAdjustKeyboardInsets={Platform.OS === "ios"} + keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom }} + ListHeaderComponent={ + props.notice || props.error ? ( + + {props.notice ? ( + {props.notice} + ) : null} + {props.error ? ( + + {props.error} + + ) : null} + + ) : undefined + } + renderItem={({ item, index }) => ( + props.onToggle(item.key)} + /> + )} + ListEmptyComponent={ + props.pending ? ( + + ) : ( + + + {props.loadError ?