From 06cb8f612d18b3f75434ba7678ee8ebb334fd107 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:38:43 -0800 Subject: [PATCH] feat(mobile): add the Issues tab and issue screens Mobile gets a fourth tab that lists issues across environments, and an issue screen with the same reach as the web panel: description, activity, linked pull requests, reactions, comments, state changes, title and description edits, labels, assignees, and new issues with templates. The list grouping, paging and permission rules move from the web page into client-runtime so both clients share them. Co-Authored-By: Claude Fable 5.2 --- apps/mobile/assets/icons/issue.png | Bin 0 -> 393 bytes apps/mobile/assets/icons/issue@2x.png | Bin 0 -> 820 bytes apps/mobile/assets/icons/issue@3x.png | Bin 0 -> 1192 bytes .../T3WorkspaceNavigationModule.kt | 4 +- .../src/main/res/drawable/t3_issues.xml | 3 + .../ios/Resources/Icons/issue.png | Bin 0 -> 393 bytes .../ios/Resources/Icons/issue@2x.png | Bin 0 -> 820 bytes .../ios/Resources/Icons/issue@3x.png | Bin 0 -> 1192 bytes .../ios/T3NativeControls.podspec | 2 +- .../ios/T3SidebarTabBarView.swift | 7 +- apps/mobile/src/Stack.tsx | 26 + .../mobile/src/components/ComposerSurface.tsx | 249 +++++++ .../src/components/ui/AppSymbol.android.tsx | 3 + apps/mobile/src/components/ui/AppSymbol.tsx | 2 + .../features/issues/IssueAssigneesScreen.tsx | 78 ++ .../features/issues/IssueCandidatesSheet.tsx | 125 ++++ .../features/issues/IssueCommentComposer.tsx | 247 +++++++ .../src/features/issues/IssueCreateScreen.tsx | 243 +++++++ .../src/features/issues/IssueEditScreen.tsx | 99 +++ .../src/features/issues/IssueLabelsScreen.tsx | 74 ++ .../src/features/issues/IssueListRow.tsx | 99 +++ .../src/features/issues/IssueRouteScreen.tsx | 665 ++++++++++++++++++ .../src/features/issues/IssuesScreen.tsx | 366 ++++++++++ .../mobile/src/features/issues/issueParams.ts | 78 ++ .../src/features/issues/issuePresentation.tsx | 90 +++ .../features/issues/useIssueCreateScopes.ts | 40 ++ .../features/layout/WorkspaceEmptyDetail.tsx | 15 +- .../createWorkspaceStackNavigator.ios.tsx | 19 +- .../AndroidWorkspaceSidebar.android.tsx | 23 +- ...roid.tsx => ListSearchToolbar.android.tsx} | 43 +- .../layout/workspace/WorkspaceExtraTab.tsx | 4 +- .../workspace/WorkspaceNativeTabs.ios.tsx | 15 +- .../workspace/WorkspaceRootScreen.android.tsx | 20 +- .../layout/workspace/WorkspaceRootScreen.tsx | 6 +- .../workspace/WorkspaceSectionToolbar.tsx | 5 +- .../layout/workspace/WorkspaceTabsContext.tsx | 1 + .../layout/workspace/WorkspaceToolbar.tsx | 20 +- .../workspace/android-workspace-tabs.ts | 2 +- .../workspace/navigation-tab-order.test.ts | 8 +- .../layout/workspace/navigation-tab-order.ts | 3 +- .../layout/workspace/navigation-tabs.ts | 1 + .../layout/workspace/useWorkspaceCompose.ts | 24 + .../workspace/workspace-detail-selection.ts | 9 +- .../workspace/workspace-selection-store.ts | 11 +- .../workspace-tab-navigation.test.ts | 4 +- .../workspace/workspace-tab-navigation.ts | 52 +- .../workspace/workspace-tabs-state.test.ts | 10 +- .../layout/workspace/workspace-tabs-state.ts | 8 +- .../pull-requests/PullRequestListRow.tsx | 49 +- .../pull-requests/PullRequestReactions.tsx | 48 +- .../src/features/threads/ThreadComposer.tsx | 215 +----- .../features/threads/ThreadDetailScreen.tsx | 35 +- .../src/lib/android-workspace-navigation.ts | 3 +- .../src/native/v5-workspace-context.tsx | 1 + .../src/persistence/mobile-preferences.ts | 11 + apps/mobile/src/state/issues.ts | 29 + apps/mobile/src/state/query.ts | 8 +- .../components/issue/IssueCreateDialog.tsx | 10 +- .../src/components/issue/IssueDetailPanel.tsx | 67 +- apps/web/src/components/issue/IssueRow.tsx | 6 +- apps/web/src/components/issue/IssuesPage.tsx | 185 +---- .../components/issue/issuePresentation.tsx | 18 +- apps/web/src/state/issues.ts | 73 +- packages/client-runtime/package.json | 8 + packages/client-runtime/src/issueList.ts | 174 +++++ .../client-runtime/src/issuePresentation.ts | 69 ++ packages/client-runtime/src/state/issues.ts | 75 +- vite.config.ts | 1 + 68 files changed, 3218 insertions(+), 670 deletions(-) create mode 100644 apps/mobile/assets/icons/issue.png create mode 100644 apps/mobile/assets/icons/issue@2x.png create mode 100644 apps/mobile/assets/icons/issue@3x.png create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/t3_issues.xml create mode 100644 apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue.png create mode 100644 apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@2x.png create mode 100644 apps/mobile/modules/t3-native-controls/ios/Resources/Icons/issue@3x.png create mode 100644 apps/mobile/src/components/ComposerSurface.tsx create mode 100644 apps/mobile/src/features/issues/IssueAssigneesScreen.tsx create mode 100644 apps/mobile/src/features/issues/IssueCandidatesSheet.tsx create mode 100644 apps/mobile/src/features/issues/IssueCommentComposer.tsx create mode 100644 apps/mobile/src/features/issues/IssueCreateScreen.tsx create mode 100644 apps/mobile/src/features/issues/IssueEditScreen.tsx create mode 100644 apps/mobile/src/features/issues/IssueLabelsScreen.tsx create mode 100644 apps/mobile/src/features/issues/IssueListRow.tsx create mode 100644 apps/mobile/src/features/issues/IssueRouteScreen.tsx create mode 100644 apps/mobile/src/features/issues/IssuesScreen.tsx create mode 100644 apps/mobile/src/features/issues/issueParams.ts create mode 100644 apps/mobile/src/features/issues/issuePresentation.tsx create mode 100644 apps/mobile/src/features/issues/useIssueCreateScopes.ts rename apps/mobile/src/features/layout/workspace/{PullRequestToolbar.android.tsx => ListSearchToolbar.android.tsx} (69%) create mode 100644 apps/mobile/src/features/layout/workspace/useWorkspaceCompose.ts create mode 100644 apps/mobile/src/state/issues.ts create mode 100644 packages/client-runtime/src/issueList.ts create mode 100644 packages/client-runtime/src/issuePresentation.ts diff --git a/apps/mobile/assets/icons/issue.png b/apps/mobile/assets/icons/issue.png new file mode 100644 index 0000000000000000000000000000000000000000..d90e5053a9395de2365081b336e3905b281229c3 GIT binary patch literal 393 zcmV;40e1e0P)!!D7O~m;(K0n+8YG;NPZ!X+$+Xx#G)aL~}ZaFI@77HKuWEy5`EgeND3zt~(_nsr5}C znnP98Qc-=kRA}==lb&H*X+HVWVyETW6D#TS3soDECuN@bB5s^iVw#+%>4&3Y8n$bf n;YKKJ*?v1jQi;@mfUiYS{2daA?w}RdgotJD zgRLTuux`<+E5)CoVqF+f6A3j&TNBe*!3};lIW^?u%sBTkQ)1>MUot6kzVqEX_nvd^ zC6S0Nw%A-)FE9ek0gr(i@EzC%e$#V}{#K%Qdk=!}lfXPs2lh0v4cq~8-4Nantk4Ya zSwe9v0-319Uj~{EgmW~2@i2uafmI&?KY%h&0P^&eluwGDhk+tcp?AG_mdVelrGe)j z0p9`>z+p|kN#LCa=Q_RPZ005LKY?kW&ysrvXbb$$EV=)hv~1MO4LSV6r+`f%&Y~^x z6@l~xkPTsvDbANdyfK?p*%W5JKg2|l6~4qb(-gZRtofk`()4LzPuw@Du)Ii3M_4GY zv)DVj6?!rGA*+~oSrbJf?=Ee0QgK;Gi;l$GA)mSIK46XY)pL9qubOS#CgW?I*1bweoQmy znop-$F(_=jsQuG;{!eKJ{o*r#x+d2lp&tDI187(-p^DD{%26z#n&l?W36s#-#Dkh# z7Z}HeNg6F0q`w?}i8`TdAYR!N(^3uMRIB#*ClDk`anmsrxOjIz_4|toQxm(_vh7EctYrEjq(a(Mc*xGn}Gl z_1iUir{%-55cU<$7`3&o|G}ZM&1h7GCPUYKI}U^^j(aw#(#8g<#BUi0S3Wll+id67 yLkFK(YJoSX2<{RZ^jnqQ$?G&`vBegfxBUTw_43kT)eN%$0000Boz6#2zr{DW*d!Kg;*_0%krxP=&x`0opwb%)c;Z z)c&9b=K|oTkHOi%GN3d_f!PkYV{;H+0Udw}z!^X`U>9H;AcTJ(#GT7{rxWnarj2Gm zqTj(u1H7>4@DD%(AlHSxW2j@^qRm0T4!?qv33y|{$cskvxb`0 z-6p;@$#&wri31M+TYcO#wg6g8e7q>j_+FDdrzJqCN2^JmlqJLHBf{tt{Pcn%hA&SE ze>-H3u$4(D?C>`-mRSMZajKrM3j+2V)k5Dpn$$f_@6JrKtD(M z7eyF8u{x`VgX z=PDSW<%R+Qx+VMIkXo!)@pw0=a)ekShC~{oj-RLeY?NelgXuKmH!tLi zHrPo7sXKt}FBDtwkV)#IUnY#U1}I~`OZd6OBwmCmWx;R3Giz!;C)9|T9eW& zkZ`xXa3m2s*w;-K?i`PIt7#LTA_D1ROUc)Sh5KCf_%4&!wNXbD)f;%rJZvP7FGD6R zFoLsK(SnRYUgh;N=@=4udw9BR?b}9Zf?g^-mfc}`mL%*6yvkw+ zcCT3MXDAlpewtz)pBX2f_gL)Qsz@;&T#FVhTC`~KS^faPWMZg_+;>m_0000 "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 0000000000000000000000000000000000000000..d90e5053a9395de2365081b336e3905b281229c3 GIT binary patch literal 393 zcmV;40e1e0P)!!D7O~m;(K0n+8YG;NPZ!X+$+Xx#G)aL~}ZaFI@77HKuWEy5`EgeND3zt~(_nsr5}C znnP98Qc-=kRA}==lb&H*X+HVWVyETW6D#TS3soDECuN@bB5s^iVw#+%>4&3Y8n$bf n;YKKJ*?v1jQi;@mfUiYS{2daA?w}RdgotJD zgRLTuux`<+E5)CoVqF+f6A3j&TNBe*!3};lIW^?u%sBTkQ)1>MUot6kzVqEX_nvd^ zC6S0Nw%A-)FE9ek0gr(i@EzC%e$#V}{#K%Qdk=!}lfXPs2lh0v4cq~8-4Nantk4Ya zSwe9v0-319Uj~{EgmW~2@i2uafmI&?KY%h&0P^&eluwGDhk+tcp?AG_mdVelrGe)j z0p9`>z+p|kN#LCa=Q_RPZ005LKY?kW&ysrvXbb$$EV=)hv~1MO4LSV6r+`f%&Y~^x z6@l~xkPTsvDbANdyfK?p*%W5JKg2|l6~4qb(-gZRtofk`()4LzPuw@Du)Ii3M_4GY zv)DVj6?!rGA*+~oSrbJf?=Ee0QgK;Gi;l$GA)mSIK46XY)pL9qubOS#CgW?I*1bweoQmy znop-$F(_=jsQuG;{!eKJ{o*r#x+d2lp&tDI187(-p^DD{%26z#n&l?W36s#-#Dkh# z7Z}HeNg6F0q`w?}i8`TdAYR!N(^3uMRIB#*ClDk`anmsrxOjIz_4|toQxm(_vh7EctYrEjq(a(Mc*xGn}Gl z_1iUir{%-55cU<$7`3&o|G}ZM&1h7GCPUYKI}U^^j(aw#(#8g<#BUi0S3Wll+id67 yLkFK(YJoSX2<{RZ^jnqQ$?G&`vBegfxBUTw_43kT)eN%$0000Boz6#2zr{DW*d!Kg;*_0%krxP=&x`0opwb%)c;Z z)c&9b=K|oTkHOi%GN3d_f!PkYV{;H+0Udw}z!^X`U>9H;AcTJ(#GT7{rxWnarj2Gm zqTj(u1H7>4@DD%(AlHSxW2j@^qRm0T4!?qv33y|{$cskvxb`0 z-6p;@$#&wri31M+TYcO#wg6g8e7q>j_+FDdrzJqCN2^JmlqJLHBf{tt{Pcn%hA&SE ze>-H3u$4(D?C>`-mRSMZajKrM3j+2V)k5Dpn$$f_@6JrKtD(M z7eyF8u{x`VgX z=PDSW<%R+Qx+VMIkXo!)@pw0=a)ekShC~{oj-RLeY?NelgXuKmH!tLi zHrPo7sXKt}FBDtwkV)#IUnY#U1}I~`OZd6OBwmCmWx;R3Giz!;C)9|T9eW& zkZ`xXa3m2s*w;-K?i`PIt7#LTA_D1ROUc)Sh5KCf_%4&!wNXbD)f;%rJZvP7FGD6R zFoLsK(SnRYUgh;N=@=4udw9BR?b}9Zf?g^-mfc}`mL%*6yvkw+ zcCT3MXDAlpewtz)pBX2f_gL)Qsz@;&T#FVhTC`~KS^faPWMZg_+;>m_0000 '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 ?