Live video on the AT Protocol

Add build info and misc settings UI improvements

+314 -197
+1
.gitignore
··· 23 23 target 24 24 my-release-key.keystore 25 25 test.xml 26 + js/app/src/build-info.json
+47 -5
js/app/components/settings/about-category-settings.tsx
··· 1 1 import { Text, View, zero } from "@streamplace/components"; 2 2 import { useTranslation } from "react-i18next"; 3 3 import { ScrollView } from "react-native"; 4 - import pkg from "../../package.json"; 4 + import { Updates } from "./updates"; 5 + 6 + let buildInfo: { 7 + hash: string; 8 + shortHash: string; 9 + branch: string; 10 + tag: string; 11 + isDirty: boolean; 12 + buildTime: string; 13 + } | null = null; 14 + 15 + try { 16 + buildInfo = require("../../src/build-info.json"); 17 + } catch { 18 + // build-info.json doesn't exist in dev mode 19 + } 5 20 6 21 export function AboutCategorySettings() { 7 22 const { t } = useTranslation("settings"); 8 23 24 + const getBuildStatus = () => { 25 + if (!buildInfo) { 26 + return "dev"; 27 + } 28 + return buildInfo.isDirty || process?.env.NODE_ENV === "development" 29 + ? "dev" 30 + : "prod"; 31 + }; 32 + 33 + const buildLabel = buildInfo ? buildInfo.tag : "development"; 34 + const buildStatus = getBuildStatus(); 35 + 9 36 return ( 10 37 <ScrollView> 11 38 <View style={[zero.layout.flex.align.center, zero.px[4], zero.py[4]]}> ··· 16 43 ]} 17 44 > 18 45 <View> 19 - <Text size="xl">{t("app-version", { version: pkg.version })}</Text> 20 - <Text size="lg" color="muted"> 21 - {t("app-version-description")} 22 - </Text> 46 + <Text>This version is </Text> 47 + <Updates /> 48 + </View> 49 + 50 + <View 51 + style={[ 52 + { flexDirection: "row" }, 53 + { alignItems: "flex-start" }, 54 + { justifyContent: "flex-start" }, 55 + ]} 56 + > 57 + <View style={[{ flex: 1 }, { paddingRight: 12 }]}> 58 + <Text size="lg">Build</Text> 59 + </View> 60 + <View style={{ alignItems: "flex-end" }}> 61 + <Text size="lg" color="muted"> 62 + {buildLabel} ({buildStatus}) 63 + </Text> 64 + </View> 23 65 </View> 24 66 </View> 25 67 </View>
+8 -19
js/app/components/settings/advanced-category-settings.tsx
··· 1 1 import { Button, Input, Text, View, zero } from "@streamplace/components"; 2 2 import { useEffect, useState } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 - import { ScrollView, Switch } from "react-native"; 4 + import { ScrollView } from "react-native"; 5 5 import { useStore } from "store"; 6 6 import { DEFAULT_URL } from "store/slices/streamplaceSlice"; 7 + import { SettingToggle } from "./components/setting-toggle"; 7 8 8 9 export function AdvancedCategorySettings() { 9 10 const url = useStore((state) => state.url); ··· 56 57 zero.gap.all[4], 57 58 ]} 58 59 > 59 - <View 60 - style={[ 61 - { flexDirection: "row" }, 62 - { alignItems: "flex-start" }, 63 - { justifyContent: "flex-start" }, 64 - ]} 65 - > 66 - <View style={[{ flex: 1 }, { paddingRight: 12 }]}> 67 - <Text size="xl">{t("use-custom-node")}</Text> 68 - <Text size="lg" color="muted"> 69 - {t("default-url", { url: defaultUrl })} 70 - </Text> 71 - </View> 72 - <Switch 73 - value={overrideEnabled} 74 - onValueChange={handleToggleOverride} 75 - /> 76 - </View> 60 + <SettingToggle 61 + title={t("use-custom-node")} 62 + description={t("default-url", { url: defaultUrl })} 63 + value={overrideEnabled} 64 + onValueChange={handleToggleOverride} 65 + /> 77 66 78 67 {overrideEnabled && ( 79 68 <View
+40
js/app/components/settings/components/setting-toggle.tsx
··· 1 + import { Text, View } from "@streamplace/components"; 2 + import { mergeStyles } from "@streamplace/components/src/ui"; 3 + import { Switch, ViewStyle } from "react-native"; 4 + 5 + export interface SettingToggleProps { 6 + title: string; 7 + description?: string; 8 + value: boolean; 9 + onValueChange: (value: boolean) => void; 10 + style?: ViewStyle; 11 + } 12 + 13 + export function SettingToggle({ 14 + title, 15 + description, 16 + value, 17 + onValueChange, 18 + style, 19 + }: SettingToggleProps) { 20 + return ( 21 + <View 22 + style={mergeStyles( 23 + { flexDirection: "row" }, 24 + { alignItems: "flex-start" }, 25 + { justifyContent: "flex-start" }, 26 + style, 27 + )} 28 + > 29 + <View style={[{ flex: 1 }, { paddingRight: 12 }]}> 30 + <Text size="xl">{title}</Text> 31 + {description && ( 32 + <Text size="lg" color="muted"> 33 + {description} 34 + </Text> 35 + )} 36 + </View> 37 + <Switch value={value} onValueChange={onValueChange} /> 38 + </View> 39 + ); 40 + }
+8 -21
js/app/components/settings/danmu-category-settings.tsx
··· 8 8 } from "@streamplace/components"; 9 9 import { useDanmuSettings } from "@streamplace/components/src/streamplace-store"; 10 10 import { useTranslation } from "react-i18next"; 11 - import { 12 - Platform, 13 - ScrollView, 14 - Switch, 15 - useWindowDimensions, 16 - } from "react-native"; 11 + import { Platform, ScrollView, useWindowDimensions } from "react-native"; 12 + import { SettingToggle } from "./components/setting-toggle"; 17 13 18 14 export function DanmuCategorySettings() { 19 15 const { t } = useTranslation("settings"); ··· 44 40 > 45 41 <View style={[{ alignItems: "stretch" }, zero.gap.all[4]]}> 46 42 {/* Enable/Disable Danmu */} 47 - <View 48 - style={[ 49 - { flexDirection: "row" }, 50 - { alignItems: "flex-start" }, 51 - { justifyContent: "flex-start" }, 52 - ]} 53 - > 54 - <View style={[{ flex: 1 }, { paddingRight: 12 }]}> 55 - <Text size="xl">{t("danmu-enabled")}</Text> 56 - <Text size="lg" color="muted"> 57 - {t("danmu-enabled-description")} 58 - </Text> 59 - </View> 60 - <Switch value={danmuEnabled} onValueChange={setDanmuEnabled} /> 61 - </View> 43 + <SettingToggle 44 + title={t("danmu-enabled")} 45 + description={t("danmu-enabled-description")} 46 + value={danmuEnabled} 47 + onValueChange={setDanmuEnabled} 48 + /> 62 49 63 50 {/* Opacity */} 64 51 <View style={[zero.gap.all[6]]}>
+15 -29
js/app/components/settings/privacy-category-settings.tsx
··· 1 - import { Text, View, zero } from "@streamplace/components"; 1 + import { View, zero } from "@streamplace/components"; 2 2 import { useEffect } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 - import { ScrollView, Switch } from "react-native"; 4 + import { ScrollView } from "react-native"; 5 5 import { useStore } from "store"; 6 6 import { useIsReady, useServerSettings, useStreamplaceUrl } from "store/hooks"; 7 + import { SettingToggle } from "./components/setting-toggle"; 7 8 8 9 export function PrivacyCategorySettings() { 9 10 const { t } = useTranslation("settings"); ··· 35 36 { paddingVertical: 24, maxWidth: 500, width: "100%" }, 36 37 ]} 37 38 > 38 - <View 39 - style={[ 40 - zero.layout.flex.row, 41 - zero.layout.flex.align.center, 42 - zero.layout.flex.justify.between, 43 - { width: "100%" }, 44 - ]} 45 - > 46 - <View style={[zero.flex.values[1], { paddingRight: 12 }]}> 47 - <Text size="xl"> 48 - {t("debug-recording-title", { host: u.host })} 49 - </Text> 50 - <Text size="lg" color="muted"> 51 - {t("debug-recording-description")} 52 - </Text> 53 - </View> 54 - <Switch 55 - value={debugRecordingOn} 56 - onValueChange={(value) => { 57 - if (value === true) { 58 - createServerSettingsRecord(true); 59 - } else { 60 - createServerSettingsRecord(false); 61 - } 62 - }} 63 - /> 64 - </View> 39 + <SettingToggle 40 + title={t("debug-recording-title", { host: u.host })} 41 + description={t("debug-recording-description")} 42 + value={debugRecordingOn} 43 + onValueChange={(value) => { 44 + if (value === true) { 45 + createServerSettingsRecord(true); 46 + } else { 47 + createServerSettingsRecord(false); 48 + } 49 + }} 50 + /> 65 51 </View> 66 52 </View> 67 53 </ScrollView>
-55
js/app/components/settings/settings-item-link.tsx
··· 1 - import { useNavigation } from "@react-navigation/native"; 2 - import { Text, View } from "@streamplace/components"; 3 - import { ChevronRight, LucideIcon } from "lucide-react-native"; 4 - import { Pressable } from "react-native"; 5 - 6 - interface SettingsItemLinkProps { 7 - title: string; 8 - screen: string; 9 - icon: LucideIcon; 10 - rootScreen?: boolean; // if true, navigates to root stack instead of Settings stack 11 - } 12 - 13 - export function SettingsItemLink({ 14 - title, 15 - screen, 16 - icon: Icon, 17 - rootScreen = false, 18 - }: SettingsItemLinkProps) { 19 - const navigation = useNavigation(); 20 - 21 - const handlePress = () => { 22 - if (rootScreen) { 23 - // Navigate to root stack screen 24 - navigation.navigate(screen as never); 25 - } else { 26 - // Navigate within Settings stack 27 - navigation.navigate(screen as never); 28 - } 29 - }; 30 - 31 - return ( 32 - <Pressable onPress={handlePress}> 33 - {({ pressed }) => ( 34 - <View 35 - style={[ 36 - { 37 - flexDirection: "row", 38 - alignItems: "center", 39 - justifyContent: "space-between", 40 - paddingVertical: 12, 41 - paddingHorizontal: 16, 42 - backgroundColor: pressed ? "#ffffff08" : "transparent", 43 - }, 44 - ]} 45 - > 46 - <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}> 47 - <Icon size={20} color="#999" /> 48 - <Text size="lg">{title}</Text> 49 - </View> 50 - <ChevronRight size={20} color="#666" /> 51 - </View> 52 - )} 53 - </Pressable> 54 - ); 55 - }
js/app/components/settings/settings-navigation-item.tsx js/app/components/settings/components/settings-navigation-item.tsx
+1 -1
js/app/components/settings/settings.tsx
··· 6 6 zero, 7 7 } from "@streamplace/components"; 8 8 import AQLink from "components/aqlink"; 9 - import { SettingsNavigationItem } from "components/settings/settings-navigation-item"; 9 + import { SettingsNavigationItem } from "components/settings/components/settings-navigation-item"; 10 10 import { Code, Info, Lock, LogIn, Shield, Video } from "lucide-react-native"; 11 11 import { ImageBackground, Pressable, ScrollView } from "react-native"; 12 12
+3 -4
js/app/components/settings/streaming-category-settings.tsx
··· 1 1 import { View, zero } from "@streamplace/components"; 2 - import { SettingsItemLink } from "components/settings/settings-item-link"; 3 2 import { Key, Webhook } from "lucide-react-native"; 4 3 import { useTranslation } from "react-i18next"; 5 4 import { ScrollView } from "react-native"; 5 + import { SettingsNavigationItem } from "./components/settings-navigation-item"; 6 6 import { HorizontalBar } from "./settings"; 7 7 8 8 export function StreamingCategorySettings() { ··· 11 11 <ScrollView> 12 12 <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 13 13 <View style={[{ paddingVertical: 0, maxWidth: 500, width: "100%" }]}> 14 - <SettingsItemLink 14 + <SettingsNavigationItem 15 15 title={t("key-management")} 16 16 screen="KeyManagement" 17 17 icon={Key} 18 - rootScreen 19 18 /> 20 19 <HorizontalBar /> 21 - <SettingsItemLink 20 + <SettingsNavigationItem 22 21 title={t("webhooks")} 23 22 screen="WebhooksSettings" 24 23 icon={Webhook}
+54 -49
js/app/components/settings/updates.native.tsx
··· 10 10 import * as ExpoUpdates from "expo-updates"; 11 11 import { useEffect, useState } from "react"; 12 12 import { useTranslation } from "react-i18next"; 13 - import { Platform, TouchableOpacity, View } from "react-native"; 13 + import { Platform, Pressable, TouchableOpacity, View } from "react-native"; 14 14 import pkg from "../../package.json"; 15 15 16 16 const UNLOCK_TAP_COUNT = 5; ··· 85 85 <Text size="2xl" style={[{ fontWeight: "bold", color: "#fff" }]}> 86 86 Streamplace v{version} 87 87 </Text> 88 - <View 88 + <Pressable 89 + onPress={handleVersionPress} 89 90 style={[ 90 91 { alignSelf: "flex-start" }, 91 92 theme.zero.bg.muted, ··· 97 98 <Text size="base" center> 98 99 {runTypeMessage} 99 100 </Text> 100 - </View> 101 + </Pressable> 101 102 </View> 102 - <Button 103 - onPress={async () => { 104 - try { 105 - setChecked(true); 106 - const res = await ExpoUpdates.checkForUpdateAsync(); 107 - if (!res.isAvailable) { 103 + <View> 104 + <Button 105 + variant="secondary" 106 + width="full" 107 + onPress={async () => { 108 + try { 109 + setChecked(true); 110 + const res = await ExpoUpdates.checkForUpdateAsync(); 111 + if (!res.isAvailable) { 112 + toast.show( 113 + t("modal-latest-version"), 114 + t("modal-no-update-available"), 115 + { duration: 2000 }, 116 + ); 117 + } else { 118 + toast.show( 119 + t("modal-update-available-title"), 120 + t("modal-update-available-description"), 121 + { duration: 2000 }, 122 + ); 123 + } 124 + } catch (e) { 108 125 toast.show( 109 - t("modal-latest-version"), 110 - t("modal-no-update-available"), 111 - { duration: 2000 }, 112 - ); 113 - } else { 114 - toast.show( 115 - t("modal-update-available-title"), 116 - t("modal-update-available-description"), 117 - { duration: 2000 }, 126 + t("modal-update-failed-title"), 127 + t("modal-update-failed-description", { 128 + store: Platform.OS === "ios" ? "App Store" : "Play Store", 129 + }), 118 130 ); 119 131 } 120 - } catch (e) { 121 - toast.show( 122 - t("modal-update-failed-title"), 123 - t("modal-update-failed-description", { 124 - store: Platform.OS === "ios" ? "App Store" : "Play Store", 125 - }), 126 - ); 127 - } 128 - }} 129 - > 130 - <Text style={[{ color: "#fff", fontWeight: "600" }]}>{buttonText}</Text> 131 - </Button> 132 - {isUpdatePending && ( 133 - <TouchableOpacity 134 - style={[ 135 - { 136 - marginTop: 8, 137 - backgroundColor: "#007AFF", 138 - borderRadius: 8, 139 - paddingHorizontal: 16, 140 - paddingVertical: 12, 141 - alignItems: "center", 142 - }, 143 - ]} 144 - onPress={() => { 145 - ExpoUpdates.reloadAsync(); 146 132 }} 147 133 > 148 - <Text style={[{ color: "#fff", fontWeight: "600" }]}> 149 - {t("button-reload-app-on-update")} 150 - </Text> 151 - </TouchableOpacity> 152 - )} 134 + {buttonText} 135 + </Button> 136 + {isUpdatePending && ( 137 + <TouchableOpacity 138 + style={[ 139 + { 140 + marginTop: 8, 141 + backgroundColor: "#007AFF", 142 + borderRadius: 8, 143 + paddingHorizontal: 16, 144 + paddingVertical: 12, 145 + alignItems: "center", 146 + }, 147 + ]} 148 + onPress={() => { 149 + ExpoUpdates.reloadAsync(); 150 + }} 151 + > 152 + <Text style={[{ color: "#fff", fontWeight: "600" }]}> 153 + {t("button-reload-app-on-update")} 154 + </Text> 155 + </TouchableOpacity> 156 + )} 157 + </View> 153 158 </View> 154 159 ); 155 160 }
+48 -4
js/app/components/settings/updates.tsx
··· 1 - import { Text } from "@streamplace/components"; 1 + import { 2 + Text, 3 + useDanmuUnlocked, 4 + useSetDanmuUnlocked, 5 + useToast, 6 + } from "@streamplace/components"; 7 + import { useState } from "react"; 2 8 import { useTranslation } from "react-i18next"; 3 - import { View } from "react-native"; 9 + import { Pressable } from "react-native-gesture-handler"; 4 10 import pkg from "../../package.json"; 5 11 12 + const UNLOCK_TAP_COUNT = 5; 13 + 6 14 // maybe someday some PWA update stuff will live here 7 15 export function Updates() { 8 16 const { t } = useTranslation("settings"); 17 + const toast = useToast(); 18 + const [checked, setChecked] = useState(false); 19 + const [tapCount, setTapCount] = useState(0); 20 + const danmuUnlocked = useDanmuUnlocked(); 21 + const setDanmuUnlocked = useSetDanmuUnlocked(); 22 + 23 + const handleVersionPress = () => { 24 + if (danmuUnlocked) { 25 + toast.show("You are already a developer", undefined, { 26 + duration: 2, 27 + variant: "info", 28 + actionLabel: "Stop being a developer", 29 + onAction: () => { 30 + setDanmuUnlocked(false); 31 + toast.show("You are no longer a developer", undefined, { 32 + duration: 2, 33 + variant: "info", 34 + }); 35 + }, 36 + }); 37 + return; 38 + } 39 + 40 + const newCount = tapCount + 1; 41 + setTapCount(newCount); 42 + 43 + if (newCount >= UNLOCK_TAP_COUNT) { 44 + setDanmuUnlocked(true); 45 + toast.show("You are now a developer", "have fun! lol", { 46 + duration: 20, 47 + variant: "success", 48 + }); 49 + setTapCount(0); 50 + } 51 + }; 52 + 9 53 return ( 10 - <View> 54 + <Pressable onPress={handleVersionPress}> 11 55 <Text size="xl">{t("app-version", { version: pkg.version })}</Text> 12 - </View> 56 + </Pressable> 13 57 ); 14 58 }
+11 -2
js/app/components/settings/webhook-manager.tsx
··· 508 508 <Text style={[text.gray[300], { fontSize: 14, fontWeight: "500" }]}> 509 509 Text Replacements 510 510 </Text> 511 - <Button size="pill" onPress={addReplacement}> 511 + <Button width="min" size="pill" onPress={addReplacement}> 512 512 <Text style={[text.white, { fontSize: 12 }]}>+ Add</Text> 513 513 </Button> 514 514 </View> ··· 830 830 </Text> 831 831 832 832 <View 833 - style={[layout.flex.row, layout.flex.justify.between, gap.all[3]]} 833 + style={[ 834 + layout.flex.row, 835 + layout.flex.justify.between, 836 + gap.all[3], 837 + w.percent[100], 838 + ]} 834 839 > 835 840 <Button 836 841 onPress={handleCreate} 837 842 size="pill" 843 + width="min" 838 844 leftIcon={<Plus color={theme.colors.text} />} 839 845 > 840 846 <Text>{t("create-webhook")}</Text> ··· 845 851 disabled={loading} 846 852 leftIcon={<RefreshCw color={theme.colors.text} />} 847 853 size="pill" 854 + width="min" 848 855 variant="secondary" 849 856 > 850 857 <Text>{t("refresh")}</Text> ··· 938 945 <View style={[layout.flex.row, layout.flex.justify.end, gap.all[3]]}> 939 946 <Button 940 947 variant="secondary" 948 + width="full" 941 949 onPress={() => setDeleteDialog({ isVisible: false, webhook: null })} 942 950 disabled={ 943 951 deleteDialog.webhook ··· 949 957 </Button> 950 958 <Button 951 959 variant="destructive" 960 + width="full" 952 961 onPress={confirmDelete} 953 962 disabled={ 954 963 deleteDialog.webhook
+1 -1
js/app/package.json
··· 10 10 "web": "npx expo start --web --port 38081", 11 11 "test": "jest --watchAll", 12 12 "build": "pnpm run build:web && pnpm run prebuild", 13 - "build:web": "pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", 13 + "build:web": "node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json", 14 14 "export": "expo export --dump-sourcemap || expo export --dump-sourcemap", 15 15 "check": "bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", 16 16 "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
+48
js/app/scripts/generate-build-info.js
··· 1 + #!/usr/bin/env node 2 + 3 + const { execSync } = require("child_process"); 4 + const fs = require("fs"); 5 + const path = require("path"); 6 + 7 + function getGitInfo() { 8 + try { 9 + const hash = execSync("git rev-parse HEAD", { encoding: "utf-8" }).trim(); 10 + const shortHash = execSync("git rev-parse --short HEAD", { 11 + encoding: "utf-8", 12 + }).trim(); 13 + const branch = execSync("git rev-parse --abbrev-ref HEAD", { 14 + encoding: "utf-8", 15 + }).trim(); 16 + const tag = execSync("git describe --tags --always --dirty", { 17 + encoding: "utf-8", 18 + }).trim(); 19 + const isDirty = tag.endsWith("-dirty"); 20 + 21 + return { 22 + hash, 23 + shortHash, 24 + branch, 25 + tag, 26 + isDirty, 27 + }; 28 + } catch (error) { 29 + console.warn("Could not get git info:", error.message); 30 + return { 31 + hash: "unknown", 32 + shortHash: "unknown", 33 + branch: "unknown", 34 + tag: "unknown", 35 + isDirty: false, 36 + }; 37 + } 38 + } 39 + 40 + const buildInfo = { 41 + ...getGitInfo(), 42 + buildTime: new Date().toISOString(), 43 + }; 44 + 45 + const outputPath = path.join(__dirname, "..", "src", "build-info.json"); 46 + fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2)); 47 + 48 + console.log("Generated build-info.json:", buildInfo);
+16 -6
js/app/src/router.tsx
··· 235 235 ]} 236 236 > 237 237 {sidebar?.isActive ? ( 238 - <Pressable style={{ padding: 5 }} onPress={handlePress}> 239 - {sidebar.isCollapsed ? ( 240 - <PanelLeftOpen size={24} color={theme.colors.accentForeground} /> 241 - ) : ( 242 - <PanelLeftClose size={24} color={theme.colors.accentForeground} /> 238 + <> 239 + <Pressable style={{ padding: 5 }} onPress={handlePress}> 240 + {sidebar.isCollapsed ? ( 241 + <PanelLeftOpen size={24} color={theme.colors.accentForeground} /> 242 + ) : ( 243 + <PanelLeftClose size={24} color={theme.colors.accentForeground} /> 244 + )} 245 + </Pressable> 246 + {canGoBack && ( 247 + <Pressable 248 + style={{ marginLeft: 10, paddingVertical: 5 }} 249 + onPress={handleGoBackPress} 250 + > 251 + <ArrowLeft size={24} color={theme.colors.accentForeground} /> 252 + </Pressable> 243 253 )} 244 - </Pressable> 254 + </> 245 255 ) : ( 246 256 <Pressable style={{ padding: 5 }} onPress={handleGoBackPress}> 247 257 {canGoBack ? (
+13 -1
js/components/src/components/ui/button.tsx
··· 39 39 rightIcon?: React.ReactNode; 40 40 loading?: boolean; 41 41 loadingText?: string; 42 + width?: "full" | "min" | number; 42 43 } 43 44 44 45 export const Button = forwardRef<any, ButtonProps>( ··· 53 54 loadingText, 54 55 disabled, 55 56 style, 57 + width = "full", 56 58 ...props 57 59 }, 58 60 ref, ··· 198 200 } 199 201 }, [variant, icons]); 200 202 203 + const widthStyle = useMemo(() => { 204 + if (width === "full") { 205 + return { width: "100%" }; 206 + } else if (width === "min") { 207 + return { alignSelf: "flex-start" as const }; 208 + } else { 209 + return { width }; 210 + } 211 + }, [width]); 212 + 201 213 return ( 202 214 <ButtonPrimitive.Root 203 215 ref={ref} 204 216 disabled={disabled || loading} 205 - style={[buttonStyle, sizeStyles.button, style]} 217 + style={[buttonStyle, sizeStyles.button, widthStyle, style]} 206 218 {...props} 207 219 > 208 220 <ButtonPrimitive.Content style={sizeStyles.inner}>