Live video on the AT Protocol

Merge pull request #689 from streamplace/natb/fix-c2pa-styles

fix: redo c2pa component styling to be more in line with existing components

authored by

Eli Mallon and committed by
GitHub
0be69354 8e722b07

+694 -581
+5 -1
js/app/components/mobile/bottom-metadata.tsx
··· 50 50 ]} 51 51 > 52 52 <View 53 - style={[layout.flex.row, layout.flex.spaceBetween, { height: "100%" }]} 53 + style={[ 54 + layout.flex.row, 55 + layout.flex.spaceBetween, 56 + { height: "100%", flex: "auto" as any }, 57 + ]} 54 58 > 55 59 {/* Left side - Profile info */} 56 60 <View
+1 -1
js/app/components/mobile/desktop-ui/bottom-controls.tsx
··· 57 57 {Platform.OS === "web" && pipSupported && ( 58 58 <Pressable onPress={onHandlePip} disabled={pipActive}> 59 59 <View style={{ opacity: pipActive ? 0.5 : 1 }}> 60 - <PictureInPicture2 /> 60 + <PictureInPicture2 color={theme.colors.text} /> 61 61 </View> 62 62 </Pressable> 63 63 )}
+129 -88
js/app/components/mobile/desktop-ui/top-controls.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 2 import { 3 + ContentWarningBadge, 3 4 PlayerUI, 4 5 Text, 5 6 View, 6 7 useAvatars, 7 8 useCameraToggle, 8 9 useLivestreamInfo, 10 + useLivestreamStore, 9 11 zero, 10 12 } from "@streamplace/components"; 11 13 import { ChevronLeft, MessageSquare, SwitchCamera } from "lucide-react-native"; ··· 42 44 const navigation = useNavigation(); 43 45 const { profile } = useLivestreamInfo(); 44 46 const { doSetIngestCamera } = useCameraToggle(); 45 - const avatars = useAvatars(profile?.did ? [profile?.did] : []); 47 + const avatars = useAvatars(profile?.did && embedded ? [profile?.did] : []); 46 48 const { width } = useWindowDimensions(); 47 49 const isTinyScreen = width < 450; 48 50 const isSmallScreen = width < 600; 49 51 52 + // Get content warnings from segment 53 + const segment = useLivestreamStore((x) => x.segment); 54 + const contentWarnings = 55 + (segment?.contentWarnings?.warnings as string[]) || []; 56 + 50 57 return ( 51 - <View 52 - style={[ 53 - layout.flex.row, 54 - layout.flex.spaceBetween, 55 - layout.flex.alignCenter, 56 - ]} 57 - > 58 - <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 59 - {Platform.OS !== "web" && ( 60 - <Pressable 61 - onPress={() => { 62 - navigation.canGoBack() 63 - ? navigation.goBack() 64 - : navigation.navigate("Home", { screen: "StreamList" }); 65 - }} 66 - style={[p[2], r[1]]} 67 - > 68 - <ChevronLeft color="white" size={24} /> 69 - </Pressable> 70 - )} 71 - <Image 72 - source={ 73 - profile?.did 74 - ? { uri: avatars[profile?.did]?.avatar } 75 - : require("assets/images/goose.png") 76 - } 77 - style={[ 78 - { 79 - width: 40, 80 - height: 40, 81 - borderRadius: 20, 82 - backgroundColor: colors.gray[800], 83 - }, 84 - borders.width.thin, 85 - borders.color.gray[700], 86 - ]} 87 - /> 58 + <View style={[layout.flex.column, gap.all[2]]}> 59 + <View 60 + style={[ 61 + layout.flex.row, 62 + layout.flex.spaceBetween, 63 + layout.flex.alignCenter, 64 + ]} 65 + > 66 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 67 + {Platform.OS !== "web" && ( 68 + <Pressable 69 + onPress={() => { 70 + navigation.canGoBack() 71 + ? navigation.goBack() 72 + : navigation.navigate("Home", { screen: "StreamList" }); 73 + }} 74 + style={[p[2], r[1]]} 75 + > 76 + <ChevronLeft color="white" size={24} /> 77 + </Pressable> 78 + )} 79 + {embedded && ( 80 + <View style={[gap.all[2]]}> 81 + <View 82 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]} 83 + > 84 + <Image 85 + source={ 86 + profile?.did 87 + ? { uri: avatars[profile?.did]?.avatar } 88 + : require("assets/images/goose.png") 89 + } 90 + style={[ 91 + { 92 + width: 40, 93 + height: 40, 94 + borderRadius: 20, 95 + backgroundColor: colors.gray[800], 96 + }, 97 + borders.width.thin, 98 + borders.color.gray[700], 99 + ]} 100 + /> 101 + 102 + <View style={[layout.flex.column, gap.all[2]]}> 103 + <Text 104 + style={[text.white, { fontSize: 16, fontWeight: "600" }]} 105 + > 106 + {profile?.handle} 107 + </Text> 108 + {!offline && <LiveBubble />} 109 + </View> 110 + </View> 111 + </View> 112 + )} 88 113 89 - <View style={[layout.flex.column]}> 90 - <Text style={[text.white, { fontSize: 16, fontWeight: "600" }]}> 91 - {profile?.handle} 92 - </Text> 93 - {!offline && <LiveBubble />} 114 + {!embedded && <ContentWarningBadge warnings={contentWarnings} />} 94 115 </View> 95 - </View> 96 116 97 - <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 98 - {embedded && Platform.OS === "web" && ( 99 - <Pressable 100 - onPress={() => { 101 - const url = window.location.href.replace("/embed/", "/"); 102 - Linking.openURL(url); 103 - }} 104 - style={[ 105 - layout.flex.row, 106 - layout.flex.alignCenter, 107 - gap.all[2], 108 - py[2], 109 - px[3], 110 - r.xl, 111 - { 112 - backgroundColor: "rgba(75,75,75, 0.65)", 113 - }, 114 - ]} 115 - > 116 - {!isSmallScreen && <Text size="lg">Powered by</Text>} 117 - <Image 118 - source={require("assets/images/cube_small.png")} 119 - style={{ 120 - width: 24, 121 - height: 24, 117 + <View 118 + style={[ 119 + layout.flex.row, 120 + layout.flex.align.start, 121 + layout.flex.justify.start, 122 + gap.all[3], 123 + ]} 124 + > 125 + {!embedded && !offline && <LiveBubble />} 126 + {embedded && Platform.OS === "web" && ( 127 + <Pressable 128 + onPress={() => { 129 + const url = window.location.href.replace("/embed/", "/"); 130 + Linking.openURL(url); 122 131 }} 123 - /> 124 - {!isTinyScreen && <Text size="lg">Streamplace</Text>} 125 - </Pressable> 126 - )} 127 - {isActivelyLive && ( 128 - <> 129 - <PlayerUI.Viewers /> 130 - 131 - <Pressable onPress={onToggleChat} style={[p[2], r[1]]}> 132 - <MessageSquare 133 - size={20} 134 - color={isChatOpen ? colors.primary[500] : colors.white} 132 + style={[ 133 + layout.flex.row, 134 + layout.flex.alignCenter, 135 + gap.all[2], 136 + py[2], 137 + px[3], 138 + r.xl, 139 + { 140 + backgroundColor: "rgba(75,75,75, 0.65)", 141 + }, 142 + ]} 143 + > 144 + {!isSmallScreen && <Text size="lg">Powered by</Text>} 145 + <Image 146 + source={require("assets/images/cube_small.png")} 147 + style={{ 148 + width: 24, 149 + height: 24, 150 + }} 135 151 /> 152 + {!isTinyScreen && <Text size="lg">Streamplace</Text>} 136 153 </Pressable> 137 - </> 138 - )} 139 - {ingest !== null && ( 140 - <Pressable onPress={doSetIngestCamera} style={[p[2], r[1]]}> 141 - <SwitchCamera size={24} color={colors.gray[200]} /> 142 - </Pressable> 143 - )} 154 + )} 155 + {isActivelyLive && ( 156 + <> 157 + <PlayerUI.Viewers /> 158 + 159 + <Pressable onPress={onToggleChat} style={[p[2], r[1]]}> 160 + <MessageSquare 161 + size={20} 162 + color={isChatOpen ? colors.primary[500] : colors.white} 163 + /> 164 + </Pressable> 165 + </> 166 + )} 167 + {ingest !== null && ( 168 + <Pressable onPress={doSetIngestCamera} style={[p[2], r[1]]}> 169 + <SwitchCamera size={24} color={colors.gray[200]} /> 170 + </Pressable> 171 + )} 172 + </View> 144 173 </View> 174 + {embedded && ( 175 + <View 176 + style={[ 177 + layout.flex.row, 178 + layout.flex.align.start, 179 + layout.flex.justify.start, 180 + gap.all[3], 181 + ]} 182 + > 183 + <ContentWarningBadge warnings={contentWarnings} /> 184 + </View> 185 + )} 145 186 </View> 146 187 ); 147 188 }
+140 -137
js/app/components/mobile/ui.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 2 import { 3 - ContentRights, 4 - ContentWarnings, 3 + ContentWarningBadge, 5 4 PlayerUI, 6 5 Slider, 7 6 Text, ··· 72 71 const { isPlayerRatioGreater } = useSegmentDimensions(); 73 72 const { doSetIngestCamera } = useCameraToggle(); 74 73 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 75 - const segment = useLivestreamStore((x) => x.segment); 76 - 77 - // Get content warnings and rights directly from the latest segment 78 - const contentWarnings = 79 - (segment?.contentWarnings?.warnings as string[]) || []; 80 - const contentRights = segment?.contentRights; 81 74 82 75 const muteWasForced = usePlayerStore((state) => state.muteWasForced); 83 76 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); ··· 90 83 chatPanelWidth, 91 84 safeAreaInsets, 92 85 } = useResponsiveLayout(); 86 + 93 87 const [showLoading, setShowLoading] = useState(false); 94 88 95 89 // get width/height ··· 174 168 <View 175 169 style={[layout.position.absolute, h.percent[100], w.percent[100]]} 176 170 > 177 - {/* Top Left - Back Button and Profile */} 178 - <View 179 - style={[ 180 - layout.position.absolute, 181 - position.left[2], 182 - { top: safeAreaInsets.top + 12 }, 183 - { maxWidth: "70%" }, 184 - ]} 185 - > 186 - <View 187 - style={[ 188 - { 189 - padding: 3, 190 - paddingRight: 8, 191 - backgroundColor: "rgba(90,90,90, 0.25)", 192 - borderRadius: 12, 193 - }, 194 - r[2], 195 - ]} 196 - > 197 - <View 198 - style={[layout.flex.row, layout.flex.center, gap.all[2]]} 199 - > 200 - <Pressable 201 - onPress={() => { 202 - navigation.canGoBack() 203 - ? navigation.goBack() 204 - : navigation.navigate("Home", { 205 - screen: "StreamList", 206 - }); 207 - }} 208 - > 209 - <ChevronLeft color="white" /> 210 - </Pressable> 211 - <Image 212 - source={ 213 - profile?.did 214 - ? { url: avatars[profile?.did]?.avatar } 215 - : require("assets/images/goose.png") 216 - } 217 - width={32} 218 - height={32} 219 - style={[ 220 - { 221 - width: 36, 222 - height: 36, 223 - backgroundColor: "green", 224 - }, 225 - { borderRadius: 999 }, 226 - borders.width.thin, 227 - borders.color.gray[700], 228 - ]} 229 - /> 230 - <Text>{profile?.handle}</Text> 231 - </View> 232 - </View> 233 - </View> 234 - 235 - {/* Content Metadata - Below mute button */} 236 - {(contentWarnings.length > 0 || 237 - (contentRights && Object.keys(contentRights).length > 0)) && ( 238 - <View 239 - style={[ 240 - layout.position.absolute, 241 - position.left[2], 242 - { top: safeAreaInsets.top + 100 }, 243 - { maxWidth: "70%" }, 244 - { 245 - backgroundColor: "rgba(0, 0, 0, 0.75)", 246 - borderRadius: 8, 247 - padding: 8, 248 - }, 249 - ]} 250 - > 251 - <ContentWarnings warnings={contentWarnings} compact={true} /> 252 - {contentRights && ( 253 - <ContentRights 254 - contentRights={contentRights} 255 - compact={true} 256 - /> 257 - )} 258 - </View> 259 - )} 171 + {/* Left Controls Column */} 172 + <LeftControlsPanel 173 + navigation={navigation} 174 + profile={profile} 175 + avatars={avatars} 176 + safeAreaInsets={safeAreaInsets} 177 + muted={muted} 178 + setMuted={setMuted} 179 + muteWasForced={muteWasForced} 180 + setMuteWasForced={setMuteWasForced} 181 + /> 260 182 261 183 {/* Right Controls Column */} 262 184 <View ··· 346 268 duration={5} 347 269 /> 348 270 </Animated.View> 349 - {muted && ( 350 - <View 351 - style={[ 352 - layout.position.absolute, 353 - position.top[16], 354 - position.left[2], 355 - layout.flex.column, 356 - layout.flex.center, 357 - ]} 358 - > 359 - <Pressable 360 - onPress={() => { 361 - if (muteWasForced) { 362 - setMuted(false); 363 - setMuteWasForced(false); 364 - } else { 365 - setMuted(false); 366 - } 367 - }} 368 - style={[ 369 - { 370 - flexDirection: "row", 371 - alignItems: "center", 372 - gap: 8, 373 - }, 374 - ]} 375 - > 376 - <View 377 - style={[ 378 - { 379 - padding: 4, 380 - backgroundColor: "rgba(50, 30, 30, 0.4)", 381 - borderRadius: 999, 382 - borderWidth: 2, 383 - borderColor: "rgba(255, 120, 120, 0.2)", 384 - }, 385 - ]} 386 - > 387 - <VolumeX size="24" color="rgba(255,120,120,0.8)" /> 388 - </View> 389 - <Text color="muted" size="sm"> 390 - Tap to unmute 391 - </Text> 392 - </Pressable> 393 - </View> 394 - )} 395 271 <PlayerUI.AutoplayButton /> 396 272 </View> 397 273 </GestureDetector> ··· 399 275 <MobileChatPanel isPlayerRatioGreater={isPlayerRatioGreater} /> 400 276 )} 401 277 </> 278 + ); 279 + } 280 + 281 + function LeftControlsPanel({ 282 + navigation, 283 + profile, 284 + avatars, 285 + safeAreaInsets, 286 + muted, 287 + setMuted, 288 + muteWasForced, 289 + setMuteWasForced, 290 + }: { 291 + navigation: any; 292 + profile: any; 293 + avatars: any; 294 + safeAreaInsets: { top: number; bottom: number; left: number; right: number }; 295 + muted: boolean; 296 + setMuted: (muted: boolean) => void; 297 + muteWasForced: boolean; 298 + setMuteWasForced: (forced: boolean) => void; 299 + }) { 300 + // Get content warnings from segment 301 + const segment = useLivestreamStore((x) => x.segment); 302 + const contentWarnings = 303 + (segment?.contentWarnings?.warnings as string[]) || []; 304 + 305 + return ( 306 + <View 307 + style={[ 308 + layout.position.absolute, 309 + position.left[2], 310 + { top: safeAreaInsets.top + 12 }, 311 + layout.flex.column, 312 + gap.all[2], 313 + { maxWidth: "70%" }, 314 + ]} 315 + > 316 + {/* Back Button and Profile */} 317 + <View 318 + style={[ 319 + { 320 + padding: 3, 321 + paddingRight: 8, 322 + backgroundColor: "rgba(90,90,90, 0.25)", 323 + borderRadius: 12, 324 + alignSelf: "flex-start", 325 + }, 326 + r[2], 327 + ]} 328 + > 329 + <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 330 + <Pressable 331 + onPress={() => { 332 + navigation.canGoBack() 333 + ? navigation.goBack() 334 + : navigation.navigate("Home", { 335 + screen: "StreamList", 336 + }); 337 + }} 338 + > 339 + <ChevronLeft color="white" /> 340 + </Pressable> 341 + <Image 342 + source={ 343 + profile?.did 344 + ? { url: avatars[profile?.did]?.avatar } 345 + : require("assets/images/goose.png") 346 + } 347 + width={32} 348 + height={32} 349 + style={[ 350 + { 351 + width: 36, 352 + height: 36, 353 + backgroundColor: "green", 354 + }, 355 + { borderRadius: 999 }, 356 + borders.width.thin, 357 + borders.color.gray[700], 358 + ]} 359 + /> 360 + <Text>{profile?.handle}</Text> 361 + </View> 362 + </View> 363 + 364 + {/* Muted indicator */} 365 + {muted && ( 366 + <Pressable 367 + onPress={() => { 368 + if (muteWasForced) { 369 + setMuted(false); 370 + setMuteWasForced(false); 371 + } else { 372 + setMuted(false); 373 + } 374 + }} 375 + style={[ 376 + { 377 + flexDirection: "row", 378 + alignItems: "center", 379 + gap: 8, 380 + }, 381 + ]} 382 + > 383 + <View 384 + style={[ 385 + { 386 + padding: 4, 387 + backgroundColor: "rgba(50, 30, 30, 0.4)", 388 + borderRadius: 999, 389 + borderWidth: 2, 390 + borderColor: "rgba(255, 120, 120, 0.2)", 391 + }, 392 + ]} 393 + > 394 + <VolumeX size="24" color="rgba(255,120,120,0.8)" /> 395 + </View> 396 + <Text color="muted" size="sm"> 397 + Tap to unmute 398 + </Text> 399 + </Pressable> 400 + )} 401 + <View> 402 + <ContentWarningBadge warnings={contentWarnings} /> 403 + </View> 404 + </View> 402 405 ); 403 406 } 404 407
+9 -49
js/components/src/components/content-metadata/content-metadata-form.tsx
··· 376 376 layout.flex.row, 377 377 layout.flex.alignCenter, 378 378 w.percent[100], 379 + gap.all[2], 379 380 ]} 380 381 > 381 - <Text 382 - style={[ 383 - text.neutral[300], 384 - { 385 - minWidth: 100, 386 - textAlign: "left", 387 - paddingBottom: 8, 388 - fontSize: 14, 389 - }, 390 - ]} 391 - > 392 - Content Warnings 393 - </Text> 394 - <Text 395 - style={[text.gray[500], { fontSize: 12, paddingBottom: 8 }]} 396 - > 397 - optional 398 - </Text> 382 + <Text>Content Warnings</Text> 383 + <Text muted>(optional)</Text> 399 384 </View> 400 385 <View style={[gap.all[2], w.percent[100]]}> 401 386 {CONTENT_WARNINGS.map((warning) => ( ··· 424 409 layout.flex.row, 425 410 layout.flex.alignCenter, 426 411 w.percent[100], 412 + gap.all[2], 427 413 ]} 428 414 > 429 - <Text 430 - style={[ 431 - text.neutral[300], 432 - { 433 - minWidth: 100, 434 - textAlign: "left", 435 - paddingBottom: 8, 436 - fontSize: 14, 437 - }, 438 - ]} 439 - > 440 - Content Rights 441 - </Text> 442 - <Text 443 - style={[text.gray[500], { fontSize: 12, paddingBottom: 8 }]} 444 - > 445 - optional 446 - </Text> 415 + <Text>Content Rights</Text> 416 + <Text muted>(optional)</Text> 447 417 </View> 448 418 449 419 <View style={[gap.all[3], w.percent[100]]}> ··· 674 644 layout.flex.row, 675 645 layout.flex.alignCenter, 676 646 w.percent[100], 647 + gap.all[2], 677 648 ]} 678 649 > 679 - <Text 680 - style={[ 681 - text.neutral[300], 682 - { minWidth: 100, textAlign: "left", paddingBottom: 8 }, 683 - ]} 684 - > 685 - Distribution 686 - </Text> 687 - <Text 688 - style={[text.gray[500], { fontSize: 12, paddingBottom: 8 }]} 689 - > 690 - optional 691 - </Text> 650 + <Text>Distribution</Text> 651 + <Text muted>(optional)</Text> 692 652 </View> 693 653 694 654 {/* allow everyone to distribute your content */}
+31 -56
js/components/src/components/content-metadata/content-rights.tsx
··· 1 - import { forwardRef } from "react"; 2 - import { StyleSheet, View } from "react-native"; 1 + import { forwardRef, type ComponentProps } from "react"; 2 + import { StyleSheet } from "react-native"; 3 + import { useTheme, zero } from "../.."; 3 4 import { LICENSE_URL_LABELS } from "../../lib/metadata-constants"; 4 - import { useTheme } from "../../lib/theme/theme"; 5 5 import { Text } from "../ui/text"; 6 6 7 - export interface ContentRightsProps { 7 + const { layout, gap, mt, text: textStyles } = zero; 8 + 9 + export interface ContentRightsProps extends ComponentProps<typeof Text> { 8 10 contentRights: { 9 11 creator?: string; 10 12 copyrightNotice?: string; ··· 15 17 compact?: boolean; 16 18 } 17 19 18 - export const ContentRights = forwardRef<any, ContentRightsProps>( 19 - ({ contentRights }, ref) => { 20 - const { theme } = useTheme(); 21 - 20 + export const ContentRights = forwardRef<Text, ContentRightsProps>( 21 + ({ contentRights, compact, ...rest }, ref) => { 22 + const { zero } = useTheme(); 22 23 if (!contentRights || Object.keys(contentRights).length === 0) { 23 24 return null; 24 25 } 25 - 26 - const styles = createStyles(theme); 27 26 28 27 const formatLicense = (license: string) => { 29 28 return LICENSE_URL_LABELS[license] || license; ··· 38 37 // } 39 38 40 39 if (contentRights.copyrightYear) { 41 - elements.push(`© ${contentRights.copyrightYear.toString()}`); 40 + elements.push( 41 + `© ${contentRights.copyrightYear.toString()}${contentRights.creditLine ? " " + contentRights.creditLine : ""}`, 42 + ); 43 + } else if (contentRights.creditLine) { 44 + elements.push(contentRights.creditLine); 42 45 } 43 46 44 47 if (contentRights.license) { ··· 49 52 elements.push(contentRights.copyrightNotice); 50 53 } 51 54 52 - if (contentRights.creditLine) { 53 - elements.push(contentRights.creditLine); 55 + if (elements.length > 0) { 56 + elements[0] = "Stream content is " + elements[0]; 57 + } 58 + 59 + if (elements.length == 0) { 60 + return null; 54 61 } 55 62 56 63 return ( 57 - <View ref={ref} style={styles.compactContainer}> 58 - <Text style={styles.compactText}>{elements.join(" • ")}</Text> 59 - </View> 64 + <Text 65 + ref={ref} 66 + style={[ 67 + zero.text.mutedForeground, 68 + mt[1], 69 + StyleSheet.flatten(rest.style), 70 + ]} 71 + {...rest} 72 + > 73 + {elements.join(" • ")} 74 + </Text> 60 75 ); 61 76 }, 62 77 ); 63 78 64 79 ContentRights.displayName = "ContentRights"; 65 - 66 - function createStyles(theme: any) { 67 - return StyleSheet.create({ 68 - container: { 69 - paddingVertical: theme.spacing[3], 70 - }, 71 - title: { 72 - fontSize: 14, 73 - fontWeight: "600", 74 - color: theme.colors.text, 75 - marginBottom: theme.spacing[2], 76 - }, 77 - content: { 78 - gap: theme.spacing[2], 79 - }, 80 - row: { 81 - flexDirection: "row", 82 - gap: theme.spacing[2], 83 - }, 84 - label: { 85 - fontSize: 13, 86 - color: theme.colors.textMuted, 87 - }, 88 - value: { 89 - fontSize: 13, 90 - color: theme.colors.text, 91 - }, 92 - compactContainer: { 93 - flexDirection: "row", 94 - gap: theme.spacing[2], 95 - flexWrap: "wrap", 96 - marginTop: theme.spacing[1], 97 - }, 98 - compactText: { 99 - fontSize: 14, 100 - fontWeight: "500", 101 - color: theme.colors.text, 102 - }, 103 - }); 104 - }
+94
js/components/src/components/content-metadata/content-warning-badge.tsx
··· 1 + import { AlertTriangle, ChevronDown } from "lucide-react-native"; 2 + import { View } from "react-native"; 3 + import { zero } from "../.."; 4 + import { C2PA_WARNING_LABELS } from "../../lib/metadata-constants"; 5 + import { useTheme } from "../../lib/theme/theme"; 6 + import { pt, r } from "../../ui"; 7 + import { 8 + DropdownMenu, 9 + DropdownMenuTrigger, 10 + ResponsiveDropdownMenuContent, 11 + } from "../ui/dropdown"; 12 + import { Text } from "../ui/text"; 13 + 14 + const { px, py, gap, layout } = zero; 15 + 16 + export interface ContentWarningBadgeProps { 17 + warnings: string[]; 18 + } 19 + 20 + export function ContentWarningBadge({ warnings }: ContentWarningBadgeProps) { 21 + const { theme } = useTheme(); 22 + 23 + const getWarningLabel = (warning: string): string => { 24 + return C2PA_WARNING_LABELS[warning] || warning; 25 + }; 26 + 27 + if (!warnings || warnings.length === 0) { 28 + return null; 29 + } 30 + 31 + return ( 32 + <DropdownMenu> 33 + <DropdownMenuTrigger> 34 + <View 35 + style={[ 36 + layout.flex.row, 37 + layout.flex.align.center, 38 + gap.all[2], 39 + px[3], 40 + py[2], 41 + r.md, 42 + { backgroundColor: theme.colors.warning + "20" }, 43 + ]} 44 + > 45 + <AlertTriangle size={14} color={theme.colors.warningForeground} /> 46 + <Text 47 + size="sm" 48 + weight="semibold" 49 + style={{ color: theme.colors.warningForeground }} 50 + > 51 + Intended for certain audiences 52 + </Text> 53 + <ChevronDown size={14} color={theme.colors.warningForeground} /> 54 + </View> 55 + </DropdownMenuTrigger> 56 + 57 + <ResponsiveDropdownMenuContent> 58 + <View style={[layout.flex.column, px[2], pt[2]]}> 59 + <Text>Heads up!</Text> 60 + <Text>This stream may contain:</Text> 61 + </View> 62 + <View 63 + style={[ 64 + layout.flex.row, 65 + { flexWrap: "wrap" }, 66 + gap.all[2], 67 + px[2], 68 + py[2], 69 + ]} 70 + > 71 + {warnings.map((warning, index) => ( 72 + <View 73 + key={index} 74 + style={[ 75 + { backgroundColor: theme.colors.warning }, 76 + px[3], 77 + py[1], 78 + r.full, 79 + ]} 80 + > 81 + <Text 82 + size="sm" 83 + weight="semibold" 84 + style={{ color: theme.colors.warningForeground }} 85 + > 86 + {getWarningLabel(warning)} 87 + </Text> 88 + </View> 89 + ))} 90 + </View> 91 + </ResponsiveDropdownMenuContent> 92 + </DropdownMenu> 93 + ); 94 + }
+46 -58
js/components/src/components/content-metadata/content-warnings.tsx
··· 1 1 import { forwardRef } from "react"; 2 - import { StyleSheet, View } from "react-native"; 2 + import { View } from "react-native"; 3 + import { zero } from "../.."; 3 4 import { C2PA_WARNING_LABELS } from "../../lib/metadata-constants"; 4 5 import { useTheme } from "../../lib/theme/theme"; 5 6 import { Text } from "../ui/text"; 6 7 8 + const { layout, gap, bg, r, p, px, py, text: textStyles, borders } = zero; 9 + 7 10 export interface ContentWarningsProps { 8 11 warnings: string[]; 9 12 compact?: boolean; 10 13 } 11 14 12 - export const ContentWarnings = forwardRef<any, ContentWarningsProps>( 13 - ({ warnings, compact = false }, ref) => { 15 + export const ContentWarnings = forwardRef<View, ContentWarningsProps>( 16 + ({ warnings, compact = false, ...rest }, ref) => { 14 17 const { theme } = useTheme(); 15 18 16 19 if (!warnings || warnings.length === 0) { 17 20 return null; 18 21 } 19 22 20 - const styles = createStyles(theme, compact); 21 - 22 23 const getWarningLabel = (warning: string): string => { 23 24 return C2PA_WARNING_LABELS[warning] || warning; 24 25 }; 25 26 26 27 if (compact) { 27 28 return ( 28 - <View ref={ref} style={styles.compactContainer}> 29 + <View 30 + ref={ref} 31 + style={[layout.flex.row, layout.flex.wrap.wrap, gap.all[1]]} 32 + {...rest} 33 + > 29 34 {warnings.map((warning, index) => ( 30 - <View key={index} style={styles.compactWarning}> 31 - <Text style={styles.compactWarningText}> 35 + <View 36 + key={index} 37 + style={[ 38 + { backgroundColor: theme.colors.warning }, 39 + r.full, 40 + px[2], 41 + { paddingVertical: 2 }, 42 + ]} 43 + > 44 + <Text 45 + size="sm" 46 + style={[{ color: theme.colors.warningForeground }]} 47 + > 32 48 {getWarningLabel(warning)} 33 49 </Text> 34 50 </View> ··· 38 54 } 39 55 40 56 return ( 41 - <View ref={ref} style={styles.container}> 42 - <Text style={styles.title}>Content Warnings</Text> 43 - <View style={styles.warningsContainer}> 57 + <View ref={ref} style={[layout.flex.column, gap.all[2]]} {...rest}> 58 + <Text 59 + style={[{ fontSize: 14, fontWeight: "600" }, textStyles.gray[900]]} 60 + > 61 + Content Warnings 62 + </Text> 63 + <View style={[layout.flex.row, layout.flex.wrap.wrap, gap.all[2]]}> 44 64 {warnings.map((warning, index) => ( 45 - <View key={index} style={styles.warning}> 46 - <Text style={styles.warningText}>{getWarningLabel(warning)}</Text> 65 + <View 66 + key={index} 67 + style={[ 68 + { backgroundColor: theme.colors.warning }, 69 + r.full, 70 + px[3], 71 + py[1], 72 + ]} 73 + > 74 + <Text 75 + size="sm" 76 + style={[{ color: theme.colors.warningForeground }]} 77 + > 78 + {getWarningLabel(warning)} 79 + </Text> 47 80 </View> 48 81 ))} 49 82 </View> ··· 53 86 ); 54 87 55 88 ContentWarnings.displayName = "ContentWarnings"; 56 - 57 - function createStyles(theme: any, compact: boolean) { 58 - return StyleSheet.create({ 59 - container: { 60 - flexDirection: "column", 61 - gap: theme.spacing[2], 62 - }, 63 - title: { 64 - fontSize: 14, 65 - fontWeight: "600", 66 - color: theme.colors.text, 67 - }, 68 - warningsContainer: { 69 - flexDirection: "row", 70 - flexWrap: "wrap", 71 - gap: theme.spacing[2], 72 - }, 73 - warning: { 74 - backgroundColor: theme.colors.warning, 75 - borderRadius: theme.borderRadius.md, 76 - padding: theme.spacing[2], 77 - }, 78 - warningText: { 79 - color: theme.colors.warningForeground, 80 - fontSize: 12, 81 - fontWeight: "500", 82 - }, 83 - compactContainer: { 84 - flexDirection: "row", 85 - flexWrap: "wrap", 86 - gap: theme.spacing[1], 87 - }, 88 - compactWarning: { 89 - backgroundColor: theme.colors.warning, 90 - borderRadius: theme.borderRadius.full, 91 - paddingHorizontal: 10, 92 - paddingVertical: 4, 93 - }, 94 - compactWarningText: { 95 - color: theme.colors.warningForeground, 96 - fontSize: 14, 97 - fontWeight: "600", 98 - }, 99 - }); 100 - }
+2
js/components/src/components/content-metadata/index.tsx
··· 3 3 4 4 // Display components 5 5 export { ContentRights } from "./content-rights"; 6 + export { ContentWarningBadge } from "./content-warning-badge"; 6 7 export { ContentWarnings } from "./content-warnings"; 7 8 8 9 export type { ContentRightsProps } from "./content-rights"; 9 10 11 + export type { ContentWarningBadgeProps } from "./content-warning-badge"; 10 12 export type { ContentWarningsProps } from "./content-warnings";
+33 -1
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 1 1 import { useRootContext } from "@rn-primitives/dropdown-menu"; 2 2 import { Menu } from "lucide-react-native"; 3 3 import { Image, Linking, Platform, Pressable, View } from "react-native"; 4 - import { useAvatars, useLivestreamInfo, zero } from "../../.."; 4 + import { 5 + ContentRights, 6 + ContentWarnings, 7 + useAvatars, 8 + useLivestreamInfo, 9 + zero, 10 + } from "../../.."; 5 11 import { colors } from "../../../lib/theme"; 6 12 import { useLivestreamStore } from "../../../livestream-store"; 7 13 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 8 14 import { useGraphManager } from "../../../streamplace-store/graph"; 15 + import { gap, pt, px } from "../../../ui"; 9 16 import { 10 17 DropdownMenu, 11 18 DropdownMenuCheckboxItem, ··· 42 49 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 43 50 44 51 const { profile } = useLivestreamInfo(); 52 + 53 + console.log("profile", profile); 45 54 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 46 55 const ls = useLivestreamStore((x) => x.livestream); 56 + const segment = useLivestreamStore((x) => x.segment); 57 + 58 + // Get content rights from the latest segment 59 + const contentRights = segment?.contentRights; 60 + const contentWarnings = segment?.contentWarnings?.warnings || []; 47 61 48 62 let graphManager = useGraphManager(profile?.did); 49 63 ··· 160 174 </DropdownMenuItem> 161 175 </DropdownMenuGroup> 162 176 )} 177 + 163 178 <DropdownMenuGroup title="Resolution"> 164 179 <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}> 165 180 <DropdownMenuRadioItem value="source"> ··· 196 211 setReportSubject={setReportSubject} 197 212 /> 198 213 </DropdownMenuGroup> 214 + <View style={[pt[3], px[2], gap.all[2]]}> 215 + {contentWarnings && contentWarnings.length > 0 && ( 216 + <View style={[gap.all[1]]}> 217 + <Text size="base" color="muted"> 218 + Stream may contain 219 + </Text> 220 + <ContentWarnings warnings={contentWarnings} compact={true} /> 221 + </View> 222 + )} 223 + {contentRights && Object.keys(contentRights).length > 0 && ( 224 + <ContentRights 225 + contentRights={contentRights} 226 + size="xs" 227 + color="muted" 228 + /> 229 + )} 230 + </View> 199 231 </DropdownMenuContent> 200 232 </Portal> 201 233 </DropdownMenu>
+23 -21
js/components/src/components/ui/checkbox.tsx
··· 1 1 import { Check } from "lucide-react-native"; 2 2 import { forwardRef } from "react"; 3 3 import { StyleSheet, TouchableOpacity, View } from "react-native"; 4 - import { useTheme } from "../../lib/theme/theme"; 4 + import { Theme, useTheme } from "../../lib/theme/theme"; 5 5 import { Text } from "./text"; 6 6 7 7 export interface CheckboxProps { ··· 60 60 </View> 61 61 {(label || description) && ( 62 62 <View style={styles.content}> 63 - {label && <Text style={styles.label}>{label}</Text>} 63 + {label && ( 64 + <Text 65 + size={size === "sm" ? "sm" : size === "lg" ? "lg" : "base"} 66 + color={disabled ? "muted" : "default"} 67 + leading="snug" 68 + > 69 + {label} 70 + </Text> 71 + )} 64 72 {description && ( 65 - <Text style={styles.description}>{description}</Text> 73 + <Text 74 + size={size === "sm" ? "xs" : size === "lg" ? "base" : "sm"} 75 + color={disabled ? "muted" : "muted"} 76 + style={{ marginTop: theme.spacing[1] }} 77 + > 78 + {description} 79 + </Text> 66 80 )} 67 81 </View> 68 82 )} ··· 74 88 Checkbox.displayName = "Checkbox"; 75 89 76 90 function createStyles( 77 - theme: any, 91 + theme: Theme, 78 92 size: string, 79 93 disabled: boolean, 80 94 checked: boolean, ··· 90 104 checkboxSize: 20, 91 105 borderRadius: 4, 92 106 padding: theme.spacing[1], 93 - gap: theme.spacing[2], 107 + gap: theme.spacing[1], 94 108 }, 95 109 lg: { 96 110 checkboxSize: 24, 97 111 borderRadius: 6, 98 - padding: theme.spacing[2], 99 - gap: theme.spacing[3], 112 + padding: theme.spacing[1], 113 + gap: theme.spacing[2], 100 114 }, 101 115 }; 102 116 ··· 116 130 ? theme.colors.border 117 131 : checked 118 132 ? theme.colors.primary 119 - : theme.colors.border, 133 + : theme.colors.textMuted, 120 134 borderRadius: currentSize.borderRadius, 121 135 backgroundColor: disabled 122 136 ? theme.colors.muted ··· 128 142 }, 129 143 content: { 130 144 flex: 1, 131 - paddingTop: currentSize.padding * 0.5, 145 + paddingTop: currentSize.padding * 0.25, 132 146 paddingLeft: theme.spacing[2], 133 - }, 134 - label: { 135 - fontSize: size === "sm" ? 14 : size === "lg" ? 18 : 16, 136 - fontWeight: "500", 137 - color: disabled ? theme.colors.textDisabled : theme.colors.text, 138 - lineHeight: size === "sm" ? 18 : size === "lg" ? 22 : 20, 139 - }, 140 - description: { 141 - fontSize: size === "sm" ? 12 : size === "lg" ? 16 : 14, 142 - color: disabled ? theme.colors.textDisabled : theme.colors.textMuted, 143 - marginTop: theme.spacing[1], 144 - lineHeight: size === "sm" ? 16 : size === "lg" ? 20 : 18, 145 147 }, 146 148 }); 147 149 }
+60 -40
js/components/src/components/ui/dropdown.tsx
··· 8 8 ChevronUp, 9 9 Circle, 10 10 } from "lucide-react-native"; 11 - import React, { forwardRef, ReactNode, useMemo, useRef } from "react"; 11 + import React, { forwardRef, ReactNode, useRef } from "react"; 12 12 import { 13 13 Platform, 14 14 Pressable, 15 + ScrollView, 15 16 StyleSheet, 16 17 useWindowDimensions, 17 18 View, 18 19 } from "react-native"; 20 + import { zero } from "../.."; 19 21 import { 20 22 a, 21 23 borderRadius, ··· 53 55 portalHost?: string; 54 56 } 55 57 >(function DropdownMenuBottomSheet( 56 - { overlayStyle, portalHost, children }, 58 + { overlayStyle, portalHost, children, ...rest }, 57 59 _ref, 58 60 ) { 59 61 // Use the primitives' context to know if open 60 62 const { open, onOpenChange } = DropdownMenuPrimitive.useRootContext(); 61 63 const { zero: zt } = useTheme(); 62 - const snapPoints = useMemo(() => ["25%", "50%", "80%"], []); 63 64 const sheetRef = useRef<BottomSheet>(null); 64 65 65 66 return ( 66 67 <DropdownMenuPrimitive.Portal hostName={portalHost}> 67 68 <BottomSheet 68 69 ref={sheetRef} 69 - // why the heck is this 1-indexed 70 - index={open ? 3 : -1} 71 - snapPoints={snapPoints} 72 70 enablePanDownToClose 73 71 enableDynamicSizing 74 72 enableContentPanningGesture={false} ··· 79 77 /> 80 78 )} 81 79 onClose={() => onOpenChange?.(false)} 82 - style={[overlayStyle]} 80 + style={[overlayStyle, StyleSheet.flatten(rest.style)]} 83 81 backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]} 84 82 handleIndicatorStyle={[ 85 83 a.sizes.width[12], ··· 169 167 overlayStyle?: any; 170 168 portalHost?: string; 171 169 } 172 - >(({ overlayStyle, portalHost, ...props }, ref) => { 170 + >(({ overlayStyle, portalHost, style, children, ...props }, ref) => { 173 171 const { zero: zt } = useTheme(); 174 172 return ( 175 173 <DropdownMenuPrimitive.Portal hostName={portalHost}> ··· 193 191 zt.bg.popover, 194 192 p[2], 195 193 a.shadows.md, 194 + style, 196 195 ] as any 197 196 } 198 197 {...props} 199 - /> 198 + > 199 + <ScrollView showsVerticalScrollIndicator={true}> 200 + {typeof children === "function" 201 + ? children({ pressed: false }) 202 + : children} 203 + </ScrollView> 204 + </DropdownMenuPrimitive.Content> 200 205 </DropdownMenuPrimitive.Overlay> 201 206 </DropdownMenuPrimitive.Portal> 202 207 ); ··· 206 211 any, 207 212 DropdownMenuPrimitive.ContentProps & { 208 213 overlayStyle?: any; 214 + maxHeightPercentage?: number; 209 215 } 210 - >(({ overlayStyle, ...props }, ref) => { 211 - const { theme } = useTheme(); 212 - return ( 213 - <DropdownMenuPrimitive.Overlay 214 - style={[ 215 - Platform.OS !== "web" ? StyleSheet.absoluteFill : undefined, 216 - overlayStyle, 217 - ]} 218 - > 219 - <DropdownMenuPrimitive.Content 220 - ref={ref} 221 - style={ 222 - [ 223 - { zIndex: 999999 }, 224 - a.sizes.minWidth[32], 225 - a.sizes.maxWidth[64], 226 - a.overflow.hidden, 227 - a.radius.all.md, 228 - a.borders.width.thin, 229 - { borderColor: theme.colors.border }, 230 - { backgroundColor: theme.colors.popover }, 231 - p[2], 232 - a.shadows.md, 233 - ] as any 234 - } 235 - {...props} 236 - /> 237 - </DropdownMenuPrimitive.Overlay> 238 - ); 239 - }); 216 + >( 217 + ( 218 + { overlayStyle, maxHeightPercentage = 0.8, children, style, ...props }, 219 + ref, 220 + ) => { 221 + const { theme } = useTheme(); 222 + const { height } = useWindowDimensions(); 223 + const maxHeight = height * maxHeightPercentage; 224 + 225 + return ( 226 + <DropdownMenuPrimitive.Overlay 227 + style={[ 228 + Platform.OS !== "web" ? StyleSheet.absoluteFill : undefined, 229 + overlayStyle, 230 + ]} 231 + > 232 + <DropdownMenuPrimitive.Content 233 + ref={ref} 234 + style={ 235 + [ 236 + { zIndex: 999999 }, 237 + a.sizes.minWidth[32], 238 + a.sizes.maxWidth[64], 239 + a.radius.all.md, 240 + a.borders.width.thin, 241 + { borderColor: theme.colors.border }, 242 + { backgroundColor: theme.colors.popover }, 243 + p[2], 244 + a.shadows.md, 245 + style, 246 + ] as any 247 + } 248 + {...props} 249 + > 250 + <ScrollView style={{ maxHeight }} showsVerticalScrollIndicator={true}> 251 + {typeof children === "function" 252 + ? children({ pressed: false }) 253 + : children} 254 + </ScrollView> 255 + </DropdownMenuPrimitive.Content> 256 + </DropdownMenuPrimitive.Overlay> 257 + ); 258 + }, 259 + ); 240 260 241 261 /// Responsive Dropdown Menu Content. On mobile this will render a *bottom sheet* that is **portaled to the root of the app**. 242 262 /// Prefer passing scoped content in as **otherwise it may crash the app**. ··· 250 270 if (isBottomSheet) { 251 271 return ( 252 272 <DropdownMenuBottomSheet ref={ref} {...props}> 253 - {children} 273 + <ScrollView style={[zero.pb[12]]}>{children}</ScrollView> 254 274 </DropdownMenuBottomSheet> 255 275 ); 256 276 }
+24 -4
js/components/src/components/ui/primitives/text.tsx
··· 277 277 const inheritedContext = 278 278 inherit && !reset && parentContext ? parentContext : {}; 279 279 280 + // Calculate fontSize first for line height calculation 281 + let calculatedFontSize = inheritedContext.fontSize; 282 + 283 + // Apply variant font size 284 + if (variant && variantStyles[variant]?.fontSize) { 285 + calculatedFontSize = variantStyles[variant].fontSize as number; 286 + } 287 + 288 + // Apply size-based font size 289 + if (size) { 290 + calculatedFontSize = typeof size === "number" ? size : sizeMap[size]; 291 + } 292 + 293 + // Use default if still undefined 294 + calculatedFontSize = calculatedFontSize || 16; 295 + 280 296 // Calculate final styles 281 297 const finalStyles: TextStyle = { 282 298 // Start with inherited values ··· 344 360 345 361 // Apply line height 346 362 ...(leading && { 347 - lineHeight: typeof leading === "number" ? leading : leadingMap[leading], 363 + lineHeight: 364 + typeof leading === "number" 365 + ? leading 366 + : leadingMap[leading] * calculatedFontSize, 348 367 }), 349 368 350 369 // Apply letter spacing ··· 389 408 if (typeof fontSize === "number" && !styleObj.lineHeight && !leading) { 390 409 return { 391 410 ...styleObj, 392 - lineHeight: fontSize * 1.2, 411 + lineHeight: fontSize, 393 412 }; 394 413 } 395 414 } ··· 478 497 if (props.leading === undefined) { 479 498 style.lineHeight = 480 499 typeof props.size === "number" 481 - ? props.size * 1.2 // Auto line height for numeric sizes 500 + ? props.size 482 501 : sizeLineHeightMap[props.size]; 483 502 } 484 503 } ··· 492 511 } 493 512 494 513 if (props.leading) { 514 + const fontSize = style.fontSize || 16; // default font size 495 515 style.lineHeight = 496 516 typeof props.leading === "number" 497 517 ? props.leading 498 - : leadingMap[props.leading]; 518 + : leadingMap[props.leading] * fontSize; 499 519 } 500 520 501 521 if (props.tracking) {
+97 -125
js/components/src/components/ui/select.tsx
··· 1 - import { ChevronDown } from "lucide-react-native"; 2 - import { forwardRef, useState } from "react"; 3 - import { 4 - FlatList, 5 - Modal, 6 - StyleSheet, 7 - TouchableOpacity, 8 - View, 9 - } from "react-native"; 1 + import { Check, ChevronDown } from "lucide-react-native"; 2 + import { forwardRef } from "react"; 3 + import { View } from "react-native"; 4 + import { zero } from "../.."; 10 5 import { useTheme } from "../../lib/theme/theme"; 6 + import { flex } from "../../ui"; 7 + import { 8 + DropdownMenu, 9 + DropdownMenuItem, 10 + DropdownMenuSeparator, 11 + DropdownMenuTrigger, 12 + ResponsiveDropdownMenuContent, 13 + } from "./dropdown"; 11 14 import { Text } from "./text"; 12 15 16 + const { layout, px, py, borders, r, gap } = zero; 17 + 13 18 export interface SelectItem { 14 19 label: string; 15 20 value: string; ··· 25 30 style?: any; 26 31 } 27 32 28 - export const Select = forwardRef<any, SelectProps>( 33 + export const Select = forwardRef<View, SelectProps>( 29 34 ( 30 35 { 31 36 value, ··· 38 43 ref, 39 44 ) => { 40 45 const { theme } = useTheme(); 41 - const [isOpen, setIsOpen] = useState(false); 42 46 43 47 const selectedItem = items.find((item) => item.value === value); 44 48 45 - const handleSelect = (itemValue: string) => { 46 - onValueChange(itemValue); 47 - setIsOpen(false); 48 - }; 49 - 50 - const styles = createStyles(theme, disabled); 51 - 52 49 return ( 53 - <> 54 - <TouchableOpacity 55 - ref={ref} 56 - style={[styles.container, style]} 57 - onPress={() => !disabled && setIsOpen(true)} 58 - disabled={disabled} 59 - > 60 - <Text style={styles.value}>{selectedItem?.label || placeholder}</Text> 61 - <ChevronDown size={16} color={theme.colors.textMuted} /> 62 - </TouchableOpacity> 50 + <DropdownMenu> 51 + <DropdownMenuTrigger disabled={disabled}> 52 + <View 53 + ref={ref} 54 + style={[ 55 + { 56 + width: "100%", 57 + paddingHorizontal: theme.spacing[3], 58 + paddingVertical: theme.spacing[3], 59 + borderWidth: 1, 60 + borderColor: theme.colors.border, 61 + borderRadius: theme.borderRadius.md, 62 + backgroundColor: disabled 63 + ? theme.colors.muted 64 + : theme.colors.card, 65 + minHeight: theme.touchTargets.minimum, 66 + opacity: disabled ? 0.5 : 1, 67 + }, 68 + style, 69 + ]} 70 + > 71 + <View 72 + style={{ 73 + flexDirection: "row", 74 + alignItems: "center", 75 + justifyContent: "space-between", 76 + width: "100%", 77 + gap: 8, 78 + }} 79 + > 80 + <Text 81 + style={{ 82 + fontSize: 16, 83 + color: disabled 84 + ? theme.colors.textDisabled 85 + : theme.colors.text, 86 + flex: 1, 87 + }} 88 + > 89 + {selectedItem?.label || placeholder} 90 + </Text> 91 + <ChevronDown size={16} color={theme.colors.textMuted} /> 92 + </View> 93 + </View> 94 + </DropdownMenuTrigger> 63 95 64 - <Modal 65 - visible={isOpen} 66 - transparent 67 - animationType="fade" 68 - onRequestClose={() => setIsOpen(false)} 96 + <ResponsiveDropdownMenuContent 97 + align="start" 98 + style={[ 99 + { 100 + maxHeight: 400, 101 + }, 102 + ]} 69 103 > 70 - <TouchableOpacity 71 - style={styles.overlay} 72 - activeOpacity={1} 73 - onPress={() => setIsOpen(false)} 74 - > 75 - <View style={styles.dropdown}> 76 - <FlatList 77 - data={items} 78 - keyExtractor={(item) => item.value} 79 - renderItem={({ item }) => ( 80 - <TouchableOpacity 81 - style={[ 82 - styles.item, 83 - item.value === value && styles.selectedItem, 84 - ]} 85 - onPress={() => handleSelect(item.value)} 86 - > 104 + {items.map((item, index) => ( 105 + <View key={item.value}> 106 + <DropdownMenuItem onPress={() => onValueChange(item.value)}> 107 + <View 108 + style={{ 109 + flexDirection: "row", 110 + alignItems: "center", 111 + justifyContent: "space-between", 112 + width: "100%", 113 + gap: 8, 114 + }} 115 + > 116 + <View style={[gap.all[1], py[1], flex.values[1]]}> 87 117 <Text 88 - style={[ 89 - styles.itemText, 90 - item.value === value ? styles.selectedItemText : {}, 91 - ]} 118 + style={{ 119 + fontWeight: item.value === value ? "500" : "400", 120 + }} 121 + color={item.value === value ? "primary" : "default"} 92 122 > 93 123 {item.label} 94 124 </Text> 95 125 {item.description && ( 96 - <Text style={styles.itemDescription}> 126 + <Text size="sm" color="muted"> 97 127 {item.description} 98 128 </Text> 99 129 )} 100 - </TouchableOpacity> 101 - )} 102 - style={styles.list} 103 - /> 130 + </View> 131 + {item.value === value ? ( 132 + <Check size={16} color={theme.colors.primary} /> 133 + ) : ( 134 + <View style={{ width: 16 }} /> 135 + )} 136 + </View> 137 + </DropdownMenuItem> 138 + {index < items.length - 1 && <DropdownMenuSeparator />} 104 139 </View> 105 - </TouchableOpacity> 106 - </Modal> 107 - </> 140 + ))} 141 + </ResponsiveDropdownMenuContent> 142 + </DropdownMenu> 108 143 ); 109 144 }, 110 145 ); 111 146 112 147 Select.displayName = "Select"; 113 - 114 - function createStyles(theme: any, disabled: boolean) { 115 - return StyleSheet.create({ 116 - container: { 117 - flexDirection: "row", 118 - alignItems: "center", 119 - justifyContent: "space-between", 120 - paddingHorizontal: theme.spacing[3], 121 - paddingVertical: theme.spacing[3], 122 - borderWidth: 1, 123 - borderColor: theme.colors.border, 124 - borderRadius: theme.borderRadius.md, 125 - backgroundColor: disabled ? theme.colors.muted : theme.colors.card, 126 - minHeight: theme.touchTargets.minimum, 127 - }, 128 - value: { 129 - fontSize: 16, 130 - color: disabled ? theme.colors.textDisabled : theme.colors.text, 131 - flex: 1, 132 - }, 133 - overlay: { 134 - flex: 1, 135 - backgroundColor: "rgba(0, 0, 0, 0.5)", 136 - justifyContent: "center", 137 - alignItems: "center", 138 - }, 139 - dropdown: { 140 - backgroundColor: theme.colors.background, 141 - borderRadius: theme.borderRadius.md, 142 - borderWidth: 1, 143 - borderColor: theme.colors.border, 144 - maxHeight: 300, 145 - width: "90%", 146 - maxWidth: 400, 147 - ...theme.shadows.lg, 148 - }, 149 - list: { 150 - maxHeight: 300, 151 - }, 152 - item: { 153 - paddingHorizontal: theme.spacing[4], 154 - paddingVertical: theme.spacing[3], 155 - borderBottomWidth: 1, 156 - borderBottomColor: theme.colors.border, 157 - }, 158 - selectedItem: { 159 - backgroundColor: theme.colors.primary, 160 - }, 161 - itemText: { 162 - fontSize: 16, 163 - color: theme.colors.text, 164 - }, 165 - selectedItemText: { 166 - color: theme.colors.primaryForeground, 167 - fontWeight: "500", 168 - }, 169 - itemDescription: { 170 - fontSize: 14, 171 - color: theme.colors.textMuted, 172 - marginTop: theme.spacing[1], 173 - }, 174 - }); 175 - }