| author | |
| committer | |
| log | ea15f17afd94e7ce7133e52f5b5983910662c9ec |
| tree | 03e4ecff0e222eee1fd24861c3c9a46f9352bfee |
| parent | 3bb06ad910389fcfc886bd5156f41dc73f307f80 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 284 insertions(+), 244 deletions(-)
apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx+1-1| ... | ... | @@ -26,7 +26,7 @@ export function CodeAppearanceSection() { |
| 26 | 26 | ); |
| 27 | 27 | |
| 28 | 28 | return ( |
| 29 | <SettingsSection card title="Code & Diffs"> | |
| 29 | <SettingsSection title="Code & Diffs"> | |
| 30 | 30 | <CodeAppearancePreview |
| 31 | 31 | fontSize={appearance.codeFontSize} |
| 32 | 32 | wordBreak={appearance.codeWordBreak} |
apps/mobile/src/features/settings/appearance/sections/TerminalAppearanceSection.tsx+1-1| ... | ... | @@ -26,7 +26,7 @@ export function TerminalAppearanceSection() { |
| 26 | 26 | ); |
| 27 | 27 | |
| 28 | 28 | return ( |
| 29 | <SettingsSection card title="Terminal"> | |
| 29 | <SettingsSection title="Terminal"> | |
| 30 | 30 | <TerminalAppearancePreview fontSize={appearance.terminalFontSize} /> |
| 31 | 31 | <AppearancePreviewSeparator /> |
| 32 | 32 | <SettingsSwitchRow |
apps/mobile/src/features/settings/appearance/sections/TextAppearanceSection.tsx+1-1| ... | ... | @@ -15,7 +15,7 @@ export function TextAppearanceSection() { |
| 15 | 15 | const { isReady, appearance, setBaseFontSize } = useAppearancePreferences(); |
| 16 | 16 | |
| 17 | 17 | return ( |
| 18 | <SettingsSection card title="Text"> | |
| 18 | <SettingsSection title="Text"> | |
| 19 | 19 | <TextAppearancePreview fontSize={appearance.baseFontSize} /> |
| 20 | 20 | <AppearancePreviewSeparator /> |
| 21 | 21 | <FontSizeSliderRow |
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-5| ... | ... | @@ -7,8 +7,6 @@ export function SettingsSection(props: { |
| 7 | 7 | readonly title?: string; |
| 8 | 8 | readonly trailing?: ReactNode; |
| 9 | 9 | readonly children: ReactNode; |
| 10 | /** Force the grouped card background; Android otherwise lists options flat. */ | |
| 11 | readonly card?: boolean; | |
| 12 | 10 | }) { |
| 13 | 11 | return ( |
| 14 | 12 | <View className="gap-2"> |
| ... | ... | @@ -30,9 +28,7 @@ export function SettingsSection(props: { |
| 30 | 28 | className={ |
| 31 | 29 | Platform.OS === "android" |
| 32 | 30 | ? "overflow-hidden rounded-[28px] bg-card" |
| 33 | : props.card | |
| 34 | ? "overflow-hidden rounded-[24px] border-continuous bg-card" | |
| 35 | : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent" | |
| 31 | : "overflow-hidden rounded-[24px] border-continuous bg-card" | |
| 36 | 32 | } |
| 37 | 33 | > |
| 38 | 34 | {props.children} |
apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx+4-8| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android"; |
| 2 | 2 | import { |
| 3 | 3 | BasicAlertDialog, |
| 4 | Button, | |
| 5 | 4 | Column, |
| 6 | 5 | DateTimePicker, |
| 7 | 6 | Host, |
| ... | ... | @@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) { |
| 240 | 239 | <Row horizontalArrangement="end" modifiers={[fillMaxWidth()]}> |
| 241 | 240 | <TextButton |
| 242 | 241 | onClick={props.onClose} |
| 243 | colors={{ contentColor: colors["--color-primary"] }} | |
| 242 | colors={{ contentColor: colors["--color-foreground"] }} | |
| 244 | 243 | > |
| 245 | 244 | <Text style={bodyTypography}>Cancel</Text> |
| 246 | 245 | </TextButton> |
| 247 | <Button | |
| 246 | <TextButton | |
| 248 | 247 | onClick={submit} |
| 249 | colors={{ | |
| 250 | containerColor: colors["--color-primary"], | |
| 251 | contentColor: colors["--color-primary-foreground"], | |
| 252 | }} | |
| 248 | colors={{ contentColor: colors["--color-foreground"] }} | |
| 253 | 249 | > |
| 254 | 250 | <Text style={bodyTypography}>Snooze</Text> |
| 255 | </Button> | |
| 251 | </TextButton> | |
| 256 | 252 | </Row> |
| 257 | 253 | </Column> |
| 258 | 254 | </Column> |
apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx+172-124| ... | ... | @@ -1,27 +1,22 @@ |
| 1 | 1 | import { |
| 2 | Button, | |
| 3 | 2 | DatePicker, |
| 4 | 3 | Host, |
| 5 | 4 | HStack, |
| 6 | 5 | Picker, |
| 7 | 6 | Popover, |
| 7 | RNHostView, | |
| 8 | 8 | Spacer, |
| 9 | 9 | Text, |
| 10 | 10 | VStack, |
| 11 | 11 | } from "@expo/ui/swift-ui"; |
| 12 | 12 | import { |
| 13 | accessibilityAddTraits, | |
| 14 | 13 | accessibilityHidden, |
| 15 | buttonBorderShape, | |
| 16 | buttonStyle, | |
| 17 | 14 | clipped, |
| 18 | controlSize, | |
| 19 | 15 | font, |
| 20 | 16 | datePickerStyle, |
| 21 | 17 | foregroundStyle, |
| 22 | 18 | frame, |
| 23 | 19 | labelsHidden, |
| 24 | labelStyle, | |
| 25 | 20 | padding, |
| 26 | 21 | pickerStyle, |
| 27 | 22 | tag, |
| ... | ... | @@ -32,12 +27,17 @@ import { |
| 32 | 27 | resolveCustomSnooze, |
| 33 | 28 | type CustomSnoozeInput, |
| 34 | 29 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 35 | import { useState } from "react"; | |
| 36 | import { useWindowDimensions } from "react-native"; | |
| 30 | import { useState, type ReactNode } from "react"; | |
| 31 | import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; | |
| 32 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | |
| 33 | import { ScrollView, useWindowDimensions, View } from "react-native"; | |
| 34 | import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; | |
| 35 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 37 | 36 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 38 | 37 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 39 | 38 | |
| 40 | 39 | const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); |
| 40 | const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>(); | |
| 41 | 41 | const modes = [ |
| 42 | 42 | { value: "date", label: "Date and time" }, |
| 43 | 43 | { value: "duration", label: "Duration" }, |
| ... | ... | @@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: { |
| 52 | 52 | readonly onClose: () => void; |
| 53 | 53 | readonly onSnooze: (snoozedUntil: string) => void; |
| 54 | 54 | }) { |
| 55 | const { width } = useWindowDimensions(); | |
| 55 | const { width, height } = useWindowDimensions(); | |
| 56 | 56 | const [mode, setMode] = useState<CustomSnoozeInput["mode"]>("date"); |
| 57 | 57 | const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); |
| 58 | 58 | const [amount, setAmount] = useState(2); |
| 59 | 59 | const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); |
| 60 | 60 | const [error, setError] = useState<string | null>(null); |
| 61 | 61 | const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); |
| 62 | const popoverWidth = Math.min(360, width - 32); | |
| 63 | const popoverHeight = Math.min(error ? 364 : 324, height - 96); | |
| 62 | 64 | const updateDate = (value: Date) => { |
| 63 | 65 | setDate(value); |
| 64 | 66 | setError(null); |
| ... | ... | @@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: { |
| 98 | 100 | /> |
| 99 | 101 | </Popover.Trigger> |
| 100 | 102 | <Popover.Content> |
| 101 | <VStack | |
| 102 | spacing={16} | |
| 103 | modifiers={[padding({ all: 16 }), frame({ width: Math.min(360, width - 32) })]} | |
| 103 | <SnoozePopoverNavigation | |
| 104 | width={popoverWidth} | |
| 105 | height={popoverHeight} | |
| 106 | onClose={props.onClose} | |
| 107 | onSubmit={submit} | |
| 104 | 108 | > |
| 105 | <HStack spacing={12}> | |
| 106 | <Button | |
| 107 | label="Cancel" | |
| 108 | systemImage="xmark" | |
| 109 | role="cancel" | |
| 110 | onPress={props.onClose} | |
| 111 | modifiers={[ | |
| 112 | labelStyle("iconOnly"), | |
| 113 | buttonStyle(NATIVE_LIQUID_GLASS_SUPPORTED ? "glass" : "bordered"), | |
| 114 | buttonBorderShape("circle"), | |
| 115 | controlSize("large"), | |
| 116 | ]} | |
| 117 | /> | |
| 118 | <Text | |
| 119 | modifiers={[ | |
| 120 | font({ textStyle: "headline" }), | |
| 121 | frame({ maxWidth: Infinity, alignment: "leading" }), | |
| 122 | accessibilityAddTraits(["isHeader"]), | |
| 123 | ]} | |
| 124 | > | |
| 125 | Custom snooze | |
| 126 | </Text> | |
| 127 | <Button | |
| 128 | label="Snooze" | |
| 129 | onPress={submit} | |
| 130 | modifiers={[ | |
| 131 | buttonStyle( | |
| 132 | NATIVE_LIQUID_GLASS_SUPPORTED ? "glassProminent" : "borderedProminent", | |
| 133 | ), | |
| 134 | controlSize("large"), | |
| 135 | ]} | |
| 136 | /> | |
| 137 | </HStack> | |
| 138 | <Picker | |
| 139 | label="Snooze mode" | |
| 140 | selection={mode} | |
| 141 | onSelectionChange={(value: CustomSnoozeInput["mode"]) => { | |
| 142 | setMode(value); | |
| 143 | setError(null); | |
| 144 | }} | |
| 145 | modifiers={[pickerStyle("segmented")]} | |
| 109 | <Host | |
| 110 | matchContents={{ vertical: true }} | |
| 111 | colorScheme={themeAppearance} | |
| 112 | style={{ width: popoverWidth }} | |
| 146 | 113 | > |
| 147 | {modes.map((option) => ( | |
| 148 | <Text key={option.value} modifiers={[tag(option.value)]}> | |
| 149 | {option.label} | |
| 150 | </Text> | |
| 151 | ))} | |
| 152 | </Picker> | |
| 153 | {mode === "date" ? ( | |
| 154 | <DatePicker | |
| 155 | title="Snooze until" | |
| 156 | selection={date} | |
| 157 | displayedComponents={["date", "hourAndMinute"]} | |
| 158 | onDateChange={updateDate} | |
| 159 | modifiers={[ | |
| 160 | datePickerStyle("wheel"), | |
| 161 | labelsHidden(), | |
| 162 | frame({ maxWidth: Infinity, height: 180 }), | |
| 163 | ]} | |
| 164 | /> | |
| 165 | ) : ( | |
| 166 | <HStack spacing={0}> | |
| 167 | <Picker | |
| 168 | label="Duration amount" | |
| 169 | selection={amount} | |
| 170 | onSelectionChange={(value: number) => { | |
| 171 | setAmount(value); | |
| 172 | setError(null); | |
| 173 | }} | |
| 174 | modifiers={[ | |
| 175 | pickerStyle("wheel"), | |
| 176 | labelsHidden(), | |
| 177 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), | |
| 178 | clipped(), | |
| 179 | ]} | |
| 180 | > | |
| 181 | {durationAmounts.map((value) => ( | |
| 182 | <Text | |
| 183 | key={value} | |
| 184 | modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]} | |
| 185 | > | |
| 186 | {String(value)} | |
| 187 | </Text> | |
| 188 | ))} | |
| 189 | </Picker> | |
| 114 | <VStack | |
| 115 | spacing={16} | |
| 116 | modifiers={[padding({ all: 16 }), foregroundStyle(colors["--color-foreground"])]} | |
| 117 | > | |
| 190 | 118 | <Picker |
| 191 | label="Duration unit" | |
| 192 | selection={unit} | |
| 193 | onSelectionChange={(value: typeof unit) => { | |
| 194 | setUnit(value); | |
| 119 | label="Snooze mode" | |
| 120 | selection={mode} | |
| 121 | onSelectionChange={(value: CustomSnoozeInput["mode"]) => { | |
| 122 | setMode(value); | |
| 195 | 123 | setError(null); |
| 196 | 124 | }} |
| 197 | modifiers={[ | |
| 198 | pickerStyle("wheel"), | |
| 199 | labelsHidden(), | |
| 200 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), | |
| 201 | clipped(), | |
| 202 | ]} | |
| 125 | modifiers={[pickerStyle("segmented")]} | |
| 203 | 126 | > |
| 204 | {units.map((option) => ( | |
| 205 | <Text | |
| 206 | key={option.value} | |
| 207 | modifiers={[tag(option.value), foregroundStyle(colors["--color-foreground"])]} | |
| 208 | > | |
| 127 | {modes.map((option) => ( | |
| 128 | <Text key={option.value} modifiers={[tag(option.value)]}> | |
| 209 | 129 | {option.label} |
| 210 | 130 | </Text> |
| 211 | 131 | ))} |
| 212 | 132 | </Picker> |
| 213 | </HStack> | |
| 214 | )} | |
| 215 | {error ? ( | |
| 216 | <Text | |
| 217 | modifiers={[ | |
| 218 | font({ textStyle: "footnote" }), | |
| 219 | foregroundStyle(colors["--color-danger-foreground"]), | |
| 220 | ]} | |
| 221 | > | |
| 222 | {error} | |
| 223 | </Text> | |
| 224 | ) : null} | |
| 225 | </VStack> | |
| 133 | {mode === "date" ? ( | |
| 134 | <DatePicker | |
| 135 | title="Snooze until" | |
| 136 | selection={date} | |
| 137 | displayedComponents={["date", "hourAndMinute"]} | |
| 138 | onDateChange={updateDate} | |
| 139 | modifiers={[ | |
| 140 | datePickerStyle("wheel"), | |
| 141 | labelsHidden(), | |
| 142 | frame({ maxWidth: Infinity, height: 180 }), | |
| 143 | ]} | |
| 144 | /> | |
| 145 | ) : ( | |
| 146 | <HStack spacing={0}> | |
| 147 | <Picker | |
| 148 | label="Duration amount" | |
| 149 | selection={amount} | |
| 150 | onSelectionChange={(value: number) => { | |
| 151 | setAmount(value); | |
| 152 | setError(null); | |
| 153 | }} | |
| 154 | modifiers={[ | |
| 155 | pickerStyle("wheel"), | |
| 156 | labelsHidden(), | |
| 157 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), | |
| 158 | clipped(), | |
| 159 | ]} | |
| 160 | > | |
| 161 | {durationAmounts.map((value) => ( | |
| 162 | <Text | |
| 163 | key={value} | |
| 164 | modifiers={[tag(value), foregroundStyle(colors["--color-foreground"])]} | |
| 165 | > | |
| 166 | {String(value)} | |
| 167 | </Text> | |
| 168 | ))} | |
| 169 | </Picker> | |
| 170 | <Picker | |
| 171 | label="Duration unit" | |
| 172 | selection={unit} | |
| 173 | onSelectionChange={(value: typeof unit) => { | |
| 174 | setUnit(value); | |
| 175 | setError(null); | |
| 176 | }} | |
| 177 | modifiers={[ | |
| 178 | pickerStyle("wheel"), | |
| 179 | labelsHidden(), | |
| 180 | frame({ minWidth: 0, maxWidth: Infinity, height: 180 }), | |
| 181 | clipped(), | |
| 182 | ]} | |
| 183 | > | |
| 184 | {units.map((option) => ( | |
| 185 | <Text | |
| 186 | key={option.value} | |
| 187 | modifiers={[ | |
| 188 | tag(option.value), | |
| 189 | foregroundStyle(colors["--color-foreground"]), | |
| 190 | ]} | |
| 191 | > | |
| 192 | {option.label} | |
| 193 | </Text> | |
| 194 | ))} | |
| 195 | </Picker> | |
| 196 | </HStack> | |
| 197 | )} | |
| 198 | {error ? ( | |
| 199 | <Text | |
| 200 | modifiers={[ | |
| 201 | font({ textStyle: "footnote" }), | |
| 202 | foregroundStyle(colors["--color-danger-foreground"]), | |
| 203 | ]} | |
| 204 | > | |
| 205 | {error} | |
| 206 | </Text> | |
| 207 | ) : null} | |
| 208 | </VStack> | |
| 209 | </Host> | |
| 210 | </SnoozePopoverNavigation> | |
| 226 | 211 | </Popover.Content> |
| 227 | 212 | </Popover> |
| 228 | 213 | </Host> |
| 229 | 214 | ); |
| 230 | 215 | } |
| 216 | ||
| 217 | /** The popover owns a native navigation bar, just like the app's sheet screens. */ | |
| 218 | function SnoozePopoverNavigation(props: { | |
| 219 | readonly width: number; | |
| 220 | readonly height: number; | |
| 221 | readonly onClose: () => void; | |
| 222 | readonly onSubmit: () => void; | |
| 223 | readonly children: ReactNode; | |
| 224 | }) { | |
| 225 | const navigationTheme = useMobileNavigationTheme(); | |
| 226 | const { themeVariables: colors } = useAppearancePreferences(); | |
| 227 | ||
| 228 | return ( | |
| 229 | <VStack modifiers={[frame({ width: props.width, height: props.height })]}> | |
| 230 | <RNHostView> | |
| 231 | <View style={{ width: props.width, height: props.height }}> | |
| 232 | <NavigationIndependentTree> | |
| 233 | <NavigationContainer theme={navigationTheme}> | |
| 234 | <SnoozeStack.Navigator | |
| 235 | screenOptions={{ | |
| 236 | contentStyle: { backgroundColor: "transparent" }, | |
| 237 | headerShadowVisible: false, | |
| 238 | headerStyle: { | |
| 239 | backgroundColor: NATIVE_LIQUID_GLASS_SUPPORTED | |
| 240 | ? "transparent" | |
| 241 | : colors["--color-card"], | |
| 242 | }, | |
| 243 | headerTintColor: colors["--color-foreground"], | |
| 244 | headerTitleStyle: { fontSize: 17, fontWeight: "700" }, | |
| 245 | headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, | |
| 246 | title: "Custom snooze", | |
| 247 | }} | |
| 248 | > | |
| 249 | <SnoozeStack.Screen name="CustomSnooze"> | |
| 250 | {() => ( | |
| 251 | <> | |
| 252 | <NativeHeaderToolbar placement="left"> | |
| 253 | <NativeHeaderToolbar.Button | |
| 254 | accessibilityLabel="Cancel custom snooze" | |
| 255 | icon="xmark" | |
| 256 | onPress={props.onClose} | |
| 257 | /> | |
| 258 | </NativeHeaderToolbar> | |
| 259 | <NativeHeaderToolbar placement="right"> | |
| 260 | <NativeHeaderToolbar.Button label="Snooze" onPress={props.onSubmit} /> | |
| 261 | </NativeHeaderToolbar> | |
| 262 | <ScrollView | |
| 263 | contentInsetAdjustmentBehavior="automatic" | |
| 264 | showsVerticalScrollIndicator={false} | |
| 265 | > | |
| 266 | {props.children} | |
| 267 | </ScrollView> | |
| 268 | </> | |
| 269 | )} | |
| 270 | </SnoozeStack.Screen> | |
| 271 | </SnoozeStack.Navigator> | |
| 272 | </NavigationContainer> | |
| 273 | </NavigationIndependentTree> | |
| 274 | </View> | |
| 275 | </RNHostView> | |
| 276 | </VStack> | |
| 277 | ); | |
| 278 | } |
apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx+2-2| ... | ... | @@ -152,7 +152,7 @@ export function CustomSnoozeSheet(props: { |
| 152 | 152 | </Pressable> |
| 153 | 153 | <Pressable |
| 154 | 154 | accessibilityRole="button" |
| 155 | className="min-h-12 justify-center rounded-xl bg-primary px-3" | |
| 155 | className="min-h-12 justify-center rounded-xl bg-subtle px-3" | |
| 156 | 156 | onPress={() => { |
| 157 | 157 | const input: CustomSnoozeInput = |
| 158 | 158 | mode === "date" |
| ... | ... | @@ -171,7 +171,7 @@ export function CustomSnoozeSheet(props: { |
| 171 | 171 | props.onClose(); |
| 172 | 172 | }} |
| 173 | 173 | > |
| 174 | <AppText className="text-primary-foreground">Snooze</AppText> | |
| 174 | <AppText className="text-foreground">Snooze</AppText> | |
| 175 | 175 | </Pressable> |
| 176 | 176 | </View> |
| 177 | 177 | </ScrollView> |
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+98-98| ... | ... | @@ -9,7 +9,6 @@ import type { |
| 9 | 9 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; |
| 10 | 10 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 11 | 11 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 12 | import { HeaderHeightContext } from "@react-navigation/elements"; | |
| 13 | 12 | import { |
| 14 | 13 | getProviderOptionCurrentLabel, |
| 15 | 14 | getProviderOptionCurrentValue, |
| ... | ... | @@ -31,13 +30,22 @@ import { |
| 31 | 30 | useState, |
| 32 | 31 | type ReactNode, |
| 33 | 32 | } from "react"; |
| 34 | import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native"; | |
| 33 | import { | |
| 34 | Alert, | |
| 35 | Platform, | |
| 36 | Pressable, | |
| 37 | RefreshControl, | |
| 38 | ScrollView, | |
| 39 | TextInput, | |
| 40 | View, | |
| 41 | } from "react-native"; | |
| 35 | 42 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; |
| 36 | 43 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 37 | 44 | |
| 38 | 45 | import { SymbolView } from "../../components/AppSymbol"; |
| 39 | 46 | import { AppText as Text } from "../../components/AppText"; |
| 40 | 47 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 48 | import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; | |
| 41 | 49 | import { MaterialButton } from "../../components/MaterialButton"; |
| 42 | 50 | import { MaterialIconButton } from "../../components/MaterialIconButton"; |
| 43 | 51 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| ... | ... | @@ -759,12 +767,27 @@ function ThreadSettingsMainContent(props: { |
| 759 | 767 | readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void; |
| 760 | 768 | }) { |
| 761 | 769 | const session = useThreadSettingsSession(); |
| 770 | const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 771 | reportFailure: false, | |
| 772 | }); | |
| 773 | const refreshProviderCatalog = useMemo( | |
| 774 | () => createProviderCatalogRefreshRunner(refreshProvidersCommand), | |
| 775 | [refreshProvidersCommand], | |
| 776 | ); | |
| 777 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 778 | const refreshProviders = useCallback(() => { | |
| 779 | if (!session.environmentId || isRefreshingProviders) return; | |
| 780 | setIsRefreshingProviders(true); | |
| 781 | void refreshProviderCatalog(session.environmentId).then((result) => { | |
| 782 | setIsRefreshingProviders(false); | |
| 783 | const error = providerCatalogRefreshError(result); | |
| 784 | if (error) Alert.alert("Could not refresh models", error); | |
| 785 | }); | |
| 786 | }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); | |
| 762 | 787 | const catalogItems = useThreadSettingsCatalogItems(session); |
| 763 | 788 | const [animationsReady, setAnimationsReady] = useState(false); |
| 764 | const nativeHeaderHeight = use(HeaderHeightContext) ?? 0; | |
| 765 | 789 | const hasActiveCatalogFilter = |
| 766 | 790 | session.providerFilter !== null || session.searchQuery.trim().length > 0; |
| 767 | const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED; | |
| 768 | 791 | const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>( |
| 769 | 792 | () => [ |
| 770 | 793 | ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems), |
| ... | ... | @@ -830,6 +853,7 @@ function ThreadSettingsMainContent(props: { |
| 830 | 853 | |
| 831 | 854 | return ( |
| 832 | 855 | <AnimatedLegendList |
| 856 | alwaysBounceVertical | |
| 833 | 857 | automaticallyAdjustsScrollIndicatorInsets |
| 834 | 858 | className="flex-1 bg-sheet" |
| 835 | 859 | style={ |
| ... | ... | @@ -838,7 +862,7 @@ function ThreadSettingsMainContent(props: { |
| 838 | 862 | : undefined |
| 839 | 863 | } |
| 840 | 864 | contentContainerStyle={{ paddingTop: 4 }} |
| 841 | contentInsetAdjustmentBehavior={usesTransparentNativeHeader ? "never" : "automatic"} | |
| 865 | contentInsetAdjustmentBehavior="automatic" | |
| 842 | 866 | data={listItems} |
| 843 | 867 | estimatedItemSize={Platform.OS === "android" ? 56 : 48} |
| 844 | 868 | extraData={animationsReady} |
| ... | ... | @@ -850,7 +874,6 @@ function ThreadSettingsMainContent(props: { |
| 850 | 874 | maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} |
| 851 | 875 | ListHeaderComponent={ |
| 852 | 876 | <> |
| 853 | {usesTransparentNativeHeader ? <View style={{ height: nativeHeaderHeight }} /> : null} | |
| 854 | 877 | {Platform.OS === "android" ? ( |
| 855 | 878 | <View className="px-4 pb-2 pt-3"> |
| 856 | 879 | <View |
| ... | ... | @@ -886,43 +909,16 @@ function ThreadSettingsMainContent(props: { |
| 886 | 909 | /> |
| 887 | 910 | ) : null} |
| 888 | 911 | </View> |
| 889 | <View className="flex-row gap-2 pt-3"> | |
| 890 | <Pressable | |
| 891 | accessibilityRole="button" | |
| 892 | accessibilityState={{ | |
| 893 | selected: session.providerFilter !== FAVORITES_PROVIDER_FILTER, | |
| 894 | }} | |
| 895 | className={cn( | |
| 896 | "rounded-full px-4 py-2", | |
| 897 | session.providerFilter !== FAVORITES_PROVIDER_FILTER | |
| 898 | ? "bg-subtle-strong" | |
| 899 | : "bg-subtle", | |
| 900 | )} | |
| 901 | onPress={() => session.setProviderFilter(null)} | |
| 902 | > | |
| 903 | <Text className="text-sm font-t3-medium text-foreground">All</Text> | |
| 904 | </Pressable> | |
| 905 | <Pressable | |
| 906 | accessibilityRole="button" | |
| 907 | accessibilityState={{ | |
| 908 | selected: session.providerFilter === FAVORITES_PROVIDER_FILTER, | |
| 909 | }} | |
| 910 | className={cn( | |
| 911 | "rounded-full px-4 py-2", | |
| 912 | session.providerFilter === FAVORITES_PROVIDER_FILTER | |
| 913 | ? "bg-subtle-strong" | |
| 914 | : "bg-subtle", | |
| 915 | )} | |
| 916 | onPress={() => session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} | |
| 917 | > | |
| 918 | <Text className="text-sm font-t3-medium text-foreground">Favorites</Text> | |
| 919 | </Pressable> | |
| 920 | </View> | |
| 921 | 912 | </View> |
| 922 | 913 | ) : null} |
| 923 | 914 | </> |
| 924 | 915 | } |
| 925 | 916 | recycleItems |
| 917 | refreshControl={ | |
| 918 | session.environmentId ? ( | |
| 919 | <RefreshControl refreshing={isRefreshingProviders} onRefresh={refreshProviders} /> | |
| 920 | ) : undefined | |
| 921 | } | |
| 926 | 922 | onLoad={() => setAnimationsReady(true)} |
| 927 | 923 | renderItem={renderCatalogItem} |
| 928 | 924 | showsVerticalScrollIndicator={false} |
| ... | ... | @@ -1042,27 +1038,22 @@ function ThreadSettingsModelsScreen() { |
| 1042 | 1038 | const navigation = useNavigation<NativeStackNavigationProp<ThreadSettingsPickerStackParams>>(); |
| 1043 | 1039 | const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; |
| 1044 | 1040 | const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; |
| 1045 | const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 1046 | reportFailure: false, | |
| 1047 | }); | |
| 1048 | const refreshProviderCatalog = useMemo( | |
| 1049 | () => createProviderCatalogRefreshRunner(refreshProvidersCommand), | |
| 1050 | [refreshProvidersCommand], | |
| 1051 | ); | |
| 1052 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 1053 | const refreshProviders = useCallback(() => { | |
| 1054 | if (!session.environmentId || isRefreshingProviders) return; | |
| 1055 | setIsRefreshingProviders(true); | |
| 1056 | void refreshProviderCatalog(session.environmentId).then((result) => { | |
| 1057 | setIsRefreshingProviders(false); | |
| 1058 | const error = providerCatalogRefreshError(result); | |
| 1059 | if (error) Alert.alert("Could not refresh models", error); | |
| 1060 | }); | |
| 1061 | }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); | |
| 1062 | 1041 | const commitAndClose = useCallback(() => { |
| 1063 | 1042 | if (!session.commitPendingModel()) return; |
| 1064 | 1043 | presentation.onClose(); |
| 1065 | 1044 | }, [presentation, session]); |
| 1045 | const providerFilters = useMemo( | |
| 1046 | () => [ | |
| 1047 | { id: "all-providers", title: "All providers", value: null }, | |
| 1048 | { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER }, | |
| 1049 | ...session.providerGroups.map((group) => ({ | |
| 1050 | id: `provider:${group.providerKey}`, | |
| 1051 | title: group.providerLabel, | |
| 1052 | value: group.providerKey, | |
| 1053 | })), | |
| 1054 | ], | |
| 1055 | [session.providerGroups], | |
| 1056 | ); | |
| 1066 | 1057 | const filterMenu = useMemo( |
| 1067 | 1058 | () => ({ |
| 1068 | 1059 | title: "Model filters", |
| ... | ... | @@ -1070,30 +1061,12 @@ function ThreadSettingsModelsScreen() { |
| 1070 | 1061 | { |
| 1071 | 1062 | type: "submenu" as const, |
| 1072 | 1063 | title: "Provider", |
| 1073 | items: [ | |
| 1074 | { | |
| 1075 | type: "action" as const, | |
| 1076 | title: "All providers", | |
| 1077 | state: session.providerFilter === null ? ("on" as const) : ("off" as const), | |
| 1078 | onPress: () => session.setProviderFilter(null), | |
| 1079 | }, | |
| 1080 | { | |
| 1081 | type: "action" as const, | |
| 1082 | title: "Favorites", | |
| 1083 | state: | |
| 1084 | session.providerFilter === FAVORITES_PROVIDER_FILTER | |
| 1085 | ? ("on" as const) | |
| 1086 | : ("off" as const), | |
| 1087 | onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER), | |
| 1088 | }, | |
| 1089 | ...session.providerGroups.map((group) => ({ | |
| 1090 | type: "action" as const, | |
| 1091 | title: group.providerLabel, | |
| 1092 | state: | |
| 1093 | session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const), | |
| 1094 | onPress: () => session.setProviderFilter(group.providerKey), | |
| 1095 | })), | |
| 1096 | ], | |
| 1064 | items: providerFilters.map((filter) => ({ | |
| 1065 | type: "action" as const, | |
| 1066 | title: filter.title, | |
| 1067 | state: session.providerFilter === filter.value ? ("on" as const) : ("off" as const), | |
| 1068 | onPress: () => session.setProviderFilter(filter.value), | |
| 1069 | })), | |
| 1097 | 1070 | }, |
| 1098 | 1071 | ...(session.hasLegacyModels |
| 1099 | 1072 | ? [ |
| ... | ... | @@ -1107,25 +1080,59 @@ function ThreadSettingsModelsScreen() { |
| 1107 | 1080 | : []), |
| 1108 | 1081 | ], |
| 1109 | 1082 | }), |
| 1110 | [session], | |
| 1083 | [providerFilters, session], | |
| 1111 | 1084 | ); |
| 1112 | 1085 | |
| 1113 | 1086 | return ( |
| 1114 | 1087 | <> |
| 1115 | 1088 | {Platform.OS === "android" ? ( |
| 1116 | 1089 | <AndroidScreenHeader |
| 1117 | actions={[ | |
| 1118 | { | |
| 1119 | accessibilityLabel: "Refresh models", | |
| 1120 | disabled: isRefreshingProviders || session.environmentId === null, | |
| 1121 | icon: "arrow.clockwise", | |
| 1122 | onPress: refreshProviders, | |
| 1123 | }, | |
| 1124 | ]} | |
| 1125 | 1090 | trailing={ |
| 1126 | session.pendingModel ? ( | |
| 1127 | <MaterialButton label="Save" tone="text" onPress={commitAndClose} /> | |
| 1128 | ) : undefined | |
| 1091 | <View className="flex-row items-center"> | |
| 1092 | <AndroidAnchoredMenu | |
| 1093 | title="Model filters" | |
| 1094 | actions={[ | |
| 1095 | { | |
| 1096 | title: "Provider", | |
| 1097 | subactions: providerFilters.map((filter) => ({ | |
| 1098 | id: filter.id, | |
| 1099 | title: filter.title, | |
| 1100 | state: session.providerFilter === filter.value ? "on" : "off", | |
| 1101 | })), | |
| 1102 | }, | |
| 1103 | ...(session.hasLegacyModels | |
| 1104 | ? [ | |
| 1105 | { | |
| 1106 | id: "show-legacy", | |
| 1107 | title: "Show legacy models", | |
| 1108 | state: session.showLegacy ? ("on" as const) : ("off" as const), | |
| 1109 | }, | |
| 1110 | ] | |
| 1111 | : []), | |
| 1112 | ]} | |
| 1113 | onPressAction={({ nativeEvent }) => { | |
| 1114 | if (nativeEvent.event === "show-legacy") { | |
| 1115 | session.setShowLegacy(!session.showLegacy); | |
| 1116 | } else { | |
| 1117 | const filter = providerFilters.find((item) => item.id === nativeEvent.event); | |
| 1118 | if (filter) session.setProviderFilter(filter.value); | |
| 1119 | } | |
| 1120 | }} | |
| 1121 | > | |
| 1122 | {(open) => ( | |
| 1123 | <MaterialIconButton | |
| 1124 | accessibilityLabel="Filter models" | |
| 1125 | icon="line.3.horizontal.decrease" | |
| 1126 | selected={hasCustomCatalogFilter} | |
| 1127 | variant={hasCustomCatalogFilter ? "tonal" : "standard"} | |
| 1128 | onPress={open} | |
| 1129 | /> | |
| 1130 | )} | |
| 1131 | </AndroidAnchoredMenu> | |
| 1132 | {session.pendingModel ? ( | |
| 1133 | <MaterialButton label="Save" tone="text" onPress={commitAndClose} /> | |
| 1134 | ) : null} | |
| 1135 | </View> | |
| 1129 | 1136 | } |
| 1130 | 1137 | onBack={presentation.onClose} |
| 1131 | 1138 | title="Thread settings" |
| ... | ... | @@ -1189,13 +1196,6 @@ function ThreadSettingsModelsScreen() { |
| 1189 | 1196 | /> |
| 1190 | 1197 | </NativeHeaderToolbar> |
| 1191 | 1198 | <NativeHeaderToolbar placement="right"> |
| 1192 | <NativeHeaderToolbar.Button | |
| 1193 | accessibilityLabel="Refresh models" | |
| 1194 | disabled={isRefreshingProviders || session.environmentId === null} | |
| 1195 | icon="arrow.clockwise" | |
| 1196 | onPress={refreshProviders} | |
| 1197 | separateBackground | |
| 1198 | /> | |
| 1199 | 1199 | <NativeHeaderToolbar.Button |
| 1200 | 1200 | accessibilityLabel={session.pendingModel ? "Save thread settings" : "Done"} |
| 1201 | 1201 | label={session.pendingModel ? "Save" : "Done"} |
apps/mobile/src/features/threads/provider-catalog-refresh.ts+1-1| ... | ... | @@ -10,7 +10,7 @@ type RefreshProvidersTarget = { |
| 10 | 10 | readonly input: { readonly refreshModels: true }; |
| 11 | 11 | }; |
| 12 | 12 | |
| 13 | /** Explicit model-picker refresh. Repeated taps share the pending discovery. */ | |
| 13 | /** Pull-to-refresh in the model picker shares any pending discovery. */ | |
| 14 | 14 | export function createProviderCatalogRefreshRunner<Result>( |
| 15 | 15 | refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>, |
| 16 | 16 | ) { |
apps/mobile/src/features/usage/UsageRouteScreen.tsx+3-3| ... | ... | @@ -412,7 +412,7 @@ function ProviderSection(props: { |
| 412 | 412 | ); |
| 413 | 413 | |
| 414 | 414 | return ( |
| 415 | <SettingsSection title="Providers" card> | |
| 415 | <SettingsSection title="Providers"> | |
| 416 | 416 | {ordered.map((provider, index) => { |
| 417 | 417 | const share = metric === "cost" ? provider.costShare : provider.tokenShare; |
| 418 | 418 | return ( |
| ... | ... | @@ -463,7 +463,7 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H |
| 463 | 463 | const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; |
| 464 | 464 | |
| 465 | 465 | return ( |
| 466 | <SettingsSection title="Totals" card> | |
| 466 | <SettingsSection title="Totals"> | |
| 467 | 467 | <View className="flex-row flex-wrap"> |
| 468 | 468 | <MetricCell |
| 469 | 469 | label="Processed tokens" |
| ... | ... | @@ -524,7 +524,7 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { |
| 524 | 524 | if (merged.models.length === 0) return null; |
| 525 | 525 | |
| 526 | 526 | return ( |
| 527 | <SettingsSection title="By model" card> | |
| 527 | <SettingsSection title="By model"> | |
| 528 | 528 | {merged.models.map((model, index) => ( |
| 529 | 529 | <View |
| 530 | 530 | key={`${model.provider}:${model.model}`} |