1import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
2import { useCallback, useMemo, useState, type ReactNode } from "react";
3import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";
4import {
5 Tabs,
6 type TabSelectedEvent,
7 type TabSelectionPreventedEvent,
8 type TabSelectionRejectedEvent,
9} from "react-native-screens";
10
11import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
12import type { WorkspaceTab } from "./workspace-tabs-state";
13import { NativeWorkspaceTabActivityContext } from "../../native/v5-workspace-context";
14import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
15
16// Tabler's git-pull-request geometry, rasterized at 1x/2x/3x for UIKit template images.
17const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png");
18const COMPOSE_TAB = "compose";
19
20/** Retain each native controller tree; removing its root can leave RNS's stack empty. */
21function RetainedWorkspaceTab(props: {
22 readonly tab: WorkspaceTab;
23 readonly active: boolean;
24 readonly children: ReactNode;
25}) {
26 const workspace = useWorkspaceTabs()!;
27 const { isTabActive } = workspace;
28 const isActive = useCallback(() => isTabActive(props.tab), [isTabActive, props.tab]);
29 const activity = useMemo(
30 () => ({
31 active: props.active,
32 isActive,
33 }),
34 [props.active, isActive],
35 );
36 const [content, setContent] = useState(
37 props.active ? { workspace, children: props.children } : null,
38 );
39 if (props.active && (content?.workspace !== workspace || content?.children !== props.children))
40 setContent({ workspace, children: props.children });
41 return content ? (
42 <NativeWorkspaceTabActivityContext value={activity}>
43 <WorkspaceTabsContext value={content.workspace}>{content.children}</WorkspaceTabsContext>
44 </NativeWorkspaceTabActivityContext>
45 ) : null;
46}
47
48/**
49 * UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class.
50 * On phones, compose is UIKit's own search-role tab, detached like Photos' search button; it never
51 * becomes selected and opens a new chat instead.
52 */
53export function WorkspaceNativeTabs(props: {
54 readonly children: ReactNode;
55 readonly hidden: boolean;
56 readonly onSelect: () => void;
57 readonly onNewTask: () => void;
58}) {
59 const workspace = useWorkspaceTabs()!;
60 const metrics = useNativeLayoutMetrics();
61 const phoneBar =
62 Platform.OS === "ios" && !Platform.isPad && (metrics?.verticalBarEdge ?? "none") === "none";
63 const [provenance, setProvenance] = useState(0);
64 const systemTabBar = useSystemWorkspaceTabBar();
65 const selectedTab = workspace.state.selectedTab;
66 const request = useMemo(
67 () => ({ selectedScreenKey: selectedTab, baseProvenance: provenance }),
68 [selectedTab, provenance],
69 );
70 return (
71 <View style={{ flex: 1 }}>
72 <Tabs.Host
73 navStateRequest={request}
74 tabBarHidden={props.hidden || !systemTabBar}
75 ios={{ tabBarControllerMode: "tabBar", tabBarMinimizeBehavior: "never" }}
76 onTabSelectionPrevented={({
77 nativeEvent,
78 }: NativeSyntheticEvent<TabSelectionPreventedEvent>) => {
79 if (nativeEvent.preventedScreenKey === COMPOSE_TAB) props.onNewTask();
80 }}
81 onTabSelectionRejected={({
82 nativeEvent,
83 }: NativeSyntheticEvent<TabSelectionRejectedEvent>) => {
84 setProvenance(nativeEvent.provenance);
85 }}
86 onTabSelected={({ nativeEvent }: NativeSyntheticEvent<TabSelectedEvent>) => {
87 const tab = nativeEvent.selectedScreenKey;
88 if (tab !== "threads" && tab !== "pullRequests") return;
89 setProvenance(nativeEvent.provenance);
90 if (nativeEvent.actionOrigin === "programmatic-js" || tab === selectedTab) return;
91 Keyboard.dismiss();
92 props.onSelect();
93 workspace.dispatch({ type: "select", tab });
94 }}
95 >
96 {(["threads", "pullRequests"] as const).map((tab: WorkspaceTab) => (
97 <Tabs.Screen
98 key={tab}
99 screenKey={tab}
100 title={tab === "threads" ? "Threads" : "Pulls"}
101 tabBarItemAccessibilityLabel={tab === "threads" ? "Threads" : "Pull requests"}
102 ios={{
103 icon:
104 tab === "threads"
105 ? { type: "sfSymbol", name: "text.bubble" }
106 : { type: "templateSource", templateSource: PULL_REQUEST_ICON },
107 selectedIcon:
108 tab === "threads" ? { type: "sfSymbol", name: "text.bubble.fill" } : undefined,
109 overrideScrollViewContentInsetAdjustmentBehavior: false,
110 }}
111 >
112 <RetainedWorkspaceTab tab={tab} active={selectedTab === tab}>
113 {props.children}
114 </RetainedWorkspaceTab>
115 </Tabs.Screen>
116 ))}
117 {phoneBar ? (
118 <Tabs.Screen
119 key={COMPOSE_TAB}
120 screenKey={COMPOSE_TAB}
121 title="New chat"
122 preventNativeSelection
123 ios={{
124 role: "search",
125 // iOS 27 detaches a search tab only while it may activate search.
126 automaticallyActivatesSearch: true,
127 icon: { type: "sfSymbol", name: "square.and.pencil" },
128 }}
129 >
130 <View />
131 </Tabs.Screen>
132 ) : null}
133 </Tabs.Host>
134 </View>
135 );
136}