Skip to content
Draft
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
Binary file added apps/mobile/assets/icons/issue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/issue@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/issue@3x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -415,6 +415,7 @@ class T3WorkspaceNavigationView(
private fun tabTitle(tab: String) = when (tab) {
"threads" -> "Chats"
"pullRequests" -> "Pulls"
"issues" -> "Issues"
"usage" -> "Usage"
else -> "Settings"
}
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20ZM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16ZM12,10a2,2 0 1,0 0,4a2,2 0 1,0 0,-4Z" />
</vector>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }
Expand Down Expand Up @@ -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)"
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"] },
Expand Down
249 changes: 249 additions & 0 deletions apps/mobile/src/components/ComposerSurface.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Animated.View
className={
Platform.OS === "android" ? undefined : "shadow-[0_6px_28px] shadow-adaptive-black-a15-a35"
}
layout={layoutTransition}
style={[
animatedShapeStyle,
{
overflow: "hidden",
},
]}
>
<AnimatedGlassSurface
chrome="none"
fallbackColor={colors["--color-composer-surface"]}
fallbackClassName="border border-composer-border"
glassEffectStyle="regular"
pointerEvents="none"
tintColor="transparent"
layout={layoutTransition}
style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]}
>
{null}
</AnimatedGlassSurface>
<Animated.View
collapsable={false}
layout={layoutTransition}
style={[props.style, animatedShapeStyle]}
>
{props.children}
</Animated.View>
</Animated.View>
);
}

/** 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"] };
}
3 changes: 3 additions & 0 deletions apps/mobile/src/components/ui/AppSymbol.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/ui/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
};

Expand Down
Loading
Loading