| 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { LegendList } from "@legendapp/list/react-native"; |
| 3 | import { type StaticScreenProps, useNavigation } from "@react-navigation/native"; |
| 4 | import { |
| 5 | filterThirdPartyLicenseEntries, |
| 6 | findThirdPartyLicenseEntry, |
| 7 | formatLicenseBundles, |
| 8 | thirdPartyLicenseEntryKey, |
| 9 | type ThirdPartyLicenseEntry, |
| 10 | } from "@t3tools/shared/thirdPartyLicenses"; |
| 11 | import { useCallback, useMemo, useState } from "react"; |
| 12 | import { Linking, Platform, Pressable, View } from "react-native"; |
| 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 14 | |
| 15 | import { SymbolView } from "../../components/AppSymbol"; |
| 16 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 17 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 18 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 19 | import { |
| 20 | createNativeMailSearchToolbarItem, |
| 21 | NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, |
| 22 | } from "../layout/native-mail-search-toolbar"; |
| 23 | import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar"; |
| 24 | |
| 25 | import { getMobileThirdPartyLicenses } from "./mobileThirdPartyLicenses"; |
| 26 | |
| 27 | function useMobileThirdPartyLicenses() { |
| 28 | return useMemo(() => { |
| 29 | try { |
| 30 | return getMobileThirdPartyLicenses(); |
| 31 | } catch { |
| 32 | return null; |
| 33 | } |
| 34 | }, []); |
| 35 | } |
| 36 | |
| 37 | function 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 | |
| 71 | export 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 | |
| 193 | type LicenseDetailProps = StaticScreenProps<{ readonly entryKey: string }>; |
| 194 | |
| 195 | export 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 | } |