1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { LegendList } from "@legendapp/list/react-native";
3import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
4import {
5 filterThirdPartyLicenseEntries,
6 findThirdPartyLicenseEntry,
7 formatLicenseBundles,
8 thirdPartyLicenseEntryKey,
9 type ThirdPartyLicenseEntry,
10} from "@t3tools/shared/thirdPartyLicenses";
11import { useCallback, useMemo, useState } from "react";
12import { Linking, Platform, Pressable, View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
14
15import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { SettingsScreen } from "./components/SettingsScreen";
18import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
19import {
20 createNativeMailSearchToolbarItem,
21 NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
22} from "../layout/native-mail-search-toolbar";
23import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
24
25import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses";
26
27function useMobileThirdPartyLicenses() {
28 return useMemo(() => {
29 try {
30 return getMobileThirdPartyLicenses();
31 } catch {
32 return null;
33 }
34 }, []);
35}
36
37function LicenseRow(props: {
38 readonly entry: ThirdPartyLicenseEntry;
39 readonly onPress: () => void;
40}) {
41 return (
42 <Pressable
43 accessibilityHint="Opens the complete license notice"
44 accessibilityLabel={`${props.entry.name}, ${props.entry.license}`}
45 accessibilityRole="button"
46 onPress={props.onPress}
47 className="border-b border-border bg-grouped-card px-5 py-4 active:bg-card-alt"
48 >
49 <View className="flex-row items-start gap-3">
50 <View className="min-w-0 flex-1 gap-1">
51 <Text className="text-base font-t3-medium text-foreground" numberOfLines={2}>
52 {props.entry.name}
53 </Text>
54 <Text className="text-sm text-foreground-muted" numberOfLines={2}>
55 {props.entry.version ? `${props.entry.version} · ` : ""}
56 {props.entry.license}
57 </Text>
58 </View>
59 <SymbolView
60 name="chevron.right"
61 size={16}
62 tintColorClassName={"accent-chevron"}
63 type="monochrome"
64 weight="semibold"
65 />
66 </View>
67 </Pressable>
68 );
69}
70
71export function SettingsOpenSourceLicensesRouteScreen() {
72 const navigation = useNavigation();
73 const insets = useSafeAreaInsets();
74 const [query, setQuery] = useState("");
75 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
76 const manifest = useMobileThirdPartyLicenses();
77 const entries = manifest?.entries ?? [];
78 const filteredEntries = useMemo(
79 () => filterThirdPartyLicenseEntries(entries, query),
80 [entries, query],
81 );
82 const renderItem = useCallback(
83 ({ item }: { readonly item: ThirdPartyLicenseEntry }) => (
84 <LicenseRow
85 entry={item}
86 onPress={() =>
87 navigation.navigate("SettingsSheet", {
88 screen: "SettingsContent",
89 params: {
90 screen: "SettingsOpenSourceLicense",
91 params: { entryKey: thirdPartyLicenseEntryKey(item) },
92 },
93 })
94 }
95 />
96 ),
97 [navigation],
98 );
99
100 if (!manifest) {
101 return (
102 <SettingsScreen title="Open source licenses">
103 <View className="flex-1 items-center justify-center px-6">
104 <Text className="text-center text-base text-foreground-muted">
105 License notices are unavailable in this build.
106 </Text>
107 </View>
108 </SettingsScreen>
109 );
110 }
111
112 return (
113 <SettingsScreen title="Open source licenses">
114 {Platform.OS === "ios" ? (
115 <NativeStackScreenOptions
116 options={{
117 unstable_headerToolbarItems: usesNativeMailSearchToolbar
118 ? () => [
119 createNativeMailSearchToolbarItem({
120 onSearchTextChange: setQuery,
121 placeholder: "Search packages",
122 searchTextChangeId: "open-source-licenses-search-text",
123 showsSearchDismissButton: true,
124 }),
125 ]
126 : undefined,
127 headerSearchBarOptions: usesNativeMailSearchToolbar
128 ? undefined
129 : {
130 allowToolbarIntegration: true,
131 autoCapitalize: "none",
132 hideNavigationBar: false,
133 hideWhenScrolling: false,
134 obscureBackground: false,
135 onCancelButtonPress: () => setQuery(""),
136 onChangeText: (event) => setQuery(event.nativeEvent.text),
137 placeholder: "Search packages",
138 },
139 }}
140 />
141 ) : null}
142 {Platform.OS === "ios" && !usesNativeMailSearchToolbar ? (
143 <NativeHeaderToolbar placement="bottom">
144 <NativeHeaderToolbar.SearchBarSlot />
145 </NativeHeaderToolbar>
146 ) : null}
147
148 <LegendList
149 className="flex-1"
150 contentContainerStyle={{
151 paddingBottom: usesNativeMailSearchToolbar
152 ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET + 18
153 : Platform.OS === "ios"
154 ? 18
155 : Math.max(insets.bottom, 18) + 18,
156 }}
157 contentInsetAdjustmentBehavior="automatic"
158 data={filteredEntries}
159 estimatedItemSize={78}
160 keyboardDismissMode="on-drag"
161 keyboardShouldPersistTaps="handled"
162 keyExtractor={thirdPartyLicenseEntryKey}
163 ListEmptyComponent={
164 <View className="items-center px-6 py-12">
165 <Text className="text-center text-base text-foreground-muted">
166 No licenses match that search.
167 </Text>
168 </View>
169 }
170 ListHeaderComponent={
171 Platform.OS !== "ios" ? (
172 <View className="px-5 pt-4 pb-5">
173 <TextInput
174 accessibilityLabel="Search open-source licenses"
175 autoCapitalize="none"
176 autoCorrect={false}
177 clearButtonMode="while-editing"
178 onChangeText={setQuery}
179 placeholder="Search packages"
180 returnKeyType="search"
181 value={query}
182 />
183 </View>
184 ) : null
185 }
186 renderItem={renderItem}
187 showsVerticalScrollIndicator={false}
188 />
189 </SettingsScreen>
190 );
191}
192
193type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>;
194
195export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailProps) {
196 const insets = useSafeAreaInsets();
197 const manifest = useMobileThirdPartyLicenses();
198 const entry = manifest
199 ? findThirdPartyLicenseEntry(manifest.entries, route.params.entryKey)
200 : undefined;
201 const sourceUrl = entry?.sourceUrl?.match(/^https?:\/\//) ? entry.sourceUrl : null;
202
203 if (!entry) {
204 return (
205 <SettingsScreen title="License notice">
206 <View className="flex-1 items-center justify-center px-6">
207 <Text className="text-center text-base text-foreground-muted">
208 This license notice is unavailable.
209 </Text>
210 </View>
211 </SettingsScreen>
212 );
213 }
214
215 return (
216 <SettingsScreen title="License notice">
217 <ScrollView
218 className="flex-1"
219 contentInsetAdjustmentBehavior="automatic"
220 contentContainerClassName="gap-5 px-5 pt-4"
221 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
222 showsVerticalScrollIndicator={false}
223 >
224 <View className="gap-2 px-1">
225 <Text className="text-2xl font-t3-bold text-foreground">{entry.name}</Text>
226 <Text className="text-base leading-normal text-foreground-muted">
227 {[entry.version, entry.license, formatLicenseBundles(entry.bundles)]
228 .filter((value): value is string => Boolean(value))
229 .join(" · ")}
230 </Text>
231 {sourceUrl ? (
232 <Pressable
233 accessibilityHint="Opens the project website"
234 accessibilityRole="link"
235 onPress={() => void Linking.openURL(sourceUrl)}
236 className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60"
237 >
238 <Text className="font-t3-medium text-primary-text">Project source</Text>
239 <SymbolView
240 name="arrow.up.right"
241 size={16}
242 tintColorClassName={"accent-primary-text"}
243 type="monochrome"
244 weight="semibold"
245 />
246 </Pressable>
247 ) : null}
248 </View>
249
250 <View className="overflow-hidden rounded-[24px] border-continuous bg-grouped-card p-4">
251 <Text selectable className="font-mono text-base leading-normal text-foreground">
252 {entry.noticeText}
253 </Text>
254 </View>
255 </ScrollView>
256 </SettingsScreen>
257 );
258}